<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://jsnote.top/</id>
  <title type="text">LearnSpider 笔记</title>
  <subtitle type="text">爬虫与反爬实战</subtitle>
  <updated>2026-10-03T10:03:10.000Z</updated>
  <author><name>xiajiao</name></author>
  <link rel="alternate" href="https://jsnote.top/"/>
  <link rel="self" href="https://jsnote.top/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://jsnote.top/posts/ls-ch15/</id>
      <title type="text">第 15 关：蜜罐与爬虫封禁 —— 数据是假的，陷阱是真的</title>
      <published>2026-10-03T10:03:10.000Z</published>
      <updated>2026-10-03T10:03:10.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch15/"/>
      <summary type="text">本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页：不加密、不验签、不弹验证码，5 页 × 10 条公告，谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是<strong>最普通不过的一个 HTML 列表页</strong>：不加密、不验签、不弹验证码，5 页 × 10 条公告，谁都能抓。难的是你不知道自己抓到的<strong>是不是真的</strong> —— 页面里埋了三类「只给爬虫准备」的东西：看不见的行会被当成数据，看不见的链接和字段碰一下就进小黑屋，之后拿到的每一份数据都对不上。这一关真正要带走的两条习惯是：<strong>抓表格前先做可见性过滤</strong>、<strong>拿到数据先做一致性校验</strong>。</p></blockquote>
<p>靶场：<code>/playground/trap</code>（登录后打开）　参考解法：<code>参考代码/s17.py</code></p>
<section><h2>现象<a href="#现象"><span>#</span></a></h2><ol>
<li>打开靶场，就是一个普通的公告列表：<code>公告编号 / 项目名称 / 金额（万元）/ 更新于</code>，页脚写着「<strong>本页 10 条</strong> · 第 1 / 5 页」。</li>
<li>分页也普通：<code>1 2 3 4 5</code> +「下一页」，下面还有个「跳转到第 N 页」的下拉框。</li>
<li>但 F12 一看就不对劲：DOM 里 <code>table tbody tr</code> 有 <strong>13 个</strong>，页脚却说 10 条。</li>
<li>用最常见的一把梭抓法（<code>pandas.read_html</code>）抓下来 → <strong>每页 12 行</strong>（它只认内联 <code>display:none</code>，靠类名隐藏的两行照样抓）→ 5 页求和 → <strong>提交上去判错</strong>。</li>
<li>更阴的是：如果你遍历了页面上所有 <code>&lt;a href&gt;</code>（或者把页面里所有 <code>&lt;input&gt;</code> 拼成请求参数），接下来同一页<strong>两次抓到的数字就不一样了</strong>，而且整页「更新于」全挤在同一秒。</li>
<li>全程<strong>没有任何报错</strong>：不 403、不弹验证码、不封 IP，HTTP 200，页面长得一模一样。</li>
</ol></section>
<section><h2>为什么「喂假数据」比「把门关上」更狠<a href="#为什么喂假数据比把门关上更狠"><span>#</span></a></h2>

<table><thead><tr><th>拦截方式</th><th>爬虫的感受</th><th>拦截方的成本</th></tr></thead><tbody><tr><td>403 / 封 IP / 加验证码</td><td>立刻知道被拦了 —— 换 IP、换 UA、上打码、上代理池</td><td>要维护封禁名单与验证码服务，还会误伤真人</td></tr><tr><td><strong>蜜罐 + 污染数据</strong></td><td><strong>根本不知道被拦了</strong>，拿着错数据继续跑几小时，最后算出来的结果一直是错的</td><td>只要多做一套假数据；真人访问完全无感</td></tr></tbody></table><p>现实里的例子：招聘网站给爬虫返回”看起来正常、但根本不存在”的职位；电商给爬虫返回随机价格；行政区划接口给爬虫塞几个幽灵条目。目的都一样 —— <strong>让你拿着脏数据走</strong>，而不是被你发现。</p><p>所以这一关的判错不会告诉你”你踩陷阱了”，只会说”答案不正确”。<strong>你得自己证明手里的数据是真的。</strong></p></section>
<section><h2>第一步：把「看不见的东西」找出来<a href="#第一步把看不见的东西找出来"><span>#</span></a></h2><p>「看不见」在网页里不止一种写法，抓取时要一起判断：</p>

<table><thead><tr><th>写法</th><th>谁看不见</th><th>备注</th></tr></thead><tbody><tr><td>内联 <code>style="display:none"</code></td><td>所有人</td><td>不从渲染树里出现，<code>pandas.read_html</code> 也会跳过它</td></tr><tr><td>CSS 类 <code>display:none</code></td><td>所有人</td><td>⚠️ <code>pandas.read_html</code> <strong>照样吃</strong>（它只认内联样式，不解析样式表），按 <code>&lt;tr&gt;</code> 抓就更不用说了</td></tr><tr><td>CSS 类 <code>visibility:hidden</code></td><td>所有人</td><td>⚠️ <strong>占着位置、<code>read_html</code> 也照吃</strong> —— 最容易漏的一种</td></tr><tr><td>离屏 <code>position:absolute; left:-9999px; width:1px; height:1px</code></td><td>所有人</td><td>无障碍工具的经典写法，常被拿来当蜜罐</td></tr><tr><td><code>opacity:0</code> / <code>height:0</code> / <code>overflow:hidden</code></td><td>所有人</td><td>同上</td></tr><tr><td><code>aria-hidden="true"</code> / <code>hidden</code> 属性</td><td>所有人 + 屏幕阅读器</td><td>语义层就已经声明”别读”</td></tr><tr><td><code>&lt;template&gt;</code> / <code>&lt;script type="text/template"&gt;</code></td><td>所有人</td><td>模板数据，不是渲染结果</td></tr></tbody></table><p>所以判断”能不能抓”的正确姿势是：<strong>先把页面里的 CSS 规则读明白，再逐行判断</strong>，而不是只认 <code>display:none</code>：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> re</span></div></div><div><div><div>2</div></div><div><span>from</span><span> bs4 </span><span>import</span><span> BeautifulSoup</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>HIDDEN_HINTS</span><span> </span><span>=</span><span> (</span></span><span>"display:none"</span><span>, </span><span>"visibility:hidden"</span><span>, </span><span>"left:-9999px"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>                </span><span>"opacity:0"</span><span>, </span><span>"height:0"</span><span>, </span><span>"width:0"</span><span>)</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>def</span><span> </span><span>hidden_class_names</span><span>(</span><span>html</span><span>:</span><span> </span><span>str</span><span>) -&gt; set[</span><span>str</span><span>]:</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"""把 &lt;style&gt; 里认定「看不见」的类名挑出来。"""</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>soup </span><span>=</span><span> </span><span>BeautifulSoup</span><span>(html, </span><span>"html.parser"</span><span>)</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>css </span><span>=</span><span> </span><span>"</span><span>\n</span><span>"</span><span><span>.</span><span>join</span><span>(tag.</span><span>get_text</span><span>() </span></span><span>for</span><span> tag </span><span>in</span><span><span> soup.</span><span>find_all</span><span>(</span></span><span>"style"</span><span>))</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>names </span><span>=</span><span> </span><span>set</span><span>()</span></div></div><div><div><div>12</div></div><div><span>    </span><span>for</span><span> match </span><span>in</span><span><span> re.</span><span>finditer</span><span>(</span></span><span>r</span><span><span>"</span><span>\.</span><span>(</span></span><span>[</span><span>A-Za-z0-9_-</span><span>]+</span><span><span>)</span><span>\s</span></span><span>*</span><span><span>\{</span><span>(</span></span><span><span>[</span><span>^</span></span><span>}</span><span>]*</span><span><span>)</span><span>\}</span><span>"</span></span><span>, css):</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>body </span><span>=</span><span> re.</span><span>sub</span><span>(</span><span>r</span><span>"\s</span><span>+</span><span>"</span><span>, </span><span>""</span><span><span>, match.</span><span>group</span><span>(</span></span><span>2</span><span><span>)).</span><span>lower</span><span>()</span></span></div></div><div><div><div>14</div></div><div><span>        </span><span>if</span><span> </span><span>any</span><span>(hint </span><span>in</span><span> body </span><span>for</span><span> hint </span><span>in</span><span><span> </span><span>HIDDEN_HINTS</span><span>):</span></span></div></div><div><div><div>15</div></div><div><span><span>            </span></span><span>names.</span><span>add</span><span>(match.</span><span>group</span><span>(</span><span>1</span><span>))</span></div></div><div><div><div>16</div></div><div><span>    </span><span>return</span><span> names</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>def</span><span> </span><span>is_hidden</span><span>(</span><span>tag</span><span>,</span><span><span> </span><span>hidden_classes</span></span><span>:</span><span> set[</span><span>str</span><span>]</span><span>) -&gt; </span><span>bool</span><span>:</span></div></div><div><div><div>19</div></div><div><span>    </span><span>"""这个元素人眼看得见吗？（内联样式 / 类名 / aria-hidden / hidden 属性）"""</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>inline </span><span>=</span><span> re.</span><span>sub</span><span>(</span><span>r</span><span>"\s</span><span>+</span><span>"</span><span>, </span><span>""</span><span><span>, (tag.</span><span>get</span><span>(</span></span><span>"style"</span><span>) </span><span>or</span><span> </span><span>""</span><span><span>)).</span><span>lower</span><span>()</span></span></div></div><div><div><div>21</div></div><div><span>    </span><span>if</span><span> </span><span>any</span><span>(hint </span><span>in</span><span> inline </span><span>for</span><span> hint </span><span>in</span><span><span> </span><span>HIDDEN_HINTS</span><span>):</span></span></div></div><div><div><div>22</div></div><div><span>        </span><span>return</span><span> </span><span>True</span></div></div><div><div><div>23</div></div><div><span>    </span><span>if</span><span> </span><span>set</span><span><span>(tag.</span><span>get</span><span>(</span></span><span>"class"</span><span>) </span><span>or</span><span><span> []) </span><span>&amp;</span><span> hidden_classes:</span></span></div></div><div><div><div>24</div></div><div><span>        </span><span>return</span><span> </span><span>True</span></div></div><div><div><div>25</div></div><div><span>    </span><span>if</span><span><span> tag.</span><span>get</span><span>(</span></span><span>"aria-hidden"</span><span><span>) </span><span>==</span><span> </span></span><span>"true"</span><span> </span><span>or</span><span><span> tag.</span><span>has_attr</span><span>(</span></span><span>"hidden"</span><span>):</span></div></div><div><div><div>26</div></div><div><span>        </span><span>return</span><span> </span><span>True</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>parent </span><span>=</span><span> tag.parent            </span><span># 隐藏也可能是包在看不见的父节点里</span></div></div><div><div><div>28</div></div><div><span>    </span><span>while</span><span> parent </span><span>is</span><span> </span><span>not</span><span> </span><span>None</span><span> </span><span>and</span><span> </span><span>getattr</span><span>(parent, </span><span>"name"</span><span>, </span><span>None</span><span>):</span></div></div><div><div><div>29</div></div><div><span>        </span><span>if</span><span><span> parent.</span><span>get</span><span>(</span></span><span>"aria-hidden"</span><span><span>) </span><span>==</span><span> </span></span><span>"true"</span><span> </span><span>or</span><span><span> parent.</span><span>has_attr</span><span>(</span></span><span>"hidden"</span><span>):</span></div></div><div><div><div>30</div></div><div><span>            </span><span>return</span><span> </span><span>True</span></div></div><div><div><div>31</div></div><div><span><span>        </span></span><span>parent </span><span>=</span><span> parent.parent</span></div></div><div><div><div>32</div></div><div><span>    </span><span>return</span><span> </span><span>False</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p>这一关的三行幽灵数据，正好用了三种不同的隐藏方式：内联 <code>display:none</code>、CSS 类 <code>display:none</code>（<code>.row-ghost</code>）、CSS 类 <code>visibility:hidden</code>（<code>.sp-invisible</code>）。少了任何一种判断，都会多抓 3 行。</p></blockquote></section>
<section><h2>第二步：三类典型蜜罐（这一关全有）<a href="#第二步三类典型蜜罐这一关全有"><span>#</span></a></h2><section><h3>一、隐藏行（幽灵数据）<a href="#一隐藏行幽灵数据"><span>#</span></a></h3><p>页面表格里塞几行”人看不见、DOM 里在”的记录，编号和金额都做得和真数据同量级 —— 不会一眼假。踩中的人通常是：把整张表抓下来的人（<code>//tr</code>、<code>table &gt; tbody &gt; tr</code>、<code>read_html</code>）。这一关的三行幽灵行，<code>read_html</code> 会漏掉其中 1 行（内联 <code>display:none</code> 那个），另外两行照抓 —— 这就是”半吊子过滤”的代价：数字看着像对的，一算就偏。</p><p><strong>破法</strong>：可见性过滤（第一步）+ <strong>用页面自己给的条数校验</strong>。这一关的页脚明明白白写着「本页 10 条」，抓到 13 行（或者 12 行）就该停下来查。</p></section><section><h3>二、隐藏链接（把爬虫往坑里带）<a href="#二隐藏链接把爬虫往坑里带"><span>#</span></a></h3><p>页面里除了正常的分页链接，还有几个离屏的链接：一个 “下一页”、一个 “接口调试入口”、一个带 <code>_hp=1</code> 的 “备用线路”。它们用的是 <code>.visually-hidden</code>（无障碍样式的经典写法）和 <code>.seo-aux</code>（SEO 辅助块）这种看着完全无害的类名。</p><p>注意一个容易搞错的点：这些链接<strong>在自动化工具眼里是”可见”的</strong> —— Playwright 的 <code>is_visible()</code> 只看有没有 1×1 的盒子，离屏元素照样返回 <code>true</code>。所以”人看不见”和”工具认为可见”是两件事，脚本得自己按<strong>几何位置</strong>判断：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>box </span><span>=</span><span> link.</span><span>bounding_box</span><span>()          </span></span><span># 离屏元素：x = -9999</span></div></div><div><div><div>2</div></div><div><span>if</span><span> box </span><span>and</span><span> box[</span><span>"x"</span><span><span>] </span><span>&lt;</span><span> </span></span><span>0</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>continue</span><span>                       </span><span># 这种东西不是给人点的，别跟</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>破法</strong>：分页只跟随<strong>看得见、在文档流里</strong>的链接；把页面里所有 <code>&lt;a href&gt;</code> 都跟一遍是最容易被钓鱼的习惯。</p></section><section><h3>三、隐藏表单字段（看着像统计参数）<a href="#三隐藏表单字段看着像统计参数"><span>#</span></a></h3><p>页面里有一个离屏的 GET 表单，字段是 <code>utm_source</code> / <code>utm_medium</code> / <code>refer</code>（名字取得很像埋点统计参数）。正常分页只需要一个 <code>page</code> 参数 —— 一旦你的请求 URL 里出现这几个字段，就等于举手说”我是把页面所有 <code>&lt;input&gt;</code> 拼起来发请求的脚本”。</p><p><strong>破法</strong>：只带接口需要的参数；不要无脑收集页面上的所有输入（尤其 <code>hidden</code> 类型的）。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 反面教材：把页面里所有 input 都拼成参数</span></div></div><div><div><div>2</div></div><div><span><span>params </span><span>=</span><span> {field.</span><span>get</span><span>(</span></span><span>"name"</span><span><span>): field.</span><span>get</span><span>(</span></span><span>"value"</span><span>) </span><span>for</span><span> field </span><span>in</span><span><span> soup.</span><span>find_all</span><span>(</span></span><span>"input"</span><span>)}   </span><span># ← 会带上蜜罐字段</span></div></div><div><div><div>3</div></div><div><span># 正面：只带你确认需要的</span></div></div><div><div><div>4</div></div><div><span><span>params </span><span>=</span><span> {</span></span><span>"page"</span><span>: </span><span>3</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>第三步：识别自己被「喂了假数据」<a href="#第三步识别自己被喂了假数据"><span>#</span></a></h2><p>被污染之后，服务端不会发任何通知，但留下的破绽有三处（这一关都埋了）：</p>

<table><thead><tr><th>信号</th><th>怎么用</th></tr></thead><tbody><tr><td><strong>同一页两次抓取不一致</strong></td><td>最可靠的一招：抓两次比对金额；变了就说明数据是现造的</td></tr><tr><td><strong>整页「更新于」挤在同一秒</strong></td><td>假数据是同一批生成的，时间戳没有分散性；正常页面各行更新时间不同</td></tr><tr><td><strong>页脚条数 ≠ 你抓到的行数</strong></td><td>可能是幽灵行，也可能是数据被动过</td></tr></tbody></table><p>把这三条写成脚本里的自检，比事后对着答案发呆强得多：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>first </span><span>=</span><span> </span><span>fetch</span><span>(</span></span><span>1</span><span>)</span></div></div><div><div><div>2</div></div><div><span>if</span><span> [row[</span><span>"amount"</span><span>] </span><span>for</span><span> row </span><span>in</span><span><span> </span><span>visible_rows</span><span>(first)] </span><span>!=</span><span> [row[</span></span><span>"amount"</span><span>] </span><span>for</span><span> row </span><span>in</span><span><span> </span><span>visible_rows</span><span>(</span><span>fetch</span><span>(</span></span><span>1</span><span>))]:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>print</span><span>(</span><span>"⚠️ 同一页两次金额不一样：踩到蜜罐了，现在停手等冷却"</span><span>)</span></div></div><div><div><div>4</div></div><div><span>    </span><span>return</span><span>          </span><span># 拿脏数据算下去只会得到错答案</span></div></div><div><div><div>5</div></div><div><span>if</span><span> </span><span>len</span><span><span>(</span><span>visible_rows</span><span>(first)) </span><span>!=</span><span> </span><span>footer_count</span><span>(first):</span></span></div></div><div><div><div>6</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"⚠️ 抓到 </span><span>{</span><span>len</span><span><span>(</span><span>visible_rows</span><span>(first))</span></span><span>}</span><span> 行，页脚说 </span><span>{</span><span><span>footer_count</span><span>(first)</span></span><span>}</span><span> 条：先查隐藏行"</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>为什么必须”停手”</strong>：这一关的冷却会<strong>叠加</strong>（45 秒起步，每多踩一次 +30 秒，上限 180 秒）。越”多试几次找规律”，被封得越久 —— 真实站点的风控台账也是这么累积的。</p></section>
<section><h2>第四步：被标记了怎么办<a href="#第四步被标记了怎么办"><span>#</span></a></h2><ul>
<li>本关的污染会自己到期（冷却结束、数据恢复原样）；真实站点<strong>不会</strong>，只能降低自己的可疑度或换出口。</li>
<li>换 IP / 清 Cookie <strong>不一定有用</strong>：风控看的是”这个会话/账号做过什么”。本关按 <code>user:&lt;id&gt;</code> 记账，没登录才退化成按 IP。</li>
<li>真正该做的是回头看<strong>你请求了什么</strong>：是不是跟了隐藏链接、是不是把隐藏字段带上了。把行为改干净了，污染才不会再累积。</li>
</ul><p>服务端那边只留一行日志，不会回给调用方（<strong>这就是真实站点的做法</strong>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CH-015 蜜罐命中 identity=user:1 kind=hidden-link:/playground/trap/next hits=1 接下来 45s 返回被污染的数据（不报错、不封禁）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>第五步：完整脚本<a href="#第五步完整脚本"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1) 浏览器登录靶场，F12 -&gt; Application -&gt; Cookies，抄下 session 的值</span></div></div><div><div><div>2</div></div><div><span># 2) 填进 参考代码/s17.py 的 SESSION_COOKIE</span></div></div><div><div><div>3</div></div><div><span>python</span><span> </span><span>参考代码/s17.py</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>脚本干了四件事：</p><ol>
<li>抓第 1 页，<strong>先把页面里的蜜罐列出来</strong>（隐藏链接、隐藏表单字段）—— 知道坑在哪，才知道别碰；</li>
<li>做<strong>可见性过滤</strong>，把 13 行收敛到 10 行，并和页脚的「本页 N 条」对上；</li>
<li><strong>同一页连抓两次</strong>做一致性校验，一旦发现被污染就当场停手；</li>
<li>5 页求和，提交。</li>
</ol></section>
<section><h2>反爬方视角：蜜罐怎么设计才有效<a href="#反爬方视角蜜罐怎么设计才有效"><span>#</span></a></h2><ol>
<li><strong>诱饵要比正路”更好走”</strong>：隐藏的”下一页”看着比真分页还像分页，隐藏字段看着比真参数还像参数。</li>
<li><strong>踩中不报错</strong>：一旦返回 403 或验证码，对方就知道被发现了；安静地喂假数据才有杀伤力。</li>
<li><strong>假数据要”像”</strong>：同量级、同格式、有正常的分页。一眼假的（金额 999999）只会提醒对方。</li>
<li><strong>留一条可自证的破绽</strong>（本关是同页两次不一致 + 时间戳同秒）：这是教学靶场的善意；真实站点往往把假数据做得<strong>每次一致</strong>，那就只能靠”用另一个数据源交叉验证”了。</li>
<li><strong>记台账、能累积</strong>：踩过几次、踩了什么，都记下来，越踩越严 —— 这就是本关冷却叠加的由来。</li>
</ol></section>
<section><h2>排查清单（⚠️ 先看前三条）<a href="#排查清单️-先看前三条"><span>#</span></a></h2><ol>
<li><strong>抓到的行数 ≠ 页脚条数</strong> → 先做可见性过滤（<code>display:none</code>、<code>visibility:hidden</code>、离屏、<code>aria-hidden</code>），别急着怀疑算法。</li>
<li><strong>同一页两次数据不一样</strong> → 你被污染了：停手、等冷却（别多试），并且别再碰隐藏链接/字段。</li>
<li><strong>整页「更新于」只剩一种取值</strong> → 假数据的味道，同上处理。</li>
<li><strong><code>pandas.read_html</code> 只认内联 <code>display:none</code></strong>：靠类名隐藏的行它照吃不误（本站 3 行幽灵行里它会漏掉 1 行、吃掉 2 行）—— 正确做法是”按 CSS 规则判断可见性”，不是只查一个属性。</li>
<li>别把页面里所有 <code>&lt;input&gt;</code> 拼成 params（尤其 <code>utm_*</code> / <code>refer</code> / <code>_hp</code> 这种”统计参数”）。</li>
<li>别遍历所有 <code>&lt;a href&gt;</code>；分页只跟随看得见、在文档流里的链接。</li>
<li>数据入库前先做校验：条数、唯一编号去重、同页两次一致、与另一个来源交叉比对。</li>
<li>请求头（Referer / UA）也保持像正常浏览 —— 这一关不看，真实站点会看。</li>
<li>答案不对时，<strong>先怀疑数据来源，再怀疑算法</strong>。这一关就是专门治”算法没问题但结果是错的”。</li>
<li>被污染之后重新抓一遍，看看数字有没有变：变了说明还在冷却里。</li>
</ol></section>
<section><h2>FAQ<a href="#faq"><span>#</span></a></h2><p><strong>Q：这关有加密吗？</strong>
没有。这一关就是要说明：<strong>反爬不一定是加密</strong>。校验可见性、校验一致性，是抓数里最容易被忽略、又最便宜有效的两道防线。</p><p><strong>Q：为什么隐藏行的金额不写成离谱的大数？</strong>
因为那样只会提醒你”这行是假的”。真实蜜罐的原则是”像”—— 同量级、同格式，让它在汇总里悄悄偏掉，你只会觉得”怎么算都不对”。</p><p><strong>Q：Playwright 的 <code>is_visible()</code> 说隐藏链接是可见的？</strong>
对，它只看有没有 1×1 的盒子。判断”人能不能看见”要看位置和尺寸：<code>box["x"] &lt; 0</code>、宽高 ≤ 1px 的，都不该去点。</p><p><strong>Q：能绕过污染吗？</strong>
等冷却到期就行（背几个 45 秒）。但真正要练的是”别踩”：只抓该抓的、只带该带的参数 —— 真实站点的污染不会自己到期。</p><p><strong>Q：页脚的「本页 10 条」是不是太便宜我了？</strong>
它是这一关的”校验基准”。真实页面上到处都有这种基准（条数、合计、校验位、编号规律），只是大多数人抓完就走，从来不看。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch14/</id>
      <title type="text">第 14 关：Cookie 签名 —— 七层复合加密，和「密钥写在前端」这件事</title>
      <published>2026-10-03T09:29:33.000Z</published>
      <updated>2026-10-03T09:29:33.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch14/"/>
      <summary type="text">本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字，但接口只认一个签过名的 sign Cookie：这个 Cookie 是前端一段混淆过的 JS连着过七层算法算出来的（拼盐 → MD5 派生密…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字，但接口只认一个<strong>签过名的 <code>sign</code> Cookie</strong>：这个 Cookie 是前端一段<strong>混淆过的 JS</strong>连着过七层算法算出来的（拼盐 → MD5 派生密钥 → 链式 XOR 流加密 → 16 字节矩阵转置 → S-box 替换 → 位置混淆 → 自定义字母表 base64）。页面只帮你签前 4 页，<strong>第 5 页得自己签</strong>。真正要记住的不是那七层，而是结尾那句：<strong>七层看着吓人，但没有一层是秘密</strong> —— 盐、S-box、字母表全在 JS 里，读了就能给任意页码签名。这正是「把密钥交给客户端」这种做法的天花板。</p></blockquote>
<p>靶场：<code>/sign?challenge=CH-014</code>（登录后打开）　参考解法：<code>参考代码/s16.py</code>　签名源码（混淆前）：<code>参考代码/ch014-sign.src.js</code></p>
<p>本关的层次结构对着 <code>参考代码/s14.js</code> 搭 —— 那是从某真实站点扒出来的一段签名脚本，也是「拼盐 → 哈希 → MD5 派生密钥做流加密 → 矩阵转置 → 再哈希 → 矩阵乘 → 十六进制」这么一长串。真实站点管这叫「JS 加密」「七层签名」，本质一样。</p>
<section><h2>现象<a href="#现象"><span>#</span></a></h2><ol>
<li>打开靶场，页面上是<strong>一张 10 行的数字表</strong> + 一排页码按钮 <code>1 2 3 4 5</code>：前 4 个正常，第 5 个按钮上挂着<strong>一把小锁</strong>。</li>
<li>点 1~4 任意一页，表里的数字马上换掉 —— 页面<strong>自己签好 Cookie</strong> 再请求，你什么都不用管。</li>
<li>点第 5 页，页面只会给你一句话：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>第 5 页不在页面里 —— 得自己把签名算出来（算法就在 /static/sign/sign.js 里）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>F12 → Network，整关只有两条接口（都是在登录之后发的）：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/challenges/CH-014/init          -&gt; {"cookie_name":"sign","total_pages":5,"visible_pages":4,"page_size":10}</span></div></div><div><div><div>2</div></div><div><span>GET /api/challenges/CH-014/data?page=1   -&gt; 200 {"code":"0","data":[362,783,...]}   ← 必须带 sign Cookie</span></div></div><div><div><div>3</div></div><div><span>GET /api/challenges/CH-014/data?page=5   -&gt; 403（页面没给它签）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>Application → Cookies 里能看到那个 Cookie：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>sign = 2v6ewxxz9cPu3Sp24OI_4K…</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一眼就看得出<strong>不是标准 base64</strong>：里面出现 <code>-</code> <code>_</code>，也没有末尾的 <code>=</code>。</p><ol>
<li>三个「不能作弊」的细节，F12 里试两下就知道：</li>
</ol>

<table><thead><tr><th>做法</th><th>结果</th></tr></thead><tbody><tr><td>不带 <code>sign</code> Cookie 请求 <code>?page=1</code></td><td><code>403</code> <code>{"reason":"missing"}</code></td></tr><tr><td>拿<strong>第 1 页</strong>的 Cookie 去要 <code>?page=5</code></td><td><code>403</code> <code>{"reason":"page"}</code> —— 页码被签进 Cookie 了</td></tr><tr><td>拿 6 分钟前的 Cookie 去请求</td><td><code>403</code> <code>{"reason":"expired"}</code> —— 时间戳也被签进去了</td></tr></tbody></table><blockquote><p>页面上的 <code>reason</code> 字段只是给排查用的粗分类，真实提示只有一句「签名无效或已过期」。<strong>服务端不会告诉你差在哪</strong>，<code>cookie=...</code> 的原文只写服务端日志。</p></blockquote></section>
<section><h2>分层：这一关要拆七层<a href="#分层这一关要拆七层"><span>#</span></a></h2>

<table><thead><tr><th>层</th><th>做了什么</th><th>还原要点</th></tr></thead><tbody><tr><td>L1</td><td>规范化拼串 <code>body = f"{page}.{timestamp_ms}"</code></td><td>签的其实就是「哪一页 + 什么时候」</td></tr><tr><td>L2</td><td>派生密钥 <code>KEY = md5("LS14.spot2026")</code></td><td><strong>盐写死在 JS 里</strong> —— 这是全关最大的一处弱点</td></tr><tr><td>L3</td><td>链式 XOR 流加密 <code>c[i] = p[i] ^ ks[i%16] ^ c[i-1]</code>，<code>ks = md5(KEY)</code></td><td>和 CBC 一个味道；⚠️ 补零必须<strong>在加密之前</strong></td></tr><tr><td>L4</td><td>每 16 字节当成 4×4 矩阵转置</td><td>转置是自逆的，正反都用同一个函数</td></tr><tr><td>L5</td><td>256 字节固定 S-box 替换（AES SubBytes 那个味道）</td><td>表就是 JS 里那个 256 项的数组</td></tr><tr><td>L6</td><td>位置混淆：<code>(b + i*7 + 13) &amp; 0xFF</code>，再按 <code>i%7+1</code> 循环左移</td><td>逐字节可逆，但顺序不能乱</td></tr><tr><td>L7</td><td>用打乱的 64 字符表做 base64（无 padding）</td><td>字母表也在 JS 里</td></tr></tbody></table><p>七层里<strong>没有一层用到真正的秘密</strong>：L2 的盐、L5 的表、L7 的字母表，全都在前端那一份 JS 里明摆着 —— 只是被混淆器换了个长相。</p></section>
<section><h2>第一步：先把 Cookie 的用法搞清楚<a href="#第一步先把-cookie-的用法搞清楚"><span>#</span></a></h2><p>别急着读算法，先确认签名<strong>绑了什么</strong>，这决定了你要重算什么：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>init </span><span>=</span><span> session.</span><span>get</span><span>(</span></span><span>"/api/challenges/CH-014/init"</span><span><span>).</span><span>json</span><span>()</span></span></div></div><div><div><div>2</div></div><div><span># {"cookie_name": "sign", "total_pages": 5, "visible_pages": 4, "page_size": 10}</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"sign"</span><span>, </span><span>""</span><span>)                       </span><span># 不给签名</span></div></div><div><div><div>5</div></div><div><span><span>session.</span><span>get</span><span>(</span></span><span>"/api/challenges/CH-014/data?page=1"</span><span>)     </span><span># -&gt; 403 missing</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"sign"</span><span>, cookie_of_page_1)         </span><span># 拿第 1 页的</span></div></div><div><div><div>8</div></div><div><span><span>session.</span><span>get</span><span>(</span></span><span>"/api/challenges/CH-014/data?page=5"</span><span>)     </span><span># -&gt; 403 page</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个结论：</p><ul>
<li>签名是<strong>按页</strong>签的（页码进签名），所以第 5 页不能复用第 4 页的 Cookie；</li>
<li>签名是<strong>按时</strong>签的（时间戳进签名，窗口 5 分钟），所以抄一个 Cookie 存起来慢慢用会过期；</li>
<li>签名<strong>只认 Cookie</strong>，不认 query / header，名字由 <code>/init</code> 下发（<code>sign</code>）。</li>
</ul><p>到这一步你已经能画出要重算的东西：<strong>给定页码 <code>page</code>，算出 <code>sign</code> 的合法值 = 过一遍七层</strong>。时间戳用当前毫秒就行。</p></section>
<section><h2>第二步：把混淆脚本扒开（只有两招障眼法）<a href="#第二步把混淆脚本扒开只有两招障眼法"><span>#</span></a></h2><p><code>/static/sign/sign.js</code> 是 JavaScript Obfuscator 的产物：控制流平坦化、十六进制变量名、死代码、字符串数组……<strong>别硬读</strong>。这个混淆器在这里其实只用了两招真正影响「能不能抄表」的手法：</p><section><h3>招式一：数字被写成了算术表达式<a href="#招式一数字被写成了算术表达式"><span>#</span></a></h3><p>产物里 S-box 不是 <code>[195,71,12,...]</code>，而是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[</span><span>0x2</span><span>*</span><span>0x508</span><span>+</span><span>0x2117</span><span>+-</span><span>0x2a64</span><span>, </span><span>0x1a42</span><span>+-</span><span>0x2178</span><span>+</span><span>0x27f</span><span>*</span><span>0x3</span><span>, </span><span>-</span><span>0x10c1</span><span>+-</span><span>0xc43</span><span>+</span><span>0xf0</span><span>*</span><span>0x1f</span><span>, </span><span>...</span><span>]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>求个值就是原数（<code>0x2*0x508+0x2117+-0x2a64</code> = 195）。<strong>256 项</strong>的那个数组就是 L5 的 S-box。</p></section><section><h3>招式二：字符串数组用的不是标准 base64 字母表<a href="#招式二字符串数组用的不是标准-base64-字母表"><span>#</span></a></h3><p>字符串被塞进一个大数组、各自 base64 了一遍，但<strong>它所使用的字母表把大小写两半调换了</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span><span> </span><span>_0x5d73a6</span><span> </span></span><span>=</span><span> </span><span>'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='</span><span>;</span></div></div><div><div><div>2</div></div><div><span>//   标准 base64 是 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>所以直接 <code>base64.b64decode</code> 会解出一堆乱码（这一点和 CH-012 的字符串表不一样，那个是别的变体）。把字母表换回去再解，长度 86 的那一条就是 L7 的 64 字符自定义字母表。</p><p>两招合起来，抠表的代码只有十几行：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> base64, re</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>JS_B64</span><span> </span><span>=</span><span> </span></span><span>"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="</span></div></div><div><div><div>4</div></div><div><span><span>STD_B64</span><span> </span><span>=</span><span> </span></span><span>"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>def</span><span> </span><span>grab_tables</span><span>(</span><span>sign_source</span><span>:</span><span> </span><span>str</span><span>):</span></div></div><div><div><div>7</div></div><div><span>    </span><span># ① S-box：混淆器把数字写成算术表达式，求值即可（只放行十六进制字面量和 +-*）</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>sbox </span><span>=</span><span> []</span></div></div><div><div><div>9</div></div><div><span>    </span><span>for</span><span> block </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span><span>"</span><span>\[</span></span><span><span>[</span><span>^</span></span><span>\[\]</span><span>]{300,}</span><span><span>\]</span><span>"</span></span><span>, sign_source):</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>parts </span><span>=</span><span> block[</span><span>1</span><span><span>:</span><span>-</span></span><span>1</span><span><span>].</span><span>split</span><span>(</span></span><span>","</span><span>)</span></div></div><div><div><div>11</div></div><div><span>        </span><span>if</span><span> </span><span>len</span><span><span>(parts) </span><span>==</span><span> </span></span><span>256</span><span> </span><span>and</span><span> </span><span>all</span><span><span>(re.</span><span>fullmatch</span><span>(</span></span><span>r</span><span>"</span><span>[</span><span>-+0-9a-fx*</span><span>]+</span><span>"</span><span>, part) </span><span>for</span><span> part </span><span>in</span><span> parts):</span></div></div><div><div><div>12</div></div><div><span><span>            </span></span><span>sbox </span><span>=</span><span> [</span><span>eval</span><span><span>(part) </span><span>&amp;</span><span> </span></span><span>0x</span><span>FF</span><span> </span><span>for</span><span> part </span><span>in</span><span> parts]</span></div></div><div><div><div>13</div></div><div><span>            </span><span>break</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>    </span><span># ② 字母表：字符串数组项是「换过字母表的 base64」</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>alphabet </span><span>=</span><span> </span><span>""</span></div></div><div><div><div>17</div></div><div><span>    </span><span>for</span><span> token </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span><span>"'</span><span>(</span></span><span>[</span><span>A-Za-z0-9+/=</span><span>]{40,}</span><span><span>)</span><span>'"</span></span><span>, sign_source):</span></div></div><div><div><div>18</div></div><div><span><span>        </span></span><span>fixed </span><span>=</span><span> token.</span><span>translate</span><span>(</span><span>str</span><span><span>.</span><span>maketrans</span><span>(</span><span>JS_B64</span><span>, </span><span>STD_B64</span><span>))</span></span></div></div><div><div><div>19</div></div><div><span><span>        </span></span><span>decoded </span><span>=</span><span> base64.</span><span>b64decode</span><span>(fixed </span><span>+</span><span> </span><span>"="</span><span><span> </span><span>*</span><span> (</span><span>-</span></span><span>len</span><span><span>(fixed) </span><span>%</span><span> </span></span><span>4</span><span><span>)).</span><span>decode</span><span>(</span></span><span>"ascii"</span><span>, </span><span>"replace"</span><span>)</span></div></div><div><div><div>20</div></div><div><span>        </span><span>if</span><span> </span><span>len</span><span><span>(decoded) </span><span>==</span><span> </span></span><span>64</span><span> </span><span>and</span><span> </span><span>len</span><span>(</span><span>set</span><span><span>(decoded)) </span><span>==</span><span> </span></span><span>64</span><span>:</span></div></div><div><div><div>21</div></div><div><span><span>            </span></span><span>alphabet </span><span>=</span><span> decoded</span></div></div><div><div><div>22</div></div><div><span>            </span><span>break</span></div></div><div><div><div>23</div></div><div><span>    </span><span>return</span><span> alphabet, sbox</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p>还有一条更省事的路：页面上的 <code>window.LSSign</code> 就是签名器本体，控制台里 <code>LSSign.applyCookie(5)</code> 一句话就能签第 5 页。<strong>能调用就等于能签</strong> —— 这也正是本关要让你体会的那件事。但手写一遍七层，你才知道「混淆」到底挡住了什么。</p></blockquote></section></section>
<section><h2>第三步：把七层用 Python 复刻一遍<a href="#第三步把七层用-python-复刻一遍"><span>#</span></a></h2><p>从 <code>参考代码/s16.py</code> 里摘出核心（完整版见文件）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>def</span><span> </span><span>sign</span><span>(</span><span>page</span><span>,</span><span><span> </span><span>timestamp_ms</span></span><span>,</span><span> </span><span>*</span><span><span>, </span><span>alphabet</span></span><span>,</span><span><span> </span><span>sbox</span></span><span>):</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>body </span><span>=</span><span> </span><span>f</span><span>"</span><span>{</span><span>int</span><span>(page)</span><span>}</span><span>.</span><span>{</span><span>int</span><span>(timestamp_ms)</span><span>}</span><span>"</span><span><span>.</span><span>encode</span><span>()          </span></span><span># L1</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> </span><span>len</span><span><span>(body) </span><span>%</span><span> </span></span><span>16</span><span>:                                          </span><span># ⚠️ 补零要在加密之前</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>body </span><span>+=</span><span> </span><span>b</span><span>"</span><span>\x00</span><span>"</span><span><span> </span><span>*</span><span> (</span></span><span>16</span><span><span> </span><span>-</span><span> </span></span><span>len</span><span><span>(body) </span><span>%</span><span> </span></span><span>16</span><span>)</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>key </span><span>=</span><span> hashlib.</span><span>md5</span><span>(</span><span>b</span><span>"LS14.spot2026"</span><span><span>).</span><span>digest</span><span>()                </span></span><span># L2</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>stream </span><span>=</span><span> hashlib.</span><span>md5</span><span>(key).</span><span>digest</span><span>()                          </span><span># L3 的密钥流</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>encrypted, previous </span><span>=</span><span> </span><span>bytearray</span><span>(), </span><span>0</span><span>                        </span><span># L3 链式 XOR</span></div></div><div><div><div>10</div></div><div><span>    </span><span>for</span><span> index, byte </span><span>in</span><span> </span><span>enumerate</span><span>(body):</span></div></div><div><div><div>11</div></div><div><span><span>        </span></span><span>value </span><span>=</span><span> byte </span><span>^</span><span> stream[index </span><span>%</span><span> </span><span>16</span><span><span>] </span><span>^</span><span> previous</span></span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>encrypted.</span><span>append</span><span>(value)</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>previous </span><span>=</span><span> value</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>transposed </span><span>=</span><span> </span><span>bytearray</span><span>()                                    </span><span># L4 4×4 转置</span></div></div><div><div><div>16</div></div><div><span>    </span><span>for</span><span> start </span><span>in</span><span> </span><span>range</span><span>(</span><span>0</span><span>, </span><span>len</span><span>(encrypted), </span><span>16</span><span>):</span></div></div><div><div><div>17</div></div><div><span><span>        </span></span><span>block </span><span>=</span><span> encrypted[start:start </span><span>+</span><span> </span><span>16</span><span>]</span></div></div><div><div><div>18</div></div><div><span>        </span><span>for</span><span> column </span><span>in</span><span> </span><span>range</span><span>(</span><span>4</span><span>):</span></div></div><div><div><div>19</div></div><div><span>            </span><span>for</span><span> row </span><span>in</span><span> </span><span>range</span><span>(</span><span>4</span><span>):</span></div></div><div><div><div>20</div></div><div><span><span>                </span></span><span>transposed.</span><span>append</span><span>(block[row </span><span>*</span><span> </span><span>4</span><span><span> </span><span>+</span><span> column])</span></span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>substituted </span><span>=</span><span> </span><span>bytes</span><span>(sbox[byte] </span><span>for</span><span> byte </span><span>in</span><span> transposed)       </span><span># L5 S-box</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>mixed </span><span>=</span><span> </span><span>bytearray</span><span>()                                         </span><span># L6 位置混淆</span></div></div><div><div><div>25</div></div><div><span>    </span><span>for</span><span> index, byte </span><span>in</span><span> </span><span>enumerate</span><span>(substituted):</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>bits </span><span>=</span><span> index </span><span>%</span><span> </span><span>7</span><span><span> </span><span>+</span><span> </span></span><span>1</span></div></div><div><div><div>27</div></div><div><span><span>        </span></span><span>value </span><span>=</span><span> (byte </span><span>+</span><span> index </span><span>*</span><span> </span><span>7</span><span><span> </span><span>+</span><span> </span></span><span>13</span><span><span>) </span><span>&amp;</span><span> </span></span><span>0x</span><span>FF</span></div></div><div><div><div>28</div></div><div><span><span>        </span></span><span>mixed.</span><span>append</span><span>(((value </span><span>&lt;&lt;</span><span> bits) </span><span>|</span><span> (value </span><span>&gt;&gt;</span><span> (</span><span>8</span><span><span> </span><span>-</span><span> bits))) </span><span>&amp;</span><span> </span></span><span>0x</span><span>FF</span><span>)   </span><span># 循环左移 bits 位</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>accumulator, bits, out </span><span>=</span><span> </span><span>0</span><span>, </span><span>0</span><span>, []                           </span><span># L7 自定义 base64（无 padding）</span></div></div><div><div><div>31</div></div><div><span>    </span><span>for</span><span> byte </span><span>in</span><span> mixed:</span></div></div><div><div><div>32</div></div><div><span><span>        </span></span><span>accumulator </span><span>=</span><span> (accumulator </span><span>&lt;&lt;</span><span> </span><span>8</span><span><span>) </span><span>|</span><span> byte</span></span></div></div><div><div><div>33</div></div><div><span><span>        </span></span><span>bits </span><span>+=</span><span> </span><span>8</span></div></div><div><div><div>34</div></div><div><span>        </span><span>while</span><span><span> bits </span><span>&gt;=</span><span> </span></span><span>6</span><span>:</span></div></div><div><div><div>35</div></div><div><span><span>            </span></span><span>bits </span><span>-=</span><span> </span><span>6</span></div></div><div><div><div>36</div></div><div><span><span>            </span></span><span>out.</span><span>append</span><span>(alphabet[(accumulator </span><span>&gt;&gt;</span><span> bits) </span><span>&amp;</span><span> </span><span>0x</span><span>3F</span><span>])</span></div></div><div><div><div>37</div></div><div><span>    </span><span>if</span><span> bits:</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>out.</span><span>append</span><span>(alphabet[(accumulator </span><span>&lt;&lt;</span><span> (</span><span>6</span><span><span> </span><span>-</span><span> bits)) </span><span>&amp;</span><span> </span></span><span>0x</span><span>3F</span><span>])</span></div></div><div><div><div>39</div></div><div><span>    </span><span>return</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(out)</span></span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>四个「一抄就错」的地方，全是我踩过的：</p><ol>
<li><strong>补零必须在加密之前</strong>。先加密再补零，那些零没被加密，服务端倒推回来就是垃圾 —— 表现为「怎么算都 403」，而且看不出原因。</li>
<li><strong>L6 是位置相关的</strong>：正着来的顺序是「先加偏移、再循环左移」，所以逆着来要<strong>先循环右移、再减偏移</strong>。顺序反了同样只是「403 看不出原因」。</li>
<li><strong>逆层顺序要严格反着</strong>：正向是 <code>L4 转置 → L5 S-box</code>，所以解的时候<strong>先</strong>逆 L5（用逆 S-box），<strong>再</strong>逆 L4。这两层写反是这一关最容易犯的错。</li>
<li><strong>如果顺手用 JS 对照实验</strong>，注意 JS 的 <code>&gt;&gt;</code> 是<strong>带符号</strong>右移：写循环左移必须是 <code>(v &lt;&lt; c) | (v &gt;&gt;&gt; (32 - c))</code>；另外 <code>x &gt;&gt;&gt; 56</code> 会把位移量按 32 取模变成 <code>x &gt;&gt;&gt; 24</code>，处理 64 位长度时得拆成高低 32 位分别写。</li>
</ol></section>
<section><h2>第四步：怎么确认自己抄对了（不用猜）<a href="#第四步怎么确认自己抄对了不用猜"><span>#</span></a></h2><p><strong>页面自己签的前 4 页就是现成的答案</strong>，拿它当测试向量：</p><ol>
<li>F12 → Application → Cookies，把第 1 页那次请求的 <code>sign</code> 值抄下来；</li>
<li>时间戳没法从 Cookie 里看出来（它被加密了），但能<strong>猜</strong>：签名就是刚刚这一分钟里生成的，于是从「当前时间」往前一秒一秒试：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> offset </span><span>in</span><span> </span><span>range</span><span>(</span><span>120</span><span>):</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>guess </span><span>=</span><span> </span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span><span>) </span><span>-</span><span> offset </span><span>*</span><span> </span></span><span>1000</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span><span> </span><span>sign</span><span>(</span></span><span>1</span><span>, guess, </span><span>alphabet</span><span><span>=</span><span>alphabet, </span></span><span>sbox</span><span><span>=</span><span>sbox) </span><span>==</span><span> sampled_cookie:</span></span></div></div><div><div><div>4</div></div><div><span>        </span><span>print</span><span>(</span><span>"对上了：七层还原正确，时间戳 ="</span><span>, guess)</span></div></div><div><div><div>5</div></div><div><span>        </span><span>break</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>参考代码/s16.py</code> 的自检段就是这么干的。<strong>对上了再往下走</strong> —— 这一关只要你把七层还原对了，后面的活就只有拼请求了。</p><p>另外两个顺手的对照点：</p><ul>
<li>解出来的明文必须是 <code>b"1.1760000000000"</code> 这种形状（<code>页码.毫秒</code>，尾部补零要去掉）；</li>
<li>长度必须对得上：<code>页码.毫秒</code> 是 15 个字符 → 补到 16 → 加密 16 字节 → base64 出来是 22 个字符（无 padding）。长度不对就是 L4/L7 抄错了。</li>
</ul></section>
<section><h2>第五步：五页取齐，求和<a href="#第五步五页取齐求和"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>total </span><span>=</span><span> </span></span><span>0</span></div></div><div><div><div>2</div></div><div><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span>, </span><span>6</span><span>):</span></div></div><div><div><div>3</div></div><div><span>    </span><span># 页面只会签 1~4 页，第 5 页得自己签 —— 差别只在这里</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>cookie </span><span>=</span><span> </span><span>sign</span><span>(page, </span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>), </span><span>alphabet</span><span><span>=</span><span>alphabet, </span></span><span>sbox</span><span><span>=</span><span>sbox)</span></span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>session.cookies.</span><span>set</span><span>(</span><span>"sign"</span><span>, cookie, </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>payload </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>f</span><span>"/api/challenges/CH-014/data?page=</span><span>{</span><span>page</span><span>}</span><span>"</span><span><span>).</span><span>json</span><span>()</span></span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>total </span><span>+=</span><span> </span><span>sum</span><span>(payload[</span><span>"data"</span><span>])</span></div></div><div><div><div>8</div></div><div><span>print</span><span>(total)   </span><span># 提交这个总和</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑法：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1) 浏览器登录靶场，F12 -&gt; Application -&gt; Cookies，抄下 session 的值</span></div></div><div><div><div>2</div></div><div><span># 2) 填进 参考代码/s16.py 的 SESSION_COOKIE</span></div></div><div><div><div>3</div></div><div><span>python</span><span> </span><span>参考代码/s16.py</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>脚本会先把两张表从 <code>sign.js</code> 里抠出来，再让你粘一个页面上的 <code>sign</code> 做自检，然后老老实实签 5 次、取 5 页、求和。</p><blockquote><p>每个账号的 50 个数字是<strong>按账号派生</strong>的（同一个人稳定、人与人不同），所以别拿别人的总和去提交。</p></blockquote></section>
<section><h2>这一关真正要记住的：为什么「七层」不解决问题<a href="#这一关真正要记住的为什么七层不解决问题"><span>#</span></a></h2><ul>
<li><strong>盐、S-box、字母表全在前端</strong> = 读者即拥有者。混淆只是把「读代码」变成「费点劲读代码」，它挡的是懒得读代码的人，不是攻击者。你能还原一次，就能给任意页码、任意时间戳签名。</li>
<li>更极端的是本关那条捷径：<code>LSSign.applyCookie(5)</code> —— <strong>能调用就等于能签</strong>。哪怕你把算法藏在 VM 里（见 CH-011 / CH-012），只要它必须在浏览器里跑出结果，调用方就一定能拿到结果。</li>
<li>正确的做法是<strong>密钥不出服务端</strong>：用 HMAC 对参数签名（服务端持密钥）、或服务端下发一次性 token、再叠上服务端风控和限速。本站 CH-005 就是这个套路（HMAC-MD5 签名 + 会话绑定），CH-010 是另一个变体（RSA 公钥在前端、私钥在服务端）—— 那两关的「客户端多算一层」是<strong>为了不泄密</strong>，而不是为了加密给别人看。</li>
</ul></section>
<section><h2>排查清单（⚠️ 先看前三条）<a href="#排查清单️-先看前三条"><span>#</span></a></h2><ol>
<li><strong>403 且 <code>reason</code> 是 <code>format</code> / 怎么算都不对</strong>：九成是补零顺序、逆层顺序（先逆 L5 再逆 L4）、或者 L6 的移位/偏移反了。</li>
<li><strong>403 且 <code>reason</code> 是 <code>page</code></strong>：Cookie 里签的页码和 <code>?page=</code> 不一致 —— 别复用别的页的签名。</li>
<li><strong>403 且 <code>reason</code> 是 <code>expired</code></strong>：时间戳是<strong>毫秒</strong>，而且服务端只认 5 分钟内；本地时间跑偏也会踩到。</li>
<li>长度不对（不是 22 / 44 …）：L4 的转置块大小别写成别的数，<code>body</code> 要补齐到 16 的倍数。</li>
<li>抠出来的 S-box 只有 255 项 / 有重复：正则抓歪了（别用 <code>\d+</code>，产物里是十六进制表达式）。</li>
<li>解出来的字母表里有 <code>+</code> <code>/</code> 但没有 64 个字符：忘了把混淆器那个<strong>大小写调换</strong>的字母表换回标准 base64。</li>
<li>自检一直对不上：先确认你抄的那个 <code>sign</code> 是不是<strong>同一页</strong>的、以及是不是刚刚才生成的（超过 5 分钟就没意义了）。</li>
<li>换了账号还是同一批数字：数字是按 <code>user_id</code> 派生的，换个账号当然就变了 —— 别把数字硬编码进脚本。</li>
<li>请求 401：<code>session</code> Cookie 没带上（域名 / path 设错了）。</li>
<li>想省事直接用 <code>LSSign.applyCookie(5)</code>：能过，但那就只是「调用」而不是「还原」；本关的分数想拿得踏实，还是把七层写出来。</li>
</ol></section>
<section><h2>FAQ<a href="#faq"><span>#</span></a></h2><p><strong>Q：为什么非要有 L4 转置和 L6 移位这种”看不出用途”的层？</strong>
真实站点也这么干：<strong>层数本身就是门槛</strong>。对写脚本的人来说，多一层就要多对一次答案；对防护方来说，成本几乎为零。理解了这一点，你就知道为什么看到「七层加密」不该先慌。</p><p><strong>Q：<code>verify</code> 为什么不直接返回「第几层解错了」？</strong>
服务端只回一句「签名无效或已过期」，原因写日志。真实站点也这样 —— 把失败原因告诉调用方，等于给自动化脚本送调试信息。</p><p><strong>Q：我能不能只逆推出「第 5 页」而不实现正向签名？</strong>
可以，但要<strong>先</strong>用正向实现自检一次：没有测试向量，你不知道自己逆出来的东西是不是对的。这一关最容易走的路是「正向复刻 + 拿 1~4 页的现成 Cookie 自检」。</p><p><strong>Q：时间戳是干什么用的？</strong>
它的作用是<strong>限时</strong>：抄来的 Cookie 只有 5 分钟寿命，逼你现场算签名。真实站点还会把时间戳和会话/随机数绑在一起（一次一密），思路一致 —— 让「重放」失效。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch13/</id>
      <title type="text">第 13 关：点选验证码 + 一次一密的轨迹报文 + 行为风控</title>
      <published>2026-10-03T09:09:01.000Z</published>
      <updated>2026-10-03T09:09:01.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch13/"/>
      <summary type="text">本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张点选验证码：把图上所有文字点掉。看着像「识图题」，真正的坎却在后面 —— 提交的不是「点了哪几个坐标」，而是一整段鼠标轨迹（时间、坐标、速度），用 AES+RSA 加…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张<strong>点选验证码</strong>：把图上所有文字点掉。看着像「识图题」，真正的坎却在后面 —— 提交的不是「点了哪几个坐标」，而是一整段<strong>鼠标轨迹</strong>（时间、坐标、速度），用 <code>AES+RSA</code> 加密成 <code>w</code>；AES 的密钥一次一密、IV 由 challenge 派生，抓包重放没用。就算坐标全对，只要轨迹像机器（匀速直线、等间隔撒点、瞬移），也会被服务端的行为风控拦下。</p></blockquote>
<p>靶场：<code>/captcha?challenge=CH-013</code>（登录后打开）　参考解法：<code>参考代码/s15.py</code></p>
<section><h2>现象<a href="#现象"><span>#</span></a></h2><ol>
<li>打开靶场，页面上就是<strong>一个普通的登录表单</strong>：用户名、密码、一个「登录」按钮，下面用虚线框标出<strong>演示账号 <code>demo / demo123</code></strong>（写死在页面上，账号密码也已经填好了，直接点「登录」就行）。</li>
<li>点「登录」—— <strong>弹出一个验证码窗口</strong>，标题写着「请点击图中的 N 个文字（不分先后）」。</li>
<li>图上有 3~5 个手写体汉字，散在噪点背景上。每点掉一个，图上会出现一个带序号的红色小圆点；点满 N 下<strong>自动提交</strong>，窗口里的数字就是这一轮的行为轨迹。</li>
<li>Network 里一共四条请求（都是点了「登录」之后才发出去的）：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET  /api/challenges/CH-013/init   -&gt; {"gt":"3f9c...","challenge":"cd5c...","rounds":3,"demo":{...}}</span></div></div><div><div><div>2</div></div><div><span>POST /api/challenges/CH-013/get    -&gt; {"bg":"/api/challenges/CH-013/image?challenge=...","width":320,"height":160,...}</span></div></div><div><div><div>3</div></div><div><span>GET  /api/challenges/CH-013/image?challenge=...      -&gt; 图片本体（image/png）</span></div></div><div><div><div>4</div></div><div><span>POST /api/challenges/CH-013/verify -&gt; 请求体只有 {"gt","challenge","w"}；成功回 {"validate":"...","rounds_left":2}</span></div></div><div><div><div>5</div></div><div><span>POST /api/challenges/CH-013/login  -&gt; {"username","password","validate"}，验证通过后才回 {"passphrase":"LS13-XXXX-XXXX-XXXX"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><code>w</code> 长这样（两段，用 <code>@</code> 拼起来）：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>w = "SBx2K9...（base64，RSA 加密的 AES 密钥）" + "@" + "9kQm...（base64，AES 加密的轨迹 JSON）"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>每次都换一张图，也每次都换一个 challenge</strong>：<code>/get</code> 返回的 <code>challenge</code> 和 <code>/init</code> 那个不一样，而且用一次就废。</li>
<li>手动点得慢一点、准一点，能过；但如果用脚本「算出中心点 → 瞬间点掉」，<strong>坐标全对也会失败</strong> —— 而且服务端只会回一句：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"code"</span><span>:</span><span>"1"</span><span>,</span><span>"result"</span><span>:</span><span>"fail"</span><span>,</span><span>"message"</span><span>:</span><span>"验证失败，请重新完成验证"</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>弹窗同时换上一张新图。<strong>它不告诉你差在哪</strong>（真实站点就是这样，「差多少」不会下发给调用方）—— 详情只写服务端日志：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CH-013 验证不通过 user=1 reason=track_uniform_speed detail={"image_id":"00047","speed_stddev":0.0001,...}</span></div></div><div><div><div>2</div></div><div><span>CH-013 验证不通过 user=1 reason=automation detail={"ua":"...Electron...","chromeKeys":0,"notification":"denied",</span></div></div><div><div><div>3</div></div><div><span><span>                                                  </span></span><span>"soft":["UA 是 Electron 等嵌入式浏览器","window.chrome 是空的", ...],"score":3}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>如果你是用 Playwright / Puppeteer / Selenium 打开的页面，那连”点得准”这一步都到不了</strong> —— 服务端会先看你上报的浏览器环境，判定是自动化就直接拒绝（<code>reason=automation</code>）。这一层就是接下来要拆的第四步。</li>
</ol></section>
<section><h2>分层：这一关要拆五层<a href="#分层这一关要拆五层"><span>#</span></a></h2>

<table><thead><tr><th>层</th><th>手法</th><th>破法</th></tr></thead><tbody><tr><td>报文加密</td><td><code>w = base64(RSA(AES 密钥)) + "@" + base64(AES-128-CBC(轨迹))</code></td><td>找到公钥，自己按同样格式拼 <code>w</code></td></tr><tr><td>一次一密</td><td>AES 密钥每次现造；<strong>IV = sha256(challenge) 的前 16 个字符</strong>；challenge 一次性</td><td>别想重放，每轮都按当前 challenge 重算</td></tr><tr><td>前端混淆</td><td>采集/加密的代码在 <code>static/captcha/security.js</code>，真正的逻辑是一段 base64 源码，<code>new Function</code> 运行时才编译</td><td>解两层 base64（注意混淆器把大小写翻转了）；或 hook <code>Function</code></td></tr><tr><td>反调试</td><td>每 500ms 拼出 <code>debugger</code> 触发一次断点，量 <code>Date.now()</code> 差值；一旦发现被停住就把采到的轨迹清空</td><td><code>Ctrl+F8</code> 停用断点，或干脆不开 DevTools</td></tr><tr><td>行为风控</td><td>坐标 + 轨迹（速度/间隔/转弯/瞬移）</td><td>生成拟人轨迹（见第五步）</td></tr><tr><td><strong>反自动化</strong></td><td>静态指纹：<code>navigator.webdriver</code>、自动化全局变量、UA、<code>window.chrome</code>、Notification 权限……</td><td>洗指纹（<code>s15.py</code> 的 <code>clean_env()</code>），或者干脆不用浏览器</td></tr></tbody></table></section>
<section><h2>第一步：先把流程和 challenge 的用法搞清楚<a href="#第一步先把流程和-challenge-的用法搞清楚"><span>#</span></a></h2><p>四步，和真实的点选验证码（极验那套）是一个套路：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>init  </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>INIT_PATH</span><span>).</span><span>json</span><span>()          </span></span><span># {"gt": ..., "challenge": ...}</span></div></div><div><div><div>2</div></div><div><span><span>got   </span><span>=</span><span> session.</span><span>post</span><span>(</span><span>GET_PATH</span><span>, </span></span><span>json</span><span><span>=</span><span>{</span></span><span>"gt"</span><span>: init[</span><span>"gt"</span><span>], </span><span>"challenge"</span><span>: init[</span><span>"challenge"</span><span><span>]}).</span><span>json</span><span>()</span></span></div></div><div><div><div>3</div></div><div><span>                                               </span><span># 换一张图 → 拿**新**的 challenge + bg + 图尺寸</span></div></div><div><div><div>4</div></div><div><span><span>image </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> got[</span></span><span>"bg"</span><span>]).content  </span><span># 图片本体</span></div></div><div><div><div>5</div></div><div><span><span>verify</span><span>=</span><span> session.</span><span>post</span><span>(</span><span>VERIFY_PATH</span><span>, </span></span><span>json</span><span><span>=</span><span>{</span></span><span>"gt"</span><span><span>: </span><span>...</span><span>, </span></span><span>"challenge"</span><span>: got[</span><span>"challenge"</span><span>], </span><span>"w"</span><span><span>: w}).</span><span>json</span><span>()</span></span></div></div><div><div><div>6</div></div><div><span><span>login </span><span>=</span><span> session.</span><span>post</span><span>(</span><span>LOGIN_PATH</span><span>, </span></span><span>json</span><span><span>=</span><span>{</span></span><span>"username"</span><span><span>: </span><span>...</span><span>, </span></span><span>"password"</span><span><span>: </span><span>...</span><span>, </span></span><span>"validate"</span><span>: verify[</span><span>"validate"</span><span><span>]}).</span><span>json</span><span>()</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个坑：</p><ul>
<li><strong><code>challenge</code> 是一次性的</strong>：<code>/init</code> 给一个（用来换图），<code>/get</code> 再给一个（用来提交）。提交时必须用<strong>后一个</strong>，用错或者用两次都是 <code>challenge 无效或已过期</code>；</li>
<li><strong>验证失败也作废</strong>：想再试一次，得重新 <code>/init</code> → <code>/get</code> 换张图，不能对着同一张图反复试坐标；</li>
<li><strong><code>validate</code> 也只能用一次</strong>：登录接口消费掉就没了。</li>
</ul></section>
<section><h2>第二步：认出图上的文字<a href="#第二步认出图上的文字"><span>#</span></a></h2><p>数据集<strong>只有标注框、没有字符内容</strong>（标注的类别就是 <code>word</code> / <code>icon</code>），服务端也不会告诉你答案。所以先得自己做检测：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> ddddocr</span></div></div><div><div><div>2</div></div><div><span><span>detector </span><span>=</span><span> ddddocr.</span><span>DdddOcr</span><span>(</span></span><span>det</span><span>=</span><span>True</span><span>, </span><span>show_ad</span><span>=</span><span>False</span><span>)</span></div></div><div><div><div>3</div></div><div><span><span>boxes </span><span>=</span><span> detector.</span><span>detection</span><span>(image_bytes)          </span></span><span># [[x1,y1,x2,y2], ...]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>更稳的做法</strong>：这个靶场的图本来就来自一个<strong>目标检测数据集</strong>（仓库里那份 <code>点选验证码目标检测数据集(1)/</code>，VOC 标注，6300 张）。拿它训一个小模型（YOLO 之类），检测效果比通用 OCR 的检测头稳得多 —— 数据集里 <code>labels/</code> 目录已经是 YOLO 格式了。</p><p>⚠️ <strong>识别结果必须”不漏不重”</strong>：服务端要求「框全部点到、点击次数等于框的数量」。多认出一个噪点、或者少认一个字，都会直接判失败（错误码 <code>click_count</code>）。识别没把握时，<strong>换一张图</strong>比硬着头皮提交划算。</p></section>
<section><h2>第三步：把 <code>w</code> 拼出来（一次一密在这儿）<a href="#第三步把-w-拼出来一次一密在这儿"><span>#</span></a></h2><p><code>w</code> 的结构和 CH-010 那套「RSA 传密钥 + AES 加密报文」像，但多了两处：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>key_text </span><span>=</span><span> </span></span><span>''</span><span><span>.</span><span>join</span><span>(random.</span><span>choice</span><span>(string.ascii_letters </span><span>+</span><span> string.digits) </span></span><span>for</span><span> _ </span><span>in</span><span> </span><span>range</span><span>(</span><span>16</span><span>))</span></div></div><div><div><div>2</div></div><div><span># ① IV 不是固定值，而是 sha256(challenge) 的前 16 个字符 —— challenge 一换，密文全变</span></div></div><div><div><div>3</div></div><div><span><span>iv </span><span>=</span><span> hashlib.</span><span>sha256</span><span>(challenge.</span><span>encode</span><span>()).</span><span>hexdigest</span><span>()[:</span></span><span>16</span><span><span>].</span><span>encode</span><span>()</span></span></div></div><div><div><div>4</div></div><div><span><span>cipher </span><span>=</span><span> </span><span>AES</span><span>.</span><span>new</span><span>(key_text.</span><span>encode</span><span>(), </span><span>AES</span><span>.</span><span>MODE_CBC</span><span>, iv)</span></span></div></div><div><div><div>5</div></div><div><span><span>body </span><span>=</span><span> base64.</span><span>b64encode</span><span>(cipher.</span><span>encrypt</span><span>(</span><span>pad</span><span>(json.</span><span>dumps</span><span>(payload).</span><span>encode</span><span>(), </span></span><span>16</span><span><span>))).</span><span>decode</span><span>()</span></span></div></div><div><div><div>6</div></div><div><span># ② AES 密钥用 RSA 公钥包起来（PKCS#1 v1.5）</span></div></div><div><div><div>7</div></div><div><span><span>wrapped </span><span>=</span><span> base64.</span><span>b64encode</span><span>(</span><span>PKCS1_v1_5</span><span>.</span><span>new</span><span>(public_key).</span><span>encrypt</span><span>(key_text.</span><span>encode</span><span>())).</span><span>decode</span><span>()</span></span></div></div><div><div><div>8</div></div><div><span><span>w </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>wrapped</span><span>}</span><span>@</span><span>{</span><span>body</span><span>}</span><span>"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>明文的 <code>payload</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"gt"</span><span>: </span><span>"..."</span><span>, </span><span>"challenge"</span><span>: </span><span>"..."</span><span>, </span><span>"ts"</span><span>: </span><span>1760000000000</span><span>, </span><span>"size"</span><span>: [</span><span>320</span><span>, </span><span>160</span><span>],</span></div></div><div><div><div>2</div></div><div><span> </span><span>"track"</span><span>:  [[</span><span>dt</span><span>, </span><span>x</span><span>, </span><span>y</span><span>], </span><span>...</span><span>], </span><span>"clicks"</span><span>: [[</span><span>dt</span><span>, </span><span>x</span><span>, </span><span>y</span><span>], </span><span>...</span><span>],</span></div></div><div><div><div>3</div></div><div><span> </span><span>"env"</span><span>: {</span><span>"webdriver"</span><span>: </span><span>false</span><span>, </span><span>"ua"</span><span>: </span><span>"..."</span><span>, </span><span>"globals"</span><span>: [], </span><span>"chromeKeys"</span><span>: </span><span>4</span><span>, </span><span>"notif"</span><span>: </span><span>"default"</span><span>, </span><span>...</span><span>},</span></div></div><div><div><div>4</div></div><div><span> </span><span>"ver"</span><span>: </span><span>"1.0.2"</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>track</code> / <code>clicks</code> 都是<strong>增量时间编码</strong>：第一个元素的 <code>dt</code> 是「距 <code>ts</code> 多少毫秒」，后面每个是「距上一个点多少毫秒」。<code>ts</code> 是开始采集的时刻（图片刚画出来），<code>size</code> 是图片在页面上的显示尺寸 —— 服务端用它把像素坐标归一化，所以自己算的时候直接用图的原尺寸就行。<code>env</code> 是浏览器环境指纹（第四步要拆的那一层）。</p><p><strong>公钥在哪儿？</strong> 不在页面里明文写着，而是被 base64 藏进了混淆产物 <code>security.js</code>。<code>参考代码/s15.py</code> 里的 <code>grab_public_key()</code> 干的就是这件事：把产物里所有 base64 片段「原样 / 大小写翻转」各解两层，谁解出 <code>BEGIN PUBLIC KEY</code> 就是它：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> token </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span>"</span><span>[</span><span>'</span><span>\"</span><span>]</span><span>(</span><span>[</span><span>A-Za-z0-9+/=</span><span>]{40,}</span><span>)</span><span>[</span><span>'</span><span>\"</span><span>]</span><span>"</span><span>, source):</span></div></div><div><div><div>2</div></div><div><span>    </span><span>for</span><span> candidate </span><span>in</span><span><span> (token, token.</span><span>swapcase</span><span>()):        </span></span><span># 混淆器把大小写翻转了</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>outer </span><span>=</span><span> base64.</span><span>b64decode</span><span>(candidate </span><span>+</span><span> pad).</span><span>decode</span><span>()      </span><span># 第一层：payload（base64）</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>inner </span><span>=</span><span> base64.</span><span>b64decode</span><span>(outer </span><span>+</span><span> pad).</span><span>decode</span><span>()          </span><span># 第二层：VM 源码</span></div></div><div><div><div>5</div></div><div><span>        </span><span># inner 里就有 PEM ——顺带还能看到整段采集/加密逻辑</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺带说一句：<strong>解出来的那份 VM 源码就是这一关前端的全部逻辑</strong>（怎么采轨迹、怎么派生 IV、反调试怎么判断），比动态调试省事得多。</p></section>
<section><h2>第四步：先过”环境”这一关 —— 自动化浏览器一进门就被认出来了<a href="#第四步先过环境这一关--自动化浏览器一进门就被认出来了"><span>#</span></a></h2><p><strong>这一步很容易被忽略</strong>：很多人第一反应是用 Playwright / Puppeteer 打开页面点两下了事。结果会发现<strong>怎么点都过不去</strong> —— 因为服务端除了看轨迹，还会把你上报的浏览器环境过一遍。</p><p>关于这一层，一个实测出来的事实值得先说清楚：<strong>在事件层面是查不出自动化的。</strong></p>

<table><thead><tr><th>你可能以为的破绽</th><th>实测结果</th></tr></thead><tbody><tr><td>注入的事件 <code>isTrusted === false</code></td><td>❌ CDP 派发的事件到页面时 <code>isTrusted</code> 就是 <code>true</code></td></tr><tr><td><code>event.movementX/movementY</code> 恒为 0</td><td>❌ 照常按坐标差算出来（实测 18px 的步长 → <code>movementX = 18</code>）</td></tr><tr><td>只有 <code>mousemove</code>、没有 <code>pointermove</code></td><td>❌ <code>pointermove / pointerdown / mousedown / mouseup</code> 一个不少</td></tr><tr><td><code>screenX</code> 和 <code>clientX</code> 对不上</td><td>❌ 差值恒定（窗口偏移），和真鼠标一样</td></tr></tbody></table><p>所以这一层是靠<strong>静态指纹</strong>判的（<code>backend/click_captcha_risk.py</code> 的 <code>check_environment()</code>）。最典型的那种写法 ——</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>browser </span><span>=</span><span> p.chromium.</span><span>launch</span><span>(</span></span><span>headless</span><span>=</span><span>False</span><span>)   </span><span># 甚至不用无头</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>—— 实测下来是这样：</p>

<table><thead><tr><th>信号</th><th><code>headless=True</code></th><th><code>headless=False</code></th></tr></thead><tbody><tr><td><code>navigator.webdriver</code></td><td><strong>True</strong></td><td><strong>True</strong> ← 一条就够</td></tr><tr><td>UA</td><td><code>HeadlessChrome/149</code></td><td>正常 Chrome（不带 headless 字样）</td></tr><tr><td><code>navigator.plugins.length</code></td><td>0</td><td>5</td></tr><tr><td><code>Object.keys(window.chrome).length</code></td><td><code>window.chrome</code> 都没有</td><td>3（<code>loadTimes</code>/<code>csi</code>/<code>app</code>）</td></tr><tr><td><code>Notification.permission</code></td><td>denied</td><td>denied</td></tr><tr><td><code>cdc_*</code> 之类的全局变量</td><td>无</td><td>无</td></tr></tbody></table><p>也就是说：<strong>裸跑 Playwright，光 <code>navigator.webdriver</code> 这一条就露了</strong>，无头还是有头都一样。</p><p><strong>硬信号（沾一个就拦）</strong></p><ul>
<li><code>navigator.webdriver === true</code> —— Selenium / Puppeteer 的默认值，最经典的自动化标记；</li>
<li><code>window</code> 上留着自动化框架的全局变量：<code>cdc_*</code>（ChromeDriver）、<code>__selenium*</code>、<code>__driver_*</code>、<code>__playwright*</code>、<code>__puppeteer*</code>、<code>callPhantom</code>、<code>_phantom</code>、<code>__nightmare</code>、<code>domAutomation</code>……</li>
<li>UA 里有 <code>HeadlessChrome</code> / <code>PhantomJS</code> / <code>Selenium</code> / <code>Puppeteer</code> / <code>Playwright</code> 字样。</li>
</ul><p><strong>软信号（攒够 2 分才拦 —— 单条会误伤真人）</strong></p><ul>
<li>UA 是 <code>Electron</code> 这类嵌入式浏览器；</li>
<li><code>Object.keys(window.chrome).length === 0</code>（正规 Chrome 有一堆 <code>chrome.*</code> 对象）；</li>
<li><code>Notification.permission === 'denied'</code>（headless Chrome 的默认值）；</li>
<li><code>navigator.plugins.length === 0</code>；</li>
<li><code>window.outerWidth/outerHeight</code> 为 0；</li>
<li><code>navigator.languages.length === 0</code>。</li>
</ul><p>脚本<strong>页面一加载就会把指纹 POST 给服务端</strong>（<code>POST /api/challenges/CH-013/env</code>）—— 也就是说<strong>门一开就被登记了</strong>，不用等你点验证码。服务端日志当场留一行，页面上也会弹一条提示：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CH-013 页面打开即识别 user=1 automation=True signals=["navigator.webdriver = true"] ua=...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>怎么知道自己的浏览器报了什么？</strong> 页面上的安全脚本内置了采集函数，控制台里直接看：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>window</span><span>.</span></span><span>LSClick</span><span>.</span><span>collectEnv</span><span>()</span></div></div><div><div><div>2</div></div><div><span>// {webdriver: false, ua: "...Chrome/150...", globals: [], chromeKeys: 4,</span></div></div><div><div><div>3</div></div><div><span>//  notif: "default", plugins: 5, languages: 2, outer: [2048, 1152], ...}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>拿这个对照下面的「正规 Chrome 长什么样」：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>navigator</span><span>.</span></span><span>webdriver</span><span>                 </span><span>// false</span></div></div><div><div><div>2</div></div><div><span><span>Object</span><span>.</span></span><span>keys</span><span><span>(</span><span>window</span><span>.</span></span><span>chrome</span><span>)          </span><span>// ["app","csi","loadTimes","runtime", ...]  ← 不是空数组</span></div></div><div><div><div>3</div></div><div><span><span>navigator</span><span>.</span></span><span>plugins</span><span>.</span><span>length</span><span>            </span><span>// 5</span></div></div><div><div><div>4</div></div><div><span><span>Notification</span><span>.</span></span><span>permission</span><span>             </span><span>// "default"</span></div></div><div><div><div>5</div></div><div><span><span>navigator</span><span>.</span></span><span>languages</span><span>.</span><span>length</span><span>          </span><span>// 2</span></div></div><div><div><div>6</div></div><div><span><span>window</span><span>.</span></span><span>outerWidth</span><span><span>, </span><span>outerHeight</span><span>      </span></span><span>// 都是正数</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>⚠️ 关键认知</strong>：这些字段全是<strong>客户端自报</strong>的，服务端没法验证真伪。所以这一层只能挡住「不洗指纹就上」的自动化；愿意花力气伪装的人照样能过 —— 这正是反爬的常态：<strong>每一层都不是铜墙铁壁，它的价值是把门槛抬高、把成本转嫁给对手</strong>。参考解法里的 <code>clean_env()</code> 干的就这件事。</p><p>顺带一提，本地开发要拿自动化浏览器测这一关（比如自己写 Playwright 脚本调试），可以在<strong>服务端</strong>开个后门：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>LEARNSPIDER_CH013_ALLOW_AUTOMATION</span><span>=</span><span>1</span><span> </span><span>python</span><span> </span><span>app.py</span><span>     </span><span># 默认关，线上别开</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>第五步：最难的一关是”像个人”<a href="#第五步最难的一关是像个人"><span>#</span></a></h2><p>坐标对了只是入场券。服务端拿到解密后的轨迹，会同时看<strong>结果</strong>和<strong>过程</strong>：</p>

<table><thead><tr><th>检查</th><th>阈值</th><th>为什么</th></tr></thead><tbody><tr><td>点击次数 = 文字框数量</td><td>相等</td><td>漏点 / 多点都算错</td></tr><tr><td>每一下都落在某个框里</td><td>归一化容差 3%（320×160 的图约 9.6×4.8 像素）</td><td>允许手抖，但不许点空</td></tr><tr><td>每个框恰好被点一次</td><td>—</td><td>防止把一个字点两遍充数</td></tr><tr><td>第一下点击的延迟</td><td>≥ 200ms</td><td>图片刚出来就点 → 人眼来不及看</td></tr><tr><td>相邻点击间隔</td><td>60ms ~ 8s</td><td>太快是连点器，太慢是挑战已过期</td></tr><tr><td>点击间隔标准差</td><td>≥ 2ms（<strong>间隔数 ≥ 3 时才判</strong>）</td><td>固定 <code>sleep</code> 的脚本标准差是 0.0x 毫秒</td></tr><tr><td>轨迹采样点数量</td><td>≥ 12</td><td>两点一线直接连过去的不行</td></tr><tr><td>瞬时速度</td><td>≤ 24 px/ms</td><td>人手做不到，这就是瞬移</td></tr><tr><td><strong>速度标准差</strong></td><td>≥ 0.02 px/ms</td><td>匀速直线插值的轨迹，这里是 0</td></tr><tr><td>方向变化次数</td><td>≥ 2（转角 &gt; 10°）</td><td>整条轨迹一条直线没有转折</td></tr></tbody></table><p>所以「算出中心点 → 直接点过去」必然挂在速度标准差上。要过这一关，得<strong>生成一段像人的轨迹</strong>：</p><blockquote><p>顺带一提：<strong>采样点的「时间间隔标准差」这一条没有</strong>。浏览器按帧投递 <code>mousemove</code>，真人的事件间隔同样被量化到 ~16.7ms 且方差极小 —— 拿它当机器特征会大面积误伤真人。这是这关的一个设计取舍：<strong>宁可漏判，不可误伤</strong>；也提醒你，风控阈值不是越多越好。</p></blockquote><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>def</span><span> </span><span>human_move</span><span>(</span><span>rng</span><span>,</span><span><span> </span><span>start</span></span><span>,</span><span><span> </span><span>end</span></span><span>):</span></div></div><div><div><div>2</div></div><div><span>    </span><span>"""慢-快-慢（ease）+ 手抖。"""</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>distance </span><span>=</span><span> math.</span><span>hypot</span><span>(end[</span><span>0</span><span><span>] </span><span>-</span><span> start[</span></span><span>0</span><span>], end[</span><span>1</span><span><span>] </span><span>-</span><span> start[</span></span><span>1</span><span>])</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>steps </span><span>=</span><span> </span><span>max</span><span>(</span><span>5</span><span>, </span><span>int</span><span>(</span><span>max</span><span>(</span><span>140.0</span><span><span>, distance </span><span>/</span><span> rng.</span><span>uniform</span><span>(</span></span><span>0.30</span><span>, </span><span>1.10</span><span><span>)) </span><span>/</span><span> rng.</span><span>uniform</span><span>(</span></span><span>9</span><span>, </span><span>17</span><span>)))</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>samples </span><span>=</span><span> []</span></div></div><div><div><div>6</div></div><div><span>    </span><span>for</span><span> step </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, steps </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>ratio </span><span>=</span><span> step </span><span>/</span><span> steps</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>ease </span><span>=</span><span> </span><span>3</span><span><span> </span><span>*</span><span> ratio </span><span>**</span><span> </span></span><span>2</span><span><span> </span><span>-</span><span> </span></span><span>2</span><span><span> </span><span>*</span><span> ratio </span><span>**</span><span> </span></span><span>3</span><span>          </span><span># 速度先快后慢，绝不会是常数</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>samples.</span><span>append</span><span>((</span></div></div><div><div><div>10</div></div><div><span>            </span><span>max</span><span>(</span><span>4.0</span><span><span>, rng.</span><span>gauss</span><span>(</span></span><span>14</span><span>, </span><span>4.5</span><span>)),                </span><span># 事件间隔带抖动（±几毫秒）</span></div></div><div><div><div>11</div></div><div><span><span>            </span></span><span>start[</span><span>0</span><span><span>] </span><span>+</span><span> (end[</span></span><span>0</span><span><span>] </span><span>-</span><span> start[</span></span><span>0</span><span><span>]) </span><span>*</span><span> ease </span><span>+</span><span> rng.</span><span>gauss</span><span>(</span></span><span>0</span><span>, </span><span>1.1</span><span>),   </span><span># 手抖</span></div></div><div><div><div>12</div></div><div><span><span>            </span></span><span>start[</span><span>1</span><span><span>] </span><span>+</span><span> (end[</span></span><span>1</span><span><span>] </span><span>-</span><span> start[</span></span><span>1</span><span><span>]) </span><span>*</span><span> ease </span><span>+</span><span> rng.</span><span>gauss</span><span>(</span></span><span>0</span><span>, </span><span>1.1</span><span>),</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>))</span></div></div><div><div><div>14</div></div><div><span>    </span><span>return</span><span> samples</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>几个「别省」的细节：</p><ul>
<li><strong>别用线性插值 + 等分时间</strong>：那正是 <code>speed_stddev ≈ 0</code> 的来源，一条匀速直线就露馅；</li>
<li><strong><code>ts</code> 要贴近当前时间</strong>：服务端允许 3 分钟误差，超了直接判失败；</li>
<li><strong>点之间要”停一下再按”</strong>（<code>rng.uniform(90, 260)</code> 毫秒），这也是人手特征；</li>
<li><strong>别用 <code>time.sleep(0.3)</code> 这种固定节律</strong>：点得多了（≥4 下、3 个间隔）会被看间隔标准差。</li>
</ul></section>
<section><h2>第六步：完整脚本<a href="#第六步完整脚本"><span>#</span></a></h2><p><code>参考代码/s15.py</code> 把上面五步串成了一条流水线：抠公钥 → init/get → ddddocr 检测 → 洗指纹 → 生成拟人轨迹 → 拼 <code>w</code> → verify → 登录拿口令。核心就四个函数：<code>detect_targets()</code>、<code>clean_env()</code>、<code>build_payload()</code>、<code>build_w()</code>。</p><p>跑通后拿到的是<strong>通关口令</strong>（形如 <code>LS13-XXXX-XXXX-XXXX</code>，按账号派生、唯一），把它提交到挑战广场。</p></section>
<section><h2>排查清单（⚠️ 先看这一条）<a href="#排查清单️-先看这一条"><span>#</span></a></h2><p><strong>服务端只说一句「验证失败」</strong>，不会告诉你差在哪 —— 真实站点就是这样，把 <code>reason</code> / <code>metrics</code> 下发到页面等于给脚本送调试助攻。所以你只能<strong>自己对着上面的阈值表复现</strong>：</p><ul>
<li>想知道到底挂在哪一条，就把 <code>backend/click_captcha_risk.py</code> 里的判定函数单独跑一遍（<code>check_clicks()</code> / <code>check_timing()</code> / <code>check_track()</code> 都是纯函数，喂进去就能出结论），或者看服务端日志 —— 每次失败都会留一行 <code>CH-013 验证不通过 user=... reason=... detail={...}</code>，里面的 <code>image_id</code> 能对上是哪张图。</li>
</ul><p>常见的几种失败与原因：</p>

<table><thead><tr><th>可能的原因</th><th>怎么看出来</th></tr></thead><tbody><tr><td><strong>环境被判成自动化</strong>（最常见）</td><td>用 Playwright / Puppeteer / Selenium 直接跑必挂。控制台跑 <code>window.LSClick.collectEnv()</code>，对照上面的「正规 Chrome 长什么样」比一遍；服务端日志里有 <code>reason=automation</code> 和命中的信号</td></tr><tr><td><strong>坐标没点准</strong></td><td>容差只有图上 3%（320×160 的图约 9.6×4.8 像素）：检测框的中心算错了、忘了按显示尺寸缩放、或者点到了框外</td></tr><tr><td><strong>漏点 / 多点</strong></td><td>检测结果和真实框数量不一致（噪点被认成字、或者有字没认出来）→ 换一张图或换更稳的模型</td></tr><tr><td><strong>首点太快</strong></td><td><code>ts</code> 设得太早（比如设成了脚本启动时间），或者一个循环里”算完就点”，没留看图的时间</td></tr><tr><td><strong>轨迹像机器</strong></td><td>匀速直线（速度标准差 ≈ 0）、点太少（&lt; 12）、整条线没有转折、或者出现瞬移</td></tr><tr><td><strong>间隔太整齐</strong></td><td>用了固定 <code>sleep</code>；点得越多越容易露</td></tr><tr><td><strong><code>w</code> 根本没解开</strong></td><td>IV 没用<strong>当次</strong> challenge 派生；RSA 段不是 PKCS#1 v1.5；或者密文被动过</td></tr><tr><td><strong>challenge 失效</strong></td><td>用了 <code>/init</code> 那个旧 challenge；一张图验两次；验失败后没重新取图</td></tr><tr><td><strong>轨迹点数为 0</strong></td><td>多半是被反调试抓到了：挂断点会让采集结果被清空，<code>Ctrl+F8</code> 停用断点再来</td></tr><tr><td><code>validate 令牌无效…已经用过了</code></td><td><code>validate</code> 一次性，换新的再登</td></tr></tbody></table></section>
<section><h2>FAQ<a href="#faq"><span>#</span></a></h2><p><strong>Q：为什么图不从 CDN 直接下发，非要服务端代理一层？</strong>
A：题库种子（<code>backend/data/click_captcha_seed.json</code>）是随仓库走的，里面每条都带着 <code>image_url</code> 和标注框。要是把七牛的持久化链接直接发到浏览器，<code>image_id</code> 一露，查一眼种子就知道该点哪儿了。代理之后 URL 上只剩一次性的 <code>challenge</code>。</p><p><strong>Q：反调试真的会拦我吗？</strong>
A：它只做一件事：发现「被断点停住过」就把采到的轨迹清空。不开 DevTools、或者开了但按 <code>Ctrl+F8</code> 停用断点，它完全无害。想读逻辑不必动态跟 —— 把 <code>security.js</code> 里的 base64 解两层，源码就出来了。</p><p><strong>Q：识别的准确率不够怎么办？</strong>
A：两招。一是<strong>换一张图</strong>（服务端每次随机给图，识别没把握就重取）；二是拿仓库里那个数据集训一个检测模型 —— 数据就是为这一关准备的。</p><p><strong>Q：为什么必须”像人”？</strong>
A：这就是行为式验证码的核心：它验证的不是「结果」而是「过程」。真实站点上，哪怕坐标分毫不差，一条匀速直线也会被判定为机器。反过来说，知道它看什么，也就知道该往哪儿使劲 —— 以及知道<strong>哪些站点的验证码不该硬闯</strong>。</p><p><strong>Q：我用 Playwright 怎么点都过不去，为什么？</strong>
A：两个可能：<strong>环境被判成自动化</strong>（<code>reason=automation</code>），或者轨迹太”机械”。先看环境：控制台跑 <code>window.LSClick.collectEnv()</code> 看自己报了什么，再跟上面「正规 Chrome 长什么样」对一遍。要过的话有两条路 —— 洗指纹（像 <code>s15.py</code> 的 <code>clean_env()</code> 那样，用真实浏览器的值伪造一份），或者不走浏览器（自己拼 <code>w</code>，那样连环境字段都是你自己说了算）。</p><p><strong>Q：这些环境字段服务端能验证真伪吗？</strong>
A：<strong>不能</strong>。它们全是客户端自报的，服务端只做「像不像正常浏览器」的启发式判断。所以这一层挡的是「不洗指纹就上」的自动化，挡不住愿意花力气伪装的人 —— 这不是缺陷，是这类防护的本来面目：<strong>每一层都把成本往上抬一点，最后决定胜负的是对手愿不愿意付出那份成本</strong>。</p><p><strong>Q：我本地想用自动化浏览器测这一关怎么办？</strong>
A：服务端留了开关（默认关，线上不要开）：<code>LEARNSPIDER_CH013_ALLOW_AUTOMATION=1 python app.py</code>，打开后跳过环境检查，行为风控照旧。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch12/</id>
      <title type="text">第 12 关：接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试</title>
      <published>2026-10-02T14:21:39.000Z</published>
      <updated>2026-10-02T14:21:39.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch12/"/>
      <summary type="text">本文是 LearnSpider 第 12 关的解密教程。这一关换了个思路：页面本身几乎是空的，真正的防守逻辑不在静态文件里，而是登录后由接口现发一段 JS 下来。这段 JS 里：关键字全被十六进制转义映射（$dbsm_0x5d57）、一段字…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 12 关的解密教程。这一关换了个思路：<strong>页面本身几乎是空的</strong>，真正的防守逻辑不在静态文件里，而是<strong>登录后由接口现发一段 JS 下来</strong>。这段 JS 里：关键字全被十六进制转义映射（<code>$dbsm_0x5d57</code>）、一段字节数组解码后丢给 <code>eval</code> 执行、内置了一份完整 MD5 用来校验数据、再加一个 500ms 轮询的反调试 <code>setInterval</code>。</p></blockquote>
<p>靶场：<code>/shield?challenge=CH-012</code>（登录后打开）　参考解法：<code>参考代码/s14.py</code></p>
<section><h2>现象<a href="#现象"><span>#</span></a></h2><ol>
<li>打开靶场，页面上是 10 个四位数字和一个「本页合计」，能翻 5 页，<strong>最后一个是灰的</strong>。</li>
<li>打开 F12 —— 还没看清代码，Sources 面板就开始<strong>周期性断住</strong>：断点停在一行看着没什么的调用上，点继续，半秒后又断。这就是<strong>时间轮询式反调试</strong>。</li>
<li>Network 里只有三条请求：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/challenges/CH-012/init     -&gt; {"script_url": "...", "data_url": "...", "total_pages": 5, ...}</span></div></div><div><div><div>2</div></div><div><span>GET /api/challenges/CH-012/script   -&gt; 一段 JS（Content-Type: text/javascript）</span></div></div><div><div><div>3</div></div><div><span>GET /api/challenges/CH-012/data?page=1</span></div></div><div><div><div>4</div></div><div><span><span>      </span></span><span>-&gt; {"code":"0","qz":[...字节数字...],"sig":"b1cd5e3ce7fb9b18e4390f76c27ec005","page":1,...}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>那段下发的 JS，<strong>静态搜 <code>debugger</code>、<code>setInterval</code>、<code>eval</code>、<code>md5</code> 全都搜不到</strong>：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span> </span><span>$dbsm_0x5d57</span><span> </span><span>=</span><span> </span><span>function</span><span><span> (</span><span>_0x1a2b</span><span>, </span><span>_0x3c4d</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span><span> </span><span>_0x1a2b</span><span> </span></span><span>=</span><span> [</span><span>...</span><span>'</span><span>\\</span><span>x64</span><span>\\</span><span>x65</span><span>\\</span><span>x62</span><span>\\</span><span>x75</span><span>\\</span><span>x67</span><span>\\</span><span>x67</span><span>\\</span><span>x65</span><span>\\</span><span>x72'</span><span>, </span><span>...</span><span>];   </span><span>// 都是转义，看不出是什么</span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span><span> </span><span>_0x1a2b</span><span>[</span><span>_0x3c4d</span><span>];</span></span></div></div><div><div><div>4</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>数据接口返回的 <code>qz</code> 是一串<strong>数字</strong>（字节值），不是明文；旁边还有 <code>sig</code> 做校验。</li>
<li>直接改 <code>page=6</code> → <code>{"code":"1","message":"页码超出范围（一共 5 页）"}</code>。</li>
</ol></section>
<section><h2>分层：这次要拆的是”下发的代码”<a href="#分层这次要拆的是下发的代码"><span>#</span></a></h2>

<table><thead><tr><th>层</th><th>手法</th><th>破法</th></tr></thead><tbody><tr><td>反调试</td><td><code>setInterval</code> 每 500ms 用 <code>(0, eval)(表[2])</code> 触发一次 <code>debugger</code>，并用 <code>Date.now()</code> 前后差值判断是否被停住 → 停住过就 <code>for(;;){}</code> 死循环</td><td><code>Ctrl+F8</code> 停用断点；或换<strong>无调试器</strong>的环境跑（脚本、Playwright）</td></tr><tr><td>字符串混淆</td><td>所有关键字/函数名走 <code>$dbsm_0x5d57(i)</code> 字符串表，表里是 <code>\xHH</code> 转义</td><td>在控制台 <code>[...Array(14)].forEach((_,i)=&gt;console.log(i, $dbsm_0x5d57(i)))</code> 直接打印全表</td></tr><tr><td>代码隐藏</td><td><code>var qz = [...]</code> 字节数组，XOR 后 <code>eval</code> 执行，真正逻辑（<code>LSShield</code>）到运行时才存在</td><td>解 XOR 就能拿到源码；或 hook <code>eval</code>/<code>Function</code></td></tr><tr><td>数据加密</td><td>数字被转成 ASCII 再用同一套 XOR 编码成 <code>qz</code>，附 <code>sig = md5(明文 + 盐)</code></td><td>找 XOR key 和盐，自己解</td></tr><tr><td>内置 MD5</td><td>整份 MD5 实现塞在脚本里，不依赖外部库</td><td>说明它只是自校验，不是”不可解密的加密”</td></tr></tbody></table></section>
<section><h2>第一步：先把反调试按住<a href="#第一步先把反调试按住"><span>#</span></a></h2><p>下发的脚本里（还原转义后）核心就这几行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>_0x5d57_0x6</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span><span> </span><span>t0</span><span> </span></span><span>=</span><span><span> </span><span>Date</span><span>.</span></span><span>now</span><span>();</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>(</span><span>0</span><span><span>, </span><span>eval</span><span>)(</span></span><span>$dbsm_0x5d57</span><span>(</span><span>2</span><span>));                  </span><span>// 表[2] = 'debugger'</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span><span> (</span><span>Date</span><span>.</span></span><span>now</span><span>() </span><span>-</span><span><span> </span><span>t0</span><span> </span></span><span>&gt;</span><span> </span><span>200</span><span>) { </span><span>for</span><span> (;;) {} }   </span><span>// 被调试器停住过 → 死循环</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span><span>w</span><span>[</span></span><span>$dbsm_0x5d57</span><span>(</span><span>1</span><span><span>)](</span><span>_0x5d57_0x6</span><span>, </span></span><span>500</span><span>);          </span><span>// setInterval(fn, 500)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个要点：</p><ul>
<li><strong><code>debugger</code> 不是关键字写在源码里</strong>，而是从字符串表里取出来、再用<strong>间接 eval</strong> <code>(0, eval)(...)</code> 执行 ——
所以”在 <code>debugger</code> 那行打个断点”这招没用，静态也搜不到；</li>
<li><strong>它用时间差检测调试器</strong>：真被断住时 <code>Date.now()</code> 会跳几百毫秒以上，于是主动进死循环；
换句话说，<strong>没有调试器时它完全无害</strong>（这也就是为什么爬虫脚本可以在无头环境里正常跑）；</li>
<li>应对方式：DevTools 按 <strong><code>Ctrl+F8</code>（Deactivate breakpoints）</strong>，或在 <code>setInterval</code> 被调用前
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>window</span><span>.</span></span><span>setInterval</span><span> </span><span>=</span><span><span> (</span><span>fn</span><span>, </span><span>ms</span><span>) </span></span><span>=&gt;</span><span><span> (</span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'拦下定时器'</span><span><span>, </span><span>fn</span><span>.</span></span><span>toString</span><span>()), </span><span>0</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
更省事的当然是<strong>根本不跟调试器</strong>。</li>
</ul><blockquote><p>补充一个通用招数：<code>Function</code> / <code>eval</code> 都是”运行时才出现源码”的典型入口，
给它们加一层 Proxy 就能把参数（也就是被隐藏的代码）打出来：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>rawEval</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>eval</span><span>;</span></div></div><div><div><div>2</div></div><div><span><span>window</span><span>.</span></span><span>eval</span><span> </span><span>=</span><span><span> (</span><span>src</span><span>) </span></span><span>=&gt;</span><span> (</span><span>typeof</span><span><span> </span><span>src</span><span> </span></span><span>===</span><span> </span><span>'string'</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'eval:'</span><span><span>, </span><span>src</span><span>), </span></span><span>rawEval</span><span><span>(</span><span>src</span><span>));</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div></blockquote></section>
<section><h2>第二步：把字符串表翻译出来<a href="#第二步把字符串表翻译出来"><span>#</span></a></h2><p><code>$dbsm_0x5d57</code> 就是个数组下标查表，表项写成十六进制转义只是为了<strong>别让关键字出现在静态文本里</strong>。
在控制台（脚本已加载后）敲一行就全出来了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>$dbsm_0x5d57</span><span>(</span><span>0</span><span>)   </span><span>// '\x6c\x73\x73\x68\x69\x65\x6c\x64' 系列...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>或者干脆本地解码（<code>s14.py</code> 里的 <code>decode_qz</code> 一样的思路）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>&gt;&gt;&gt;</span><span> </span></span><span>bytes</span><span><span>.</span><span>fromhex</span><span>(</span></span><span>"6465627567676572"</span><span><span>).</span><span>decode</span><span>()   </span></span><span># 'debugger'</span></div></div><div><div><div>2</div></div><div><span><span>&gt;&gt;&gt;</span><span> </span></span><span>bytes</span><span><span>.</span><span>fromhex</span><span>(</span></span><span>"736574496e74657276616c"</span><span><span>).</span><span>decode</span><span>()  </span></span><span># 'setInterval'</span></div></div><div><div><div>3</div></div><div><span><span>&gt;&gt;&gt;</span><span> </span></span><span>bytes</span><span><span>.</span><span>fromhex</span><span>(</span></span><span>"6576616c"</span><span><span>).</span><span>decode</span><span>()          </span></span><span># 'eval'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>翻译完，整段脚本的可读性就回来了：<code>w.LSShield = { decode, split, digest, verify, ready }</code>。</p></section>
<section><h2>第三步：解 <code>qz</code>（真正的数据）<a href="#第三步解-qz真正的数据"><span>#</span></a></h2><p>数据接口给的 <code>qz</code> 是<strong>明文的每个字节异或一个固定值</strong>（这一关是 <code>0x5a</code>），
拿到明文后还能顺手验一下摘要：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>text </span><span>=</span><span> </span></span><span>''</span><span><span>.</span><span>join</span><span>(</span></span><span>chr</span><span><span>(b </span><span>^</span><span> </span></span><span>0x</span><span>5a</span><span>) </span><span>for</span><span> b </span><span>in</span><span> payload[</span><span>"qz"</span><span>])          </span><span># "9725,5484,..."</span></div></div><div><div><div>2</div></div><div><span>assert</span><span><span> </span><span>md5</span><span>(text </span><span>+</span><span> </span></span><span>"qz2026"</span><span><span>) </span><span>==</span><span> payload[</span></span><span>"sig"</span><span>]                  </span><span># 和内置 MD5 做的是同一件事</span></div></div><div><div><div>3</div></div><div><span><span>values </span><span>=</span><span> [</span></span><span>int</span><span>(x) </span><span>for</span><span> x </span><span>in</span><span><span> text.</span><span>split</span><span>(</span></span><span>","</span><span>)]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>XOR key 和盐在哪儿？</strong> 就在下发的那段脚本里 —— 而且<strong>把它们解出来也能看到</strong>：
<code>qz</code> 解码后是那支 <code>LSShield</code> 工厂函数的源码，里面明写着</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>return</span><span> </span><span>md5</span><span><span>(</span><span>text</span><span> </span></span><span>+</span><span> </span><span>'qz2026'</span><span>).</span><span>toString</span><span>();   </span><span>// 盐</span></div></div><div><div><div>2</div></div><div><span><span>bytes</span><span>[</span><span>i</span><span>] </span></span><span>^</span><span> </span><span>0x5a</span><span>                           </span><span>// XOR key</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>所以 <code>s14.py</code> 的思路是：<strong>先解开 <code>qz</code> 拿到源码 → 从源码里正则抠出盐</strong>，一步都不用猜。</p></section>
<section><h2>第四步：五页求和<a href="#第四步五页求和"><span>#</span></a></h2><p><code>init</code> 接口只说有几页（<code>total_pages: 5</code>）。循环翻页、每页解 10 个数字，累加即可：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span>, init[</span><span>"total_pages"</span><span><span>] </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>payload </span><span>=</span><span> session.</span><span>get</span><span>(init[</span><span>"data_url"</span><span>], </span><span>params</span><span><span>=</span><span>{</span></span><span>"page"</span><span><span>: page}).</span><span>json</span><span>()</span></span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>text </span><span>=</span><span> </span><span>decode_qz</span><span>(payload[</span><span>"qz"</span><span>], </span><span>0x</span><span>5a</span><span>)</span></div></div><div><div><div>4</div></div><div><span>    </span><span>assert</span><span><span> </span><span>md5</span><span>(text </span><span>+</span><span> salt) </span><span>==</span><span> payload[</span></span><span>"sig"</span><span>]</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>total </span><span>+=</span><span> </span><span>sum</span><span>(</span><span>int</span><span>(x) </span><span>for</span><span> x </span><span>in</span><span><span> text.</span><span>split</span><span>(</span></span><span>","</span><span>))</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑出来（<code>参考代码/s14.py</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>XOR key = 0x5a，盐 = qz2026</span></div></div><div><div><div>2</div></div><div><span>第 1 页：10 个数字，本页合计 42584</span></div></div><div><div><div>3</div></div><div><span>...</span></div></div><div><div><div>4</div></div><div><span>第 5 页：10 个数字，本页合计 51223</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>五页数字总和：251142（提交这个数字）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li><strong>一开 F12 就反复断住</strong>：反调试，<code>Ctrl+F8</code> 停用断点；或先 <code>window.setInterval</code> 打补丁再刷新。</li>
<li><strong>控制台里 <code>LSShield</code> 是 undefined</strong>：脚本是页面<strong>动态插 <code>&lt;script&gt;</code></strong> 加载的，等它加载完再取；
也可以直接 <code>fetch(init.script_url)</code> 把源码拿下来自己看。</li>
<li><strong>静态搜不到 <code>debugger</code> / <code>setInterval</code></strong>：正常，它们分别由 <code>(0, eval)(表[2])</code> 和 <code>表[1]</code> 生成。</li>
<li><strong><code>qz</code> 解出来是乱码</strong>：XOR key 不对（这一关是 <code>0x5a</code>，注意别用成了别的关卡的 key）。</li>
<li><strong><code>sig</code> 校验不过</strong>：盐不对，或者拼盐时多/少了分隔符（是 <code>明文 + 盐</code>，<strong>没有</strong>分隔符）。</li>
<li><strong><code>code: "1"</code></strong>：页码超范围，一共 5 页。</li>
<li><strong><code>/script</code> 返回 401</strong>：接口需要登录态，脚本里要带 Cookie。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><p><strong>Q：为什么要把 JS 放到接口里发，而不是直接写进静态文件？</strong>
A：静态文件是”看得见、搜得到、能下载”的；放到接口里至少能<strong>按会话下发</strong>（每人不完全一样的参数）、能<strong>随时改</strong>，
还想让你多花一步去”抓代码”。对爬虫来说多了一层”必须先登录、先分析”的门槛。</p><p><strong>Q：内置 MD5 有什么用？不是随便就绕过了吗？</strong>
A：它的作用不是”加密”（MD5 本来也不可逆），而是<strong>自校验 + 提高门槛</strong>：
让服务端能确认数据没被乱改，同时逼你读懂它的数据格式。</p><p><strong>Q：既然是前端校验，改一下不就行了？</strong>
A：可以，但<strong>判断题在后端</strong>。前端能改的只是”你自己看到的”；数据、答案、判分都在服务端，
所以正确姿势是<strong>还原算法</strong>，不是改前端。</p></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>这些手段只适合在<strong>授权范围内的练习环境</strong>里研究。真实站点有服务条款和技术措施保护，绕过它们可能违法。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch10/</id>
      <title type="text">第 10 关：RSA 传密钥 + AES 加密报文（顺带一层 JavaScript 混淆）</title>
      <published>2026-10-02T14:16:46.000Z</published>
      <updated>2026-10-02T14:16:46.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch10/"/>
      <summary type="text">本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起：请求头 sm 是每次现算的，报文也不是明文，靶场还只放你书单里的前四页。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起：请求头 <code>sm</code> 是<strong>每次现算</strong>的，报文也不是明文，靶场还只放你书单里的前四页。</p></blockquote>
<p>靶场：<code>/books?challenge=CH-010</code>（登录后打开）　参考解法：<code>参考代码/s12.py</code></p>
<section><h2>现象：请求头里有个看不懂的长串<a href="#现象请求头里有个看不懂的长串"><span>#</span></a></h2><p>打开靶场，F12 → Network，只有一条 <code>POST /api/challenges/CH-010/data</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>sm: NBUhZ9tR...（很长的 base64）@P1q8oX...（另一段 base64）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><strong>没有请求体</strong>：参数全在 <code>sm</code> 里；</li>
<li><strong>响应体不是 JSON</strong>：一串 base64 文本；</li>
<li>页面上的 5 个页码里<strong>最后 1 个是灰的</strong>，怎么点都不动；</li>
<li>把这条请求「Copy as cURL」重放一次 —— 大概率报 <code>请求已过期</code>。</li>
</ul></section>
<section><h2>辨型：两段字符 = RSA 传密钥、AES 传报文<a href="#辨型两段字符--rsa-传密钥aes-传报文"><span>#</span></a></h2><p>真实站点（<code>spa16.scrape.center</code> 那一类）就是这套组合拳，原因很实在：</p>

<table><thead><tr><th>算法</th><th>用途</th><th>为什么</th></tr></thead><tbody><tr><td>RSA</td><td><strong>只加密对称密钥</strong></td><td>非对称加密慢、还有长度上限，塞不下整页数据</td></tr><tr><td>AES</td><td>加密<strong>报文本体</strong></td><td>对称加密快，能加密任意长度</td></tr><tr><td>JS 混淆</td><td>让密钥/算法不好直接在源码里搜到</td><td>抬高静态分析成本</td></tr></tbody></table><p>所以 <code>sm</code> 的两段是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>sm = base64( RSA_PKCS1_v1_5(AES 密钥明文) )  +  "@"  +  base64( AES-128-CBC(明文 JSON) )</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>定位资源：脚本里躺着的三件套<a href="#定位资源脚本里躺着的三件套"><span>#</span></a></h2><p><code>&lt;head&gt;</code> 里依次加载三个脚本：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/static/vendor/crypto-js.js        # AES（和第 11 关共用一份）</span></div></div><div><div><div>2</div></div><div><span>/static/vendor/jsencrypt.min.js    # RSA</span></div></div><div><div><div>3</div></div><div><span>/static/books/security.js                # 混淆过的业务逻辑：生成 sm + 解密响应</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>security.js</code> 是 <strong>JavaScript Obfuscator</strong> 的产物：字符串全部收进数组、控制流被平坦化、还塞了死代码。别人怎么挖它的？两条路：</p><ol>
<li><strong>动态 hook（最快）</strong>——在控制台劫持几个函数，刷新页面，参数自己就打印出来了：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>raw</span><span> </span><span>=</span><span> </span><span>JSEncrypt</span><span>.</span><span>prototype</span><span>.</span><span>setPublicKey</span></div></div><div><div><div>2</div></div><div><span>JSEncrypt</span><span>.</span><span>prototype</span><span>.</span><span>setPublicKey</span><span> </span><span>=</span><span> </span><span>function</span><span><span> (</span><span>key</span><span>) { </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'PUBLIC KEY =</span><span>\n</span><span>'</span><span> </span><span>+</span><span><span> </span><span>key</span><span>); </span></span><span>return</span><span><span> </span><span>raw</span><span>.</span></span><span>call</span><span>(</span><span>this</span><span><span>, </span><span>key</span><span>) }</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>enc</span><span> </span><span>=</span><span><span> </span><span>CryptoJS</span><span>.</span></span><span>AES</span><span>.</span><span>encrypt</span></div></div><div><div><div>5</div></div><div><span><span>CryptoJS</span><span>.</span></span><span>AES</span><span>.</span><span>encrypt</span><span> </span><span>=</span><span> </span><span>function</span><span><span> (</span><span>plain</span><span>, </span><span>key</span><span>, </span><span>cfg</span><span>) { </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'明文:'</span><span><span>, </span><span>plain</span><span>, </span></span><span>'密钥:'</span><span><span>, </span><span>key</span><span>.</span></span><span>toString</span><span><span>(</span><span>CryptoJS</span><span>.</span></span><span>enc</span><span>.</span><span>Utf8</span><span>)); </span><span>return</span><span><span> </span><span>enc</span><span>.</span></span><span>apply</span><span>(</span><span>this</span><span>, </span><span>arguments</span><span>) }</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>静态解混淆</strong>——用 de4js / synchrony 之类的工具还原字符串数组。注意：<strong>混淆器不只做了 base64，还把结果大小写翻转了一遍</strong>，所以直接 <code>grep BEGIN</code> 是搜不到的。把数组元素抓出来，<code>swapcase()</code> 之后再 base64 解码，才能看到 PEM：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> token </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span>"</span><span>[</span><span>'</span><span>\"</span><span>]</span><span>(</span><span>[</span><span>A-Za-z0-9+/=</span><span>]{40,}</span><span>)</span><span>[</span><span>'</span><span>\"</span><span>]</span><span>"</span><span>, security_source):</span></div></div><div><div><div>2</div></div><div><span>    </span><span>for</span><span> candidate </span><span>in</span><span><span> (token, token.</span><span>swapcase</span><span>()):</span></span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>text </span><span>=</span><span> base64.</span><span>b64decode</span><span>(candidate </span><span>+</span><span> </span><span>"="</span><span><span> </span><span>*</span><span> (</span><span>-</span></span><span>len</span><span><span>(candidate) </span><span>%</span><span> </span></span><span>4</span><span><span>)).</span><span>decode</span><span>(</span></span><span>"utf-8"</span><span>)</span></div></div><div><div><div>4</div></div><div><span>        </span><span>if</span><span> </span><span>"BEGIN PUBLIC KEY"</span><span> </span><span>in</span><span> text:</span></div></div><div><div><div>5</div></div><div><span>            </span><span>print</span><span>(text)   </span><span># ← RSA 公钥到手（公钥本来就是公开的）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>还原公式<a href="#还原公式"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>key_text </span><span>=</span><span> </span></span><span>16</span><span> 个 </span><span>0</span><span>-</span><span>9a</span><span><span>-</span><span>f 的随机串                      </span></span><span># 每次请求现造一个</span></div></div><div><div><div>2</div></div><div><span><span>plain    </span><span>=</span><span> json.</span><span>dumps</span><span>({username, path, timestamp, pageNumber, pageSize})</span></span></div></div><div><div><div>3</div></div><div><span><span>aes_part </span><span>=</span><span> </span><span>base64</span><span>(</span><span>AES_CBC_PKCS7</span><span>(plain, key_text, </span></span><span>iv</span><span>=</span><span>"LS2026CH010IV000"</span><span>))</span></div></div><div><div><div>4</div></div><div><span><span>rsa_part </span><span>=</span><span> </span><span>base64</span><span>(</span><span>RSA_PKCS1_v1_5</span><span>(key_text, public_key))</span></span></div></div><div><div><div>5</div></div><div><span><span>sm       </span><span>=</span><span> rsa_part </span><span>+</span><span> </span></span><span>"@"</span><span><span> </span><span>+</span><span> aes_part</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>服务端会<strong>逐项核对</strong>（顺序大致如下），这就是为什么”复用同一个 sm 重放”必然失败：</p>

<table><thead><tr><th>检查</th><th>不通过时的表现</th></tr></thead><tbody><tr><td>能否解出 RSA 段 + AES 段</td><td><code>400 报文格式不正确</code>（此时它还没法用你的密钥加密回复，只能明文说）</td></tr><tr><td><code>username</code> 等于当前登录账号</td><td><code>请求与登录账号不一致</code></td></tr><tr><td><code>path</code> 等于接口路径</td><td><code>请求路径与签名不一致</code></td></tr><tr><td><code>abs(now - timestamp) &lt;= 300 秒</code></td><td><code>请求已过期，请重新生成凭据</code>（注意时间戳是<strong>毫秒</strong>）</td></tr><tr><td><code>pageNumber</code> 在 <code>1..5</code> 范围内</td><td><code>页码超出范围（一共 5 页）</code></td></tr></tbody></table><p>页码只有<strong>序号</strong>：<code>GET /api/challenges/CH-010/init</code>（页面初始化时就会请求）只返回 <code>username</code>、<code>page_count</code>、<code>visible_pages</code>、<code>page_size</code> —— 不告诉你数据库里到底是哪五页。页面只放出前 <code>visible_pages</code>（=4）页，<strong>第 5 页只能自己写脚本取</strong>。</p><p>序号 <code>n</code> 对应哪一页数据由服务端按账号决定（每个账号一套，同人稳定、人人不同），所以在本地自己造一个 <code>sm</code> 去请求序号 1、2、3、4、5，就是你该拿的全部数据。</p><p>响应体同样是 AES：<code>base64(AES_CBC_PKCS7(JSON, key_text, 同一个 IV))</code>，用<strong>这次请求的 keyText</strong> 解。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><p>见 <code>参考代码/s12.py</code>（<code>grab_public_key</code> + <code>build_sm</code> + <code>decrypt</code> + 逐页汇总）。核心就三行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>sm, key_text </span><span>=</span><span> </span><span>build_sm</span><span>(public_key, username, page, page_size)      </span></span><span># 现造密钥并封装（page 是序号）</span></div></div><div><div><div>2</div></div><div><span><span>body </span><span>=</span><span> </span><span>decrypt</span><span>(key_text, session.</span><span>post</span><span>(</span><span>DATA_PATH</span><span>, </span></span><span>headers</span><span><span>=</span><span>{</span></span><span>"sm"</span><span>: sm}).text)</span></div></div><div><div><div>3</div></div><div><span><span>total </span><span>+=</span><span> </span></span><span>sum</span><span>(row[</span><span>"id"</span><span>] </span><span>for</span><span> row </span><span>in</span><span> body[</span><span>"data"</span><span>][</span><span>"list"</span><span>])             </span><span># 五页取齐后求和</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑出来大概长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>账号 xiajiao 的书单：共 5 页（页面只放出前 4 页），每页 18 条</span></div></div><div><div><div>2</div></div><div><span>第 1 页：18 条，本页 id 合计 123642696</span></div></div><div><div><div>3</div></div><div><span>...</span></div></div><div><div><div>4</div></div><div><span>五页书籍 id 总和：601884792（提交这个数字）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li><strong><code>RSA.import_key</code> 报 “RSA key format is not supported”</strong>：PEM 的换行丢了。从混淆产物里解出来的字符串里是 <code>\n</code> 转义，解码后要保留换行（<code>s12.py</code> 里直接 <code>strip()</code> 即可）。</li>
<li><strong>解出来是乱码</strong>：IV 不对（必须是脚本里那个 16 字节常量）、密钥长度不对（16 字符 ≠ 16 位十六进制数，是<strong>按 UTF-8 取字节</strong>）、或者你用了 AES-ECB。</li>
<li><strong><code>请求已过期</code></strong>：时间戳单位写成了秒，<code>Date.now()</code> 是毫秒。</li>
<li><strong><code>页码超出范围</code></strong>：序号只有 <code>1..5</code>（<code>/init</code> 的 <code>page_count</code>），别把数据库页码当参数传。</li>
<li><strong><code>400 报文格式不正确</code></strong>：<code>sm</code> 的分隔符不是 <code>@</code>、RSA 段没做 base64、或者公钥抄错了。</li>
<li><strong>响应解不开</strong>：注意用的是<strong>本次请求</strong>的 keyText，不是第一次那个；每次请求都要重新生成。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><p><strong>Q：为什么不能只 RSA、或只 AES？</strong>
A：只 RSA 传不了长报文；只 AES 就得把密钥写在前端（等于公开）。混合加密是两者的折中：密钥每次随机、且只能被持有私钥的服务端读到。</p><p><strong>Q：公钥是公开的，那这套加密还有意义吗？</strong>
A：有。它防的是「别人拿到你的报文改改就能用」——服务端核对 <code>username</code>/<code>path</code>/<code>timestamp</code>/页码，等于给请求绑上了会话与时效。</p><p><strong>Q：为什么不干脆用浏览器自动化点五页？</strong>
A：可以，但那是把「每页都要重算密钥」的成本交给浏览器，而且不方便汇总求和。真实项目里更常见的是脚本直连。</p></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>这套东西只在<strong>授权范围内的练习环境</strong>里练手。真实站点有服务条款和法律法规，未经允许的批量抓取可能违规甚至违法。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch11/</id>
      <title type="text">第 11 关：无限 debugger + VM 混淆 + 签名请求头</title>
      <published>2026-10-02T14:16:46.000Z</published>
      <updated>2026-10-02T14:16:46.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch11/"/>
      <summary type="text">本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最&quot;脏&quot;：无限 debugger（关键字还是拼出来的）、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名，票价则靠 base6…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最”脏”：<strong>无限 debugger</strong>（关键字还是拼出来的）、<strong>base64 源码交给 <code>new Function</code> 在运行时编译</strong>、请求头 <code>m</code> 要现算签名，票价则靠 <strong>base64 + CSS 伪元素</strong>拼出来。</p></blockquote>
<p>靶场：<code>/mover?challenge=CH-011</code>（登录后打开）　参考解法：<code>参考代码/s13.py</code></p>
<section><h2>现象<a href="#现象"><span>#</span></a></h2><ol>
<li>打开靶场，页面数据正常。<strong>一按 F12</strong>，Sources 面板疯狂断住，断点停在一行看不出名堂的代码上，点继续还会再断 —— 这就是无限 debugger。</li>
<li>Network 里只有一条 <code>GET /api/challenges/CH-011/flights?page=1</code>，请求头有个 <code>m</code>：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>m: 6cee2f75d7dab9a29548288f66fb3025_1790950486</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看着像”32 位十六进制 + 下划线 + 秒”。</p><ol>
<li>页面上的票价是 <code>¥</code> 后面几个空白的 <code>&lt;span&gt;</code>，源码里搜不到数字：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"mv-digit d0a6fd0"</span><span>&gt;&lt;/</span><span>span</span><span>&gt;&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"mv-digit d2478eb"</span><span>&gt;&lt;/</span><span>span</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>直接请求接口（不带 <code>m</code>）→ <code>403 {"code":"1","message":"请求凭据无效或已过期"}</code>。</li>
<li>只有 5 个页码，<strong>最后一个是灰的</strong>。</li>
</ol></section>
<section><h2>分层：三层伪装，逐一剥<a href="#分层三层伪装逐一剥"><span>#</span></a></h2>

<table><thead><tr><th>层</th><th>手法</th><th>破法</th></tr></thead><tbody><tr><td>反调试</td><td>无限 <code>debugger</code>；关键字用 <code>['de','bu','g','ger'].join('')</code> 拼出来，整段逻辑藏在 base64 里由 <code>new Function</code> 编译</td><td>先<strong>停掉断点</strong>（见下），或者干脆不跟 JS、直接 hook</td></tr><tr><td>请求签名</td><td><code>m = md5(页码 + 毫秒时间戳 + 盐) + "_" + 秒</code></td><td>找到<strong>盐</strong>，自己复现 md5</td></tr><tr><td>数据展示</td><td>票价是 base64，页面上再用随机类名 + CSS <code>::before{content}</code> 逐字画</td><td>解 base64 就行；想从 DOM 抄则要去读样式表</td></tr></tbody></table></section>
<section><h2>第一步：让无限 debugger 闭嘴<a href="#第一步让无限-debugger-闭嘴"><span>#</span></a></h2><p>它长这样（<code>backend/static/mover/security.js</code> 里的真实逻辑，混淆后更难认）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span> </span><span>KEYWORD</span><span> </span><span>=</span><span> [</span><span>'de'</span><span>, </span><span>'bu'</span><span>, </span><span>'g'</span><span>, </span><span>'ger'</span><span>].</span><span>join</span><span>(</span><span>''</span><span>)   </span><span>// 静态搜 "debugger" 搜不到</span></div></div><div><div><div>2</div></div><div><span>function</span><span> </span><span>spin</span><span>() {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>new</span><span> </span><span>Function</span><span>(</span><span>KEYWORD</span><span>)()                          </span><span>// 每次现编译一次，断点打不住</span></div></div><div><div><div>4</div></div><div><span>  </span><span>setTimeout</span><span><span>(</span><span>spin</span><span>, </span></span><span>700</span><span>)</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span>spin</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>随手可用的几个办法：</p><ul>
<li><strong>DevTools 里按 <code>Ctrl+F8</code></strong>（或 Sources 面板右上角那个「禁止进入的斜杠图标」）<strong>Deactivate breakpoints</strong> —— 最省事；</li>
<li>在断住的那行右键 <strong>Never pause here</strong>；</li>
<li>控制台先打补丁再刷新：
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>window</span><span>.</span></span><span>Function</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Proxy</span><span><span>(</span><span>Function</span><span>, { </span></span><span>apply</span><span>:</span><span><span> (</span><span>t</span><span>, </span><span>a</span><span>) </span></span><span>=&gt;</span><span> { </span><span>return</span><span> </span><span>t</span><span>(</span><span>...</span><span><span>a</span><span>) } })  </span></span><span>// 甚至顺便 console.log 参数</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
<li>或者<strong>根本别跟</strong>：这一关的签名不依赖浏览器运行环境，直接 hook + 写脚本更快。</li>
</ul><blockquote><p>⚠️ 别用 “Disable JavaScript”：页面自己也要靠 JS 取数。</p></blockquote></section>
<section><h2>第二步：把盐挖出来<a href="#第二步把盐挖出来"><span>#</span></a></h2><p>盐（<code>XJ2026</code>）在 base64 里那份「VM 源码」中。三条路，任选：</p><p><strong>a) hook 摘要函数（最快）</strong> —— 页面加载了 crypto-js，全局有 <code>CryptoJS</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>raw</span><span> </span><span>=</span><span><span> </span><span>CryptoJS</span><span>.</span></span><span>MD5</span></div></div><div><div><div>2</div></div><div><span><span>CryptoJS</span><span>.</span></span><span>MD5</span><span> </span><span>=</span><span><span> (</span><span>s</span><span>) </span></span><span>=&gt;</span><span><span> { </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'md5 输入:'</span><span><span>, </span><span>s</span><span>); </span></span><span>return</span><span> </span><span>raw</span><span><span>(</span><span>s</span><span>) }</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>刷新页面，你会在控制台看到形如 <code>11234567890123XJ2026</code> 的明文：<strong>前缀是「页码 + 毫秒时间戳」，后缀就是盐</strong>。</p><p><strong>b) hook <code>Function</code>，让它把源码吐出来</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>rawFunction</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>Function</span></div></div><div><div><div>2</div></div><div><span><span>window</span><span>.</span></span><span>Function</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Proxy</span><span><span>(</span><span>rawFunction</span><span>, {</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>apply</span><span><span>(</span><span>target</span><span>, </span><span>thisArg</span><span>, </span><span>args</span><span>) { </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'VM 源码：</span><span>\n</span><span>'</span><span> </span><span>+</span><span><span> </span><span>args</span><span>[</span><span>args</span><span>.</span></span><span>length</span><span> </span><span>-</span><span> </span><span>1</span><span>]); </span><span>return</span><span> </span><span>target</span><span>(</span><span>...</span><span><span>args</span><span>) },</span></span></div></div><div><div><div>4</div></div><div><span>})</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>刷新后直接能看到那段 base64 解码后的源码（里面就有 <code>SALT</code> 和 <code>buildM</code>）。</p><p><strong>c) 静态解 base64</strong> —— 把产物里的长 base64 抠出来 <code>atob</code>。注意混淆器还给字符串做了<strong>大小写翻转</strong>（和 CH-010 抠公钥一样），所以更推荐 a/b 两条动态路线。</p></section>
<section><h2>第三步：复现签名<a href="#第三步复现签名"><span>#</span></a></h2><p>后端（和参考脚本）都在做同一件事：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>m </span><span>=</span><span> </span><span>md5</span><span>(</span></span><span>f</span><span>"</span><span>{</span><span>page</span><span>}{</span><span>毫秒时间戳</span><span>}{</span><span>盐</span><span>}</span><span>"</span><span><span>) </span><span>+</span><span> </span></span><span>"_"</span><span><span> </span><span>+</span><span> 秒</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>几个容易踩的点：</p><ul>
<li><strong>毫秒</strong>参与摘要，但<strong>发出去的只有秒</strong> → 服务端把那一秒内的 1000 个毫秒值各算一遍 md5 来比对（<code>_ch011_verify_m()</code>就是这么做的），所以别把毫秒直接拼进字符串发过去；</li>
<li><strong>页码参与签名</strong>：用第 2 页的签名去请求第 1 页会被拒；</li>
<li><strong>分隔符只能是 ASCII</strong>：HTTP 请求头是 Latin-1 的，塞个中文全角括号进去，浏览器会直接报
<code>Failed to read the 'headers' property from 'RequestInit': String contains non ISO-8859-1 code point</code>
（真实站点之所以敢用中文括号，是因为它把签名放在 <strong>GET 参数</strong>里）。</li>
</ul></section>
<section><h2>第四步：票价（base64 + CSS）<a href="#第四步票价base64--css"><span>#</span></a></h2><p>接口返回的每一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"id"</span><span>: </span><span>101</span><span>, </span><span>"airline"</span><span>: </span><span>"中国联合航空"</span><span>, </span><span>"flight"</span><span>: </span><span>"KN5911 波音737(中)"</span><span>,</span></div></div><div><div><div>2</div></div><div><span> </span><span>"depart"</span><span>: </span><span>"13:50"</span><span>, </span><span>"arrive"</span><span>: </span><span>"17:30"</span><span>, </span><span>"duration"</span><span>: </span><span>"3小时40分"</span><span>,</span></div></div><div><div><div>3</div></div><div><span> </span><span>"value"</span><span>: </span><span>"MTQ3Mg=="</span><span>,                 </span><span>// base64("1472")</span></div></div><div><div><div>4</div></div><div><span> </span><span>"cls"</span><span>: [</span><span>"d46c76e"</span><span>, </span><span>"d0a6fd0"</span><span>, </span><span>"de8a727"</span><span>, </span><span>"d0041c8"</span><span>]}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>页面把 <code>cls</code> 逐字渲染成空 span，然后往 <code>&lt;head&gt;</code> 插一份 CSS：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[</span><span>class</span><span>~=</span><span>"d46c76e"</span><span>]</span><span>::before</span><span> { content: </span><span>"1"</span><span> }</span></div></div><div><div><div>2</div></div><div><span>[</span><span>class</span><span>~=</span><span>"d0a6fd0"</span><span>]</span><span>::before</span><span> { content: </span><span>"4"</span><span> }</span></div></div><div><div><div>3</div></div><div><span>...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>所以在 DOM 里 <code>textContent</code> 永远是空的 —— 数字<strong>只活在样式表里</strong>。写脚本的人不用管这一层：<code>base64</code> 解开就是数字。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><p>见 <code>参考代码/s13.py</code>。核心就三行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>m </span><span>=</span><span> </span><span>build_m</span><span>(page)                                    </span></span><span># md5(page + 毫秒 + 盐) + "_" + 秒</span></div></div><div><div><div>2</div></div><div><span><span>payload </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>DATA_PATH</span><span>, </span></span><span>params</span><span><span>=</span><span>{</span></span><span>"page"</span><span>: page}, </span><span>headers</span><span><span>=</span><span>{</span></span><span>"m"</span><span><span>: m}).</span><span>json</span><span>()</span></span></div></div><div><div><div>3</div></div><div><span><span>total </span><span>+=</span><span> </span></span><span>sum</span><span>(</span><span>int</span><span><span>(base64.</span><span>b64decode</span><span>(row[</span></span><span>"value"</span><span>])) </span><span>for</span><span> row </span><span>in</span><span> payload[</span><span>"data"</span><span>])</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑出来：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>共 5 页（页面只放出前 4 页），每页 10 条</span></div></div><div><div><div>2</div></div><div><span>第 1 页：10 条，本页合计 11111</span></div></div><div><div><div>3</div></div><div><span>...</span></div></div><div><div><div>4</div></div><div><span>五页票价总和：65635（提交这个数字）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li><strong><code>403 请求凭据无效或已过期</code></strong>：盐不对 / 页码没参与签名 / 时间戳用了秒（参与摘要的必须是<strong>毫秒</strong>）/ 本机时间与服务器差太多。</li>
<li><strong>浏览器报 <code>String contains non ISO-8859-1 code point</code></strong>：请求头里放了非 ASCII（比如中文括号）。</li>
<li><strong>一开 F12 就断</strong>：<code>Ctrl+F8</code> 停用断点，或给 <code>Function</code> 打补丁。</li>
<li><strong>自己 <code>new Function(payload)</code> 报 <code>SyntaxError</code></strong>：函数体要先 <code>atob</code> 解码；另外 <code>Function</code> 的<strong>最后一个参数</strong>才是函数体，前面的都是形参名（别把 base64 当形参名传）。</li>
<li><strong>页面价格是空白</strong>：正常现象 —— 数字在 CSS 里（<code>mv-digit</code> 的 <code>::before</code>）。用 <code>getComputedStyle(el, '::before').content</code> 才能看到。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><p><strong>Q：这算”反爬”还是”反调试”？</strong>
A：这几种手法在真实站点里通常一起出现（机票、电商比价那类页面尤其多）：拖慢你读代码的速度，同时用签名保证请求是”当场生成”的。</p><p><strong>Q：为什么非要 <code>md5</code> 不可？</strong>
A：它只是个便宜的摘要：既能让服务端判断”这个请求是照着我的算法生成的”，又不至于像 HMAC 那样给客户端一把真钥匙（客户端 JS 里的东西都能被扒出来，<strong>本质上是”提高门槛”，不是”绝对安全”</strong>）。</p><p><strong>Q：既然盐能扒出来，签名还有意义吗？</strong>
A：有。它把”随便构造请求”变成”你得先看懂我的 JS”，并且配合时间戳让旧签名很快失效。</p></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>这些手段只适合在<strong>授权范围内的练习环境</strong>里研究。真实站点有服务条款和技术措施保护，绕过它们可能违法。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch1/</id>
      <title type="text">Python 爬虫入门实战：服务端渲染页面怎么取数（CH-001）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch1/"/>
      <summary type="text">本文是 LearnSpider 第 1 关的解密教程。它练的不是「怎么破加密」，而是先判断数据到底在哪：这一关的数据只在服务端渲染出来的 HTML 里，源码里没有接口。答案是第一页 HTML 源码的字符数。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 1 关的解密教程。它练的不是「怎么破加密」，而是<strong>先判断数据到底在哪</strong>：这一关的数据只在服务端渲染出来的 HTML 里，源码里没有接口。答案是第一页 HTML 源码的<strong>字符数</strong>。</p></blockquote>
<section><h2>先搞清楚这一关在考什么<a href="#先搞清楚这一关在考什么"><span>#</span></a></h2><p>前几关的坑通常藏在接口里，这一关反过来：页面是 Jinja2 <strong>服务端渲染</strong>的，电影数据直接写进了 HTML 源码。</p><p>所以拿到数据的方式只有一个：把页面请求下来，读源码。</p><p>但答案不是「抓到了哪些电影」，而是<strong>第一页源码有多少个字符</strong>。也就是说：</p><ol>
<li>请求 <code>GET /playground/movies/static</code>（不加参数就是第 1 页）；</li>
<li>把响应体当成<strong>文本</strong>（不是字节）数长度；</li>
<li>把数字提交到挑战广场。</li>
</ol></section>
<section><h2>现象：源码里全是数据<a href="#现象源码里全是数据"><span>#</span></a></h2><p>浏览器打开靶场页 <code>/playground/movies/static</code>，页面上是电影卡片。右键「查看网页源代码」，能看到 <code>&lt;li&gt;</code> 里直接写着电影名、评分、评价人数：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>li</span><span> </span><span>class</span><span>=</span><span>"movie-item"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>h3</span><span> </span><span>class</span><span>=</span><span>"movie-title"</span><span>&gt;肖申克的救赎&lt;/</span><span>h3</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"movie-rating"</span><span>&gt;9.7&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"movie-rating-count"</span><span>&gt;3235402人评价&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;/</span><span>li</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Network 面板里只有一个 <code>document</code> 请求，没有 XHR。这就说明数据在 HTML 里，而不是接口里。</p></section>
<section><h2>三个最容易数错的地方<a href="#三个最容易数错的地方"><span>#</span></a></h2>

<table><thead><tr><th>写法</th><th>数出来的是什么</th><th>对不对</th></tr></thead><tbody><tr><td><code>len(response.text)</code></td><td>解码后<strong>字符数</strong></td><td>✅ 这一关要的就是这个</td></tr><tr><td><code>len(response.content)</code></td><td>原始<strong>字节数</strong>（UTF-8 中文一个字 3 字节）</td><td>❌ 会大好几倍</td></tr><tr><td><code>len(response.text.strip())</code></td><td>去掉了首尾空白</td><td>❌ 少几十个字符</td></tr></tbody></table><p>还有一个隐蔽的坑：<strong>编码</strong>。如果 <code>response.encoding</code> 猜错了（比如把 UTF-8 当 ISO-8859-1），<code>response.text</code> 会变成乱码，字符数也就跟着错。</p><p>服务端已经声明了 <code>Content-Type: text/html; charset=utf-8</code>，正常情况下 requests 会照用。求稳可以显式指定：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>response.encoding </span><span>=</span><span> </span></span><span>"utf-8"</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-001：答案 = 第一页 HTML 源码的字符数"""</span></div></div><div><div><div>2</div></div><div><span>import</span><span> sys</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>7</div></div><div><span><span>PAGE_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/playground/movies/static"</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>headers </span><span>=</span><span> {</span></span></div></div><div><div><div>10</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>    </span><span>"Accept-Language"</span><span>: </span><span>"zh-CN,zh;q=0.9"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>    </span><span>"User-Agent"</span><span>: (</span></div></div><div><div><div>13</div></div><div><span>        </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "</span></div></div><div><div><div>14</div></div><div><span>        </span><span>"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span><span>response </span><span>=</span><span> requests.</span><span>get</span><span>(</span><span>PAGE_URL</span><span>, </span></span><span>headers</span><span><span>=</span><span>headers, </span></span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>19</div></div><div><span><span>response.</span><span>raise_for_status</span><span>()</span></span></div></div><div><div><div>20</div></div><div><span><span>response.encoding </span><span>=</span><span> </span></span><span>"utf-8"</span><span>          </span><span># 显式指定，避免猜错编码导致字符数不对</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span><span>html </span><span>=</span><span> response.text</span></span></div></div><div><div><div>23</div></div><div><span>print</span><span>(</span><span>"状态码："</span><span>, response.status_code)</span></div></div><div><div><div>24</div></div><div><span>print</span><span>(</span><span>"Content-Type："</span><span><span>, response.headers.</span><span>get</span><span>(</span></span><span>"Content-Type"</span><span>))</span></div></div><div><div><div>25</div></div><div><span>print</span><span>(</span><span>"源码字符数："</span><span>, </span><span>len</span><span>(html))</span></div></div><div><div><div>26</div></div><div><span>print</span><span>(</span><span>"源码字节数（对比用）："</span><span>, </span><span>len</span><span>(response.content))</span></div></div><div><div><div>27</div></div><div><span>print</span><span>(</span><span>"页面里有没有电影名："</span><span>, </span><span>"movie-title"</span><span> </span><span>in</span><span> html)</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span>if</span><span> </span><span>len</span><span><span>(sys.argv) </span><span>&gt;</span><span> </span></span><span>1</span><span>:</span></div></div><div><div><div>30</div></div><div><span>    </span><span># 顺手核对一下第一页有多少部电影</span></div></div><div><div><div>31</div></div><div><span>    </span><span>print</span><span>(</span><span>"电影卡片数："</span><span><span>, html.</span><span>count</span><span>(</span></span><span>'class="movie-item"'</span><span>))</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>跑完把 <code>源码字符数</code> 提交到挑战广场的 CH-001 答题框即可。参考实现见 <code>参考代码/s1.py</code>。</p></section>
<section><h2>顺手确认一下「第 2 页也这样」<a href="#顺手确认一下第-2-页也这样"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>page2 </span><span>=</span><span> requests.</span><span>get</span><span>(</span><span>PAGE_URL</span><span>, </span></span><span>params</span><span><span>=</span><span>{</span></span><span>"page"</span><span>: </span><span>2</span><span>}, </span><span>headers</span><span><span>=</span><span>headers, </span></span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>2</div></div><div><span>print</span><span>(</span><span>len</span><span>(page2.text))   </span><span># 和第一页长度不同，说明确实在分页</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这一关只需要第一页；但确认「改了参数内容会变」是好习惯 —— 能避免你把一个静态的缓存页当成接口。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>源码里搜页面上确定存在的字（电影名），搜得到 → 数据在 HTML 里。</li>
<li>数长度前先确认 <code>response.encoding</code>，别让乱码影响字符数。</li>
<li>用 <code>.text</code>（字符）还是 <code>.content</code>（字节）先想清楚，两者差 2~3 倍。</li>
<li>不要 <code>.strip()</code>、不要 <code>re.sub(r"\s+", "")</code> —— 这一关答案要的就是原始长度。</li>
<li>提交答案要在登录状态下做。</li>
<li>如果数出来差一点点，先对比 <code>len(html)</code> 和 <code>len(html.encode("utf-8"))</code>，确认自己数的是字符。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>为什么答案不是「电影数量」？<a href="#为什么答案不是电影数量"><span>#</span></a></h3><p>题面写的是「第一页源码的字符数」。这类题让你先确认<strong>源码就是数据源</strong>，再确认你拿到的响应体完整、没被二次处理。</p></section><section><h3>用 <code>requests.get(...).text</code> 和浏览器「查看源代码」长度一样吗？<a href="#用-requestsgettext-和浏览器查看源代码长度一样吗"><span>#</span></a></h3><p>一样（前提是编码一致、没做 gzip 之外的额外转换）。浏览器 DevTools 里看到的换行是 <code>\n</code>，requests 也不会替你改成 <code>\r\n</code>。</p></section><section><h3>需要登录吗？<a href="#需要登录吗"><span>#</span></a></h3><p>这一关的靶场页本身不需要登录，但<strong>提交答案要登录</strong>，所以脚本里要不要带 cookie 取决于你是想直接提交，还是只想算出数字。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>这关的数据是自己靶场的练习数据。真实站点里，服务端渲染页通常还带缓存与 CDN，抓之前先看 robots 与条款，控制频率。</p><p>去挑战广场提交答案，然后进 CH-002：数据会从源码里消失，只剩接口。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch2/</id>
      <title type="text">Python 爬虫接口实战：翻页取完再累加（CH-002）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch2/"/>
      <summary type="text">本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里，只能从分页接口拿；答案是全部电影「评分总人数」的累加值。重点是：怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里，只能从分页接口拿；答案是<strong>全部电影「评分总人数」的累加值</strong>。重点是：怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。</p></blockquote>
<section><h2>现象：源码里没有数据了<a href="#现象源码里没有数据了"><span>#</span></a></h2><p>打开 <code>/movies?challenge=CH-002</code>，电影卡片正常显示。但「查看源代码」里搜不到任何一个电影名 —— 页面是 Vue 渲染的，数据只能来自 XHR。</p><p>F12 → Network → Fetch/XHR，刷新页面，会看到一个请求：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/movies?page=1&amp;page_size=12</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应长这样（字段是精简过的）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"items"</span><span>: [</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"title"</span><span>: </span><span>"肖申克的救赎"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"rating"</span><span>: </span><span>9.7</span><span>,</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"rating_text"</span><span>: </span><span>"9.7"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>      </span><span>"rating_count_text"</span><span>: </span><span>"3235402人评价"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>      </span><span>"rating_count"</span><span>: </span><span>3235402</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>11</div></div><div><span>  </span><span>"page"</span><span>: </span><span>1</span><span>,</span></div></div><div><div><div>12</div></div><div><span>  </span><span>"page_size"</span><span>: </span><span>12</span><span>,</span></div></div><div><div><div>13</div></div><div><span>  </span><span>"total_movies"</span><span>: </span><span>249</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>接口故意不给你的东西<a href="#接口故意不给你的东西"><span>#</span></a></h2><p><code>/api/movies</code> <strong>不会</strong>下发整批统计字段：</p>

<table><thead><tr><th>字段</th><th>状态</th></tr></thead><tbody><tr><td><code>total_rating_score</code>（评分总和）</td><td>被剥掉了</td></tr><tr><td><code>total_rating_count</code>（评价人数总和）</td><td>被剥掉了 ← 正是答案</td></tr><tr><td><code>top_movie</code>（评分最高那部）</td><td>被剥掉了</td></tr><tr><td><code>total_movies</code>（共多少部）</td><td>✅ 有，用来算要翻几页</td></tr></tbody></table><p>所以「直接读一个字段就交卷」这条路被堵死了，必须一页一页取回来自己累加。</p><p>顺带说一句：<code>page_size</code> 上限是 100。想「一次要 1000 条」也会被服务端截回 100。</p></section>
<section><h2>数据规模<a href="#数据规模"><span>#</span></a></h2><ul>
<li>共 249 部电影，每页 12 条 → 21 页（最后一页可能不满）。</li>
<li>判断翻页结束的两种写法：<code>page * page_size &gt;= total_movies</code>，或者某一页返回的 <code>items</code> 为空。</li>
</ul></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-002：答案 = 全部电影「评分总人数」之和"""</span></div></div><div><div><div>2</div></div><div><span>import</span><span> re</span></div></div><div><div><div>3</div></div><div><span>import</span><span> time</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>8</div></div><div><span><span>API_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/movies"</span></div></div><div><div><div>9</div></div><div><span><span>PAGE_SIZE</span><span> </span><span>=</span><span> </span></span><span>12</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></span></div></div><div><div><div>12</div></div><div><span><span>session.headers.</span><span>update</span><span>({</span></span></div></div><div><div><div>13</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"application/json, text/plain, */*"</span><span>,</span></div></div><div><div><div>14</div></div><div><span>    </span><span>"Accept-Language"</span><span>: </span><span>"zh-CN,zh;q=0.9"</span><span>,</span></div></div><div><div><div>15</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/movies?challenge=CH-002"</span><span>,</span></div></div><div><div><div>16</div></div><div><span>    </span><span>"User-Agent"</span><span>: (</span></div></div><div><div><div>17</div></div><div><span>        </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "</span></div></div><div><div><div>18</div></div><div><span>        </span><span>"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>20</div></div><div><span>})</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span># 数据是按账号派生的，所以要带登录态：</span></div></div><div><div><div>23</div></div><div><span># 浏览器登录后 F12 -&gt; Application -&gt; Cookies -&gt; 复制 session 的值</span></div></div><div><div><div>24</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>25</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>def</span><span> </span><span>rating_count</span><span>(</span><span>movie</span><span>):</span></div></div><div><div><div>29</div></div><div><span>    </span><span>"""优先用字段，字段缺失/为 0 时回头从 '3235402人评价' 里抠数字。"""</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>count </span><span>=</span><span> movie.</span><span>get</span><span>(</span><span>"rating_count"</span><span>) </span><span>or</span><span> </span><span>0</span></div></div><div><div><div>31</div></div><div><span>    </span><span>if</span><span> count:</span></div></div><div><div><div>32</div></div><div><span>        </span><span>return</span><span> </span><span>int</span><span>(count)</span></div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>match </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"</span><span>(</span></span><span>[</span><span>\d</span><span>,</span><span>]+</span><span><span>)</span><span>\s</span></span><span>*</span><span>人评价"</span><span><span>, movie.</span><span>get</span><span>(</span></span><span>"rating_count_text"</span><span>, </span><span>""</span><span>))</span></div></div><div><div><div>34</div></div><div><span>    </span><span>return</span><span> </span><span>int</span><span><span>(match.</span><span>group</span><span>(</span></span><span>1</span><span><span>).</span><span>replace</span><span>(</span></span><span>","</span><span>, </span><span>""</span><span>)) </span><span>if</span><span> match </span><span>else</span><span> </span><span>0</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div>
</div></div><div><div><div>37</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>page </span><span>=</span><span> </span><span>1</span></div></div><div><div><div>39</div></div><div><span><span>    </span></span><span>total_movies </span><span>=</span><span> </span><span>None</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>total_count </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>41</div></div><div><span><span>    </span></span><span>movies </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>    </span><span>while</span><span> </span><span>True</span><span>:</span></div></div><div><div><div>44</div></div><div><span><span>        </span></span><span>response </span><span>=</span><span> session.</span><span>get</span><span>(</span></div></div><div><div><div>45</div></div><div><span><span>            </span></span><span>API_URL</span><span>, </span><span>params</span><span><span>=</span><span>{</span></span><span>"page"</span><span>: page, </span><span>"page_size"</span><span><span>: </span><span>PAGE_SIZE</span><span>}, </span></span><span>timeout</span><span>=</span><span>10</span></div></div><div><div><div>46</div></div><div><span><span>        </span></span><span>)</span></div></div><div><div><div>47</div></div><div><span><span>        </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>48</div></div><div><span><span>        </span></span><span>payload </span><span>=</span><span> response.</span><span>json</span><span>()</span></div></div><div><div><div>49</div></div><div>
</div></div><div><div><div>50</div></div><div><span><span>        </span></span><span>items </span><span>=</span><span> payload.</span><span>get</span><span>(</span><span>"items"</span><span>, [])</span></div></div><div><div><div>51</div></div><div><span>        </span><span>if</span><span> </span><span>not</span><span> items:</span></div></div><div><div><div>52</div></div><div><span>            </span><span>break</span></div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div><span><span>        </span></span><span>total_movies </span><span>=</span><span> payload.</span><span>get</span><span>(</span><span>"total_movies"</span><span>, total_movies)</span></div></div><div><div><div>55</div></div><div><span>        </span><span>for</span><span> movie </span><span>in</span><span> items:</span></div></div><div><div><div>56</div></div><div><span><span>            </span></span><span>total_count </span><span>+=</span><span> </span><span>rating_count</span><span>(movie)</span></div></div><div><div><div>57</div></div><div><span><span>            </span></span><span>movies </span><span>+=</span><span> </span><span>1</span></div></div><div><div><div>58</div></div><div>
</div></div><div><div><div>59</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page</span><span>:&gt;2</span><span>}</span><span> 页：</span><span>{</span><span>len</span><span>(items)</span><span>}</span><span> 条，累计 </span><span>{</span><span>movies</span><span>}</span><span> 条，"</span></div></div><div><div><div>60</div></div><div><span>              </span><span>f</span><span>"评价人数小计 </span><span>{</span><span>total_count</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>61</div></div><div>
</div></div><div><div><div>62</div></div><div><span>        </span><span>if</span><span> total_movies </span><span>and</span><span><span> movies </span><span>&gt;=</span><span> total_movies:</span></span></div></div><div><div><div>63</div></div><div><span>            </span><span>break</span></div></div><div><div><div>64</div></div><div><span><span>        </span></span><span>page </span><span>+=</span><span> </span><span>1</span></div></div><div><div><div>65</div></div><div><span><span>        </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)          </span><span># 别把靶场当压力测试</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>共 </span><span>{</span><span>movies</span><span>}</span><span> 部电影，评价人数总和：</span><span>{</span><span>total_count</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>68</div></div><div>
</div></div><div><div><div>69</div></div><div>
</div></div><div><div><div>70</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>71</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>把最后的数字提交到 CH-002 答题框即可。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>源码里搜不到数据 → 打开 Network 的 <code>Fetch/XHR</code>，找首屏那个请求。</li>
<li>右键 → <code>Copy as cURL</code>，先在终端跑通一次，确认只差 Cookie。</li>
<li>看响应里有没有现成的总量字段；有就省事，没有就老老实实翻页。</li>
<li>翻页前先拿 <code>total_movies</code> 和 <code>page_size</code> 算出总页数，别写成死循环。</li>
<li>每页间隔 0.5s 以上。</li>
<li>解析用字段优先、文本兜底（<code>rating_count</code> 拿不到就正则 <code>([\d,]+)人评价</code>）。</li>
<li>累加前把一千分隔符去掉（<code>"1,234"</code> → <code>1234</code>）。</li>
<li>结束条件写成「取到的条数 ≥ 总数」或「这一页为空」，不要写「拿到 21 页」。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>为什么我直接写 <code>page_size=1000</code> 也只能拿到 100 条？<a href="#为什么我直接写-page_size1000-也只能拿到-100-条"><span>#</span></a></h3><p>服务端有上限（<code>min(100, max(1, page_size))</code>），超了就按 100 处理。这是接口很常见的保护写法。</p></section><section><h3>数据为什么每个人不一样？<a href="#数据为什么每个人不一样"><span>#</span></a></h3><p>电影数据是共用的一份，但很多关卡的数据是<strong>按账号派生</strong>的（这道题的答案会不会变，取决于靶场实现）。所以别抄别人的答案，自己跑一遍。</p></section><section><h3>需要登录吗？<a href="#需要登录吗"><span>#</span></a></h3><p><code>/api/movies</code> 本身公开可读；提交答案必须登录。</p></section><section><h3>只抓到 20 页就停了？<a href="#只抓到-20-页就停了"><span>#</span></a></h3><p>说明结束条件写得不对：最后一页可能只有 9 条（不满 12 条），这时要停，但前面 20 页都是满的。用「累计条数 ≥ total_movies」判断最稳。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>翻页是很典型的「礼貌爬取」场景：按页取、留间隔、别并发轰炸。真实站点上，疯狂翻页最容易触发限流甚至封号。</p><p>去挑战广场提交，然后进 CH-003：数据不再是文本，而是一个你猜不到路径的文件。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch3/</id>
      <title type="text">Python 爬虫实战：随机下发路径的资源怎么下载（CH-003）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch3/"/>
      <summary type="text">本文是 LearnSpider 第 3 关的解密教程。这一关要把图片下载下来，答案是这张图片的字节数。真正的考点是：拿到接口给的 image_id / image_url 之后，别把「字节」和「字符」搞混，也别拖太久（图片是随机下发的）。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 3 关的解密教程。这一关要把图片下载下来，答案是<strong>这张图片的字节数</strong>。真正的考点是：拿到接口给的 <code>image_id</code> / <code>image_url</code> 之后，别把「字节」和「字符」搞混，也别拖太久（图片是随机下发的）。</p></blockquote>
<section><h2>现象：图片地址在页面源码里找不到<a href="#现象图片地址在页面源码里找不到"><span>#</span></a></h2><p>在挑战广场点开 CH-003，抽屉里有一个「获取图片」按钮。点一下，页面会请求：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/challenges/CH-003/image</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"challenge_id"</span><span>: </span><span>"CH-003"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"image_id"</span><span>: </span><span>"e33a1790898939fa"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"image_url"</span><span>: </span><span>"https://spider.jsnote.top/api/challenges/CH-003/image/e33a1790898939fa"</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>图片本体在 <code>image_url</code> 上，但它<strong>不是磁盘文件名</strong>：</p><ul>
<li>磁盘上图片一直叫 <code>challenge-01.jpg</code>、<code>challenge-02.jpg</code>（稳定名字，方便维护）；</li>
<li>对外暴露的是随机的 <code>image_id</code>，接口用它反查磁盘文件再 <code>send_file</code>。</li>
</ul><p>所以「猜路径下载」这条路是堵死的：<strong>每次都要先调一次接口拿 <code>image_id</code> + <code>image_url</code></strong>。</p></section>
<section><h2>目标是「字节数」不是「字符数」<a href="#目标是字节数不是字符数"><span>#</span></a></h2><p>图片是二进制，正确姿势是 <code>len(response.content)</code>：</p>

<table><thead><tr><th>写法</th><th>结果</th></tr></thead><tbody><tr><td><code>len(response.content)</code></td><td>✅ 图片真实字节数</td></tr><tr><td><code>len(response.text)</code></td><td>❌ 二进制被当文本解码，乱码 + 长度不对</td></tr><tr><td><code>len(response.text.encode("utf-8"))</code></td><td>❌ 双重编码，更长，而且可能报错</td></tr></tbody></table><p>而且这次提交不只是填一个数字：<strong>还要带上 <code>image_id</code></strong>，服务端要靠它找到你这一张图去核对大小。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-003：答案 = 随机下发那张图片的字节数（提交时带 image_id）"""</span></div></div><div><div><div>2</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>5</div></div><div><span><span>PICK_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-003/image"</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></span></div></div><div><div><div>8</div></div><div><span><span>session.headers.</span><span>update</span><span>({</span></span></div></div><div><div><div>9</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"*/*"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>    </span><span>"User-Agent"</span><span>: (</span></div></div><div><div><div>12</div></div><div><span>        </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "</span></div></div><div><div><div>13</div></div><div><span>        </span><span>"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>15</div></div><div><span>})</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span># 图片是按「当前会话」下发的，并且提交要带登录态 —— 整个流程用一个 Session + 登录 cookie</span></div></div><div><div><div>18</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>19</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>23</div></div><div><span>    </span><span># 1) 先让服务端发一张图</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>pick </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>PICK_URL</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>pick.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>data </span><span>=</span><span> pick.</span><span>json</span><span>()</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>image_id </span><span>=</span><span> data[</span><span>"image_id"</span><span>]</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>image_url </span><span>=</span><span> data[</span><span>"image_url"</span><span>]</span></div></div><div><div><div>29</div></div><div><span>    </span><span>print</span><span>(</span><span>"拿到图片："</span><span>, image_id)</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>    </span><span># 2) 用返回的地址下载本体（别自己拼路径：路径就是随机 image_id）</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>image </span><span>=</span><span> session.</span><span>get</span><span>(image_url, </span><span>timeout</span><span>=</span><span>20</span><span>)</span></div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>image.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>size </span><span>=</span><span> </span><span>len</span><span>(image.content)                 </span><span># ⚠️ content（字节），不是 text（字符）</span></div></div><div><div><div>36</div></div><div><span>    </span><span>print</span><span>(</span><span>"Content-Type："</span><span><span>, image.headers.</span><span>get</span><span>(</span></span><span>"Content-Type"</span><span>))</span></div></div><div><div><div>37</div></div><div><span>    </span><span>print</span><span>(</span><span>"Content-Length（服务端声明的）："</span><span><span>, image.headers.</span><span>get</span><span>(</span></span><span>"Content-Length"</span><span>))</span></div></div><div><div><div>38</div></div><div><span>    </span><span>print</span><span>(</span><span>"实际字节数："</span><span>, size)</span></div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div><span>    </span><span># 存的文件是个 JPEG：开头 FFD8</span></div></div><div><div><div>41</div></div><div><span>    </span><span>print</span><span>(</span><span>"文件头："</span><span>, image.content[:</span><span>4</span><span><span>].</span><span>hex</span><span>())</span></span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>答案：</span><span>{</span><span>size</span><span>}</span><span>  （提交时 body 带上 image_id = </span><span>{</span><span>image_id</span><span>}</span><span>）"</span><span>)</span></div></div><div><div><div>44</div></div><div><span>    </span><span>print</span><span>(</span><span>"提交示例：POST /api/challenges/CH-003/submit "</span></div></div><div><div><div>45</div></div><div><span>          </span><span>f</span><span>'</span><span>{{</span><span>"answer": </span><span>{</span><span>size</span><span>}</span><span>, "image_id": "</span><span>{</span><span>image_id</span><span>}</span><span>"</span><span>}}</span><span>'</span><span>)</span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div>
</div></div><div><div><div>48</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>49</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>为什么每一步都要在同一个 Session 里<a href="#为什么每一步都要在同一个-session-里"><span>#</span></a></h2><p><code>GET /api/challenges/CH-003/image</code> 会把这张图片记进当前会话（<code>session["ch003_image_id"]</code>）：</p><ul>
<li>如果你提交时没带 <code>image_id</code>，服务端会退回用会话里最近下发的那张 —— 换个 Session 就对不上了；</li>
<li>图片是<strong>随机</strong>下发的，再点一次「获取图片」就是另一张、另一个大小。</li>
</ul><p>所以正确顺序是：拿图 → 立刻下载 → 立刻算大小 → 立刻提交。跨天再提交，很可能「图片对不上」。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>别猜图片路径，先调 <code>GET /api/challenges/CH-003/image</code>。</li>
<li>用返回的 <code>image_url</code> 原样请求（它是绝对地址，服务端按当前请求算出来的）。</li>
<li>大小用 <code>len(response.content)</code>。</li>
<li>顺手打一下 <code>Content-Length</code>，和实际字节数对不上就先怀疑传输被截断。</li>
<li>检查文件头（JPEG 是 <code>ffd8</code>，PNG 是 <code>89504e47</code>），确认下下来的真是图片而不是错误页。</li>
<li>全流程放在一个 Session 里，别中途换 Cookie。</li>
<li>提交时带上 <code>image_id</code>。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>为什么我下载下来的字节数和浏览器看到的文件大小不一样？<a href="#为什么我下载下来的字节数和浏览器看到的文件大小不一样"><span>#</span></a></h3><p>先看是不是被 gzip 了（图片一般不会）。再看是不是拿 <code>.text</code> 数了字符 —— 二进制文件用文本方式读，长度必然不对。</p></section><section><h3>同一张图片能反复下载吗？<a href="#同一张图片能反复下载吗"><span>#</span></a></h3><p>能，只要 <code>image_id</code> 还有效（<code>challenge_images</code> 表里登记着）。但<strong>大小是固定的</strong>，重复下载不会让答案变化；换个 <code>image_id</code> 才会变。</p></section><section><h3>接口返回的 <code>image_url</code> 为什么是完整域名？<a href="#接口返回的-image_url-为什么是完整域名"><span>#</span></a></h3><p>服务端用 <code>url_for(..., _external=True)</code> 生成的，这样你在任何页面上都能直接拿去请求，不用自己拼域名。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>下载资源类接口在真实站点上通常带鉴权与防盗链（Referer 校验、临时签名 URL）。练习里只在自己的靶场跑；真实站点的图片请遵守版权与使用条款。</p><p>去挑战广场提交，然后进 CH-004：这次的伪装不在数据里，而在<strong>你的网络握手</strong>上。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch4/</id>
      <title type="text">Python 爬虫 TLS 指纹实战：requests 被挂住时怎么查（CH-004）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch4/"/>
      <summary type="text">本文是 LearnSpider 第 4 关的解密教程。这一关的接口数据本身是明文 JSON，难点在你根本连不上：requests 会一直卡到超时，服务端什么都不返回。答案是 5 页共 50 条关键词的 CPC 之和（保留 2 位小数）。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 4 关的解密教程。这一关的接口数据本身是明文 JSON，难点在<strong>你根本连不上</strong>：<code>requests</code> 会一直卡到超时，服务端什么都不返回。答案是 5 页共 50 条关键词的 CPC 之和（保留 2 位小数）。</p></blockquote>
<section><h2>现象：请求「不报错，也不返回」<a href="#现象请求不报错也不返回"><span>#</span></a></h2><p>先按常规思路来：F12 找到接口，复制成 cURL，粘到终端跑：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>"https://spider.jsnote.top/api/challenges/CH-004/cpc?page=1"</span><span> </span><span>-H</span><span> </span><span>"Cookie: session=..."</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>结果不是 403、不是 200，而是<strong>一直挂着</strong>，然后：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>curl: (28) Operation timed out</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这才是这一关真正的坑：<strong>服务端把连接挂住了</strong>（黑洞）。它不告诉你为什么，你只能自己判断。</p><p>顺手验证一下：浏览器打开靶场页 <code>/cpc</code> 完全正常。也就是说「浏览器能拿到，脚本拿不到」—— 那问题一定出在<strong>浏览器和脚本的差异</strong>上，而不是参数、Cookie、路径。</p></section>
<section><h2>差异在哪：TLS 握手里的密码套件顺序<a href="#差异在哪tls-握手里的密码套件顺序"><span>#</span></a></h2><p>HTTP 头可以伪造（UA、sec-ch-ua 随便抄），但 <strong>TLS 握手里的 ClientHello 抄不了</strong>：</p>

<table><thead><tr><th>客户端</th><th>首个密码套件</th></tr></thead><tbody><tr><td>Python <code>requests</code> / <code>httpx</code> / <code>urllib3</code>（OpenSSL 默认）</td><td><code>TLS_AES_256_GCM_SHA384</code></td></tr><tr><td>Chrome / Edge</td><td><code>TLS_AES_128_GCM_SHA256</code></td></tr><tr><td><code>curl</code></td><td>视版本而定，多数也不是 Chrome 的顺序</td></tr></tbody></table><p>服务端在 nginx 上把 <code>$ssl_protocol</code> / <code>$ssl_ciphers</code> / <code>$ssl_curves</code> 透传给了后端，后端口只看<strong>第一个套件</strong>：</p><ul>
<li>含 <code>AES256</code> → 判「不是浏览器」，把连接挂住；</li>
<li><code>AES128</code> / <code>CHACha20</code> → 放行。</li>
</ul><p>所以这不是「请求头问题」，也不是「代理问题」——<strong>HTTP 层根本没有机会参与</strong>。</p></section>
<section><h2>两个容易踩的细节<a href="#两个容易踩的细节"><span>#</span></a></h2><ol>
<li><strong>端口也有讲究</strong>：走 443（HTTPS）才有 TLS 指纹可谈。明文兜底端口 <code>:5787</code> 会直接返回「请通过 HTTPS（443 端口）访问本站」，因为那台机器上没有握手信息。</li>
<li><strong>本地开发是例外</strong>：本机用 Vite 代理调试时，代理会预置三个 <code>X-TLS-*</code> 头模拟生产环境，所以本地能跑通；你用脚本直连本地后端反而会被判「不是 HTTPS」。本地想调试就把黑洞时长设成 0（<code>LEARNSPIDER_TLS_TARPIT_SECONDS=0</code>），它会退回 403，而不是挂你 100 秒。</li>
</ol></section>
<section><h2>解法：让握手长得像 Chrome<a href="#解法让握手长得像-chrome"><span>#</span></a></h2><p>最省事的是 <code>curl_cffi</code>：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pip</span><span> </span><span>install</span><span> </span><span>curl_cffi</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它的 <code>impersonate="chrome"</code> 会把 ClientHello（套件顺序、扩展、曲线）整套伪装成 Chrome，服务端看第一个套件就是 <code>TLS_AES_128_GCM_SHA256</code>，直接放行。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-004：答案 = 5 页 50 条关键词的 CPC 之和（保留 2 位小数）</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>为什么 requests 不行：</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>requests 走 OpenSSL 默认套件顺序，ClientHello 首个套件是 TLS_AES_256_GCM_SHA384</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>（Chrome 是 TLS_AES_128_GCM_SHA256）。服务端按首个套件判定客户端类型，</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>不合规就把连接挂住（什么都不返回），所以你只能等到自己的 timeout。</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>curl_cffi 的 impersonate="chrome" 连 ClientHello 一起伪装，才能拿到数据。</span></div></div><div><div><div>8</div></div><div><span>"""</span></div></div><div><div><div>9</div></div><div><span>from</span><span> curl_cffi </span><span>import</span><span> requests</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>12</div></div><div><span><span>API_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-004/cpc"</span></div></div><div><div><div>13</div></div><div><span><span>TOTAL_PAGES</span><span> </span><span>=</span><span> </span></span><span>5</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span><span>headers </span><span>=</span><span> {</span></span></div></div><div><div><div>16</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"application/json, text/plain, */*"</span><span>,</span></div></div><div><div><div>17</div></div><div><span>    </span><span>"Accept-Language"</span><span>: </span><span>"zh-CN,zh;q=0.9"</span><span>,</span></div></div><div><div><div>18</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/cpc?challenge=CH-004"</span><span>,</span></div></div><div><div><div>19</div></div><div><span>    </span><span>"User-Agent"</span><span>: (</span></div></div><div><div><div>20</div></div><div><span>        </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "</span></div></div><div><div><div>21</div></div><div><span>        </span><span>"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>(</span></span><span>impersonate</span><span>=</span><span>"chrome"</span><span>)     </span><span># 关键就这一行</span></div></div><div><div><div>26</div></div><div><span><span>session.headers.</span><span>update</span><span>(headers)</span></span></div></div><div><div><div>27</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>28</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>total </span><span>=</span><span> </span><span>0.0</span></div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>rows </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>34</div></div><div><span>    </span><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, </span><span>TOTAL_PAGES</span><span> </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>35</div></div><div><span><span>        </span></span><span>response </span><span>=</span><span> session.</span><span>get</span><span>(</span></div></div><div><div><div>36</div></div><div><span><span>            </span></span><span>API_URL</span><span>, </span><span>params</span><span><span>=</span><span>{</span></span><span>"page"</span><span>: page}, </span><span>timeout</span><span>=</span><span>20</span><span>, </span><span>impersonate</span><span>=</span><span>"chrome"</span></div></div><div><div><div>37</div></div><div><span><span>        </span></span><span>)</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>39</div></div><div><span><span>        </span></span><span>payload </span><span>=</span><span> response.</span><span>json</span><span>()</span></div></div><div><div><div>40</div></div><div><span><span>        </span></span><span>items </span><span>=</span><span> payload.</span><span>get</span><span>(</span><span>"items"</span><span>, [])</span></div></div><div><div><div>41</div></div><div><span><span>        </span></span><span>rows </span><span>+=</span><span> </span><span>len</span><span>(items)</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span><span>        </span></span><span>page_sum </span><span>=</span><span> </span><span>sum</span><span>(</span><span>float</span><span>(item[</span><span>"cpc"</span><span>]) </span><span>for</span><span> item </span><span>in</span><span> items)</span></div></div><div><div><div>44</div></div><div><span><span>        </span></span><span>total </span><span>+=</span><span> page_sum</span></div></div><div><div><div>45</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page</span><span>}</span><span> 页：</span><span>{</span><span>len</span><span>(items)</span><span>}</span><span> 条，本页 CPC 合计 </span><span>{</span><span>page_sum</span><span>:.2f</span><span>}</span><span>，"</span></div></div><div><div><div>46</div></div><div><span>              </span><span>f</span><span>"累计 </span><span>{</span><span>total</span><span>:.2f</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>47</div></div><div>
</div></div><div><div><div>48</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>共 </span><span>{</span><span>rows</span><span>}</span><span> 条，CPC 总和：</span><span>{</span><span>round</span><span>(total, </span><span>2</span><span>)</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>49</div></div><div>
</div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>52</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s4.py</code>。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>请求「不报错也不返回」→ 先怀疑服务端黑洞，而不是自己网慢（把 timeout 设成 5 秒更快确认）。</li>
<li>浏览器能开、脚本不行 → 找两者差异：Cookie、请求头、<strong>TLS 指纹</strong>、HTTP 版本。</li>
<li>抄全套 HTTP 头没用 → 说明检查点在 TLS 层。</li>
<li>确认自己走的是 443 的 HTTPS，不是明文兜底端口。</li>
<li>用 <code>curl_cffi</code> 的 <code>impersonate="chrome"</code>（不是只改 UA）。</li>
<li>指纹合规了还是 401/403 → 这时候才轮到 Cookie 与参数。</li>
<li>接口一次只给一页，记得翻完 5 页（网页上只放出前 4 页）。</li>
<li>求和后保留 2 位小数（判题容差 0.05）。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>我改了 User-Agent 和 sec-ch-ua，为什么还是卡住？<a href="#我改了-user-agent-和-sec-ch-ua为什么还是卡住"><span>#</span></a></h3><p>因为那些是 HTTP 头。这一关看的是 TLS 握手里的东西，HTTP 层改什么都到不了判定逻辑。</p></section><section><h3>为什么 curl 也不行？<a href="#为什么-curl-也不行"><span>#</span></a></h3><p>不同版本的 curl 用的是本地 OpenSSL 的套件顺序，多数情况下和 Chrome 不一致。要么升级/换 curl，要么直接用 <code>curl_cffi</code>。</p></section><section><h3><code>impersonate="chrome"</code> 会不会影响别的请求？<a href="#impersonatechrome-会不会影响别的请求"><span>#</span></a></h3><p>只影响你显式声明的这个 Session。日常抓别的接口不必开，避免不必要的麻烦。</p></section><section><h3>为什么判题的容差是 0.05？<a href="#为什么判题的容差是-005"><span>#</span></a></h3><p>CPC 是两位小数浮点数，累加会有误差。留一点容差比要求「完全相等」更合理。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>TLS 指纹是真实存在的反爬手段（CDN、风控产品都在用）。它的意义是把「没有浏览器指纹的客户端」和「正常用户」分开 —— 逆向它属于学习范畴，滥用会直接影响服务方。</p><p>在自己的靶场里练，别拿去扫别人的站点。去挑战广场提交，然后进 CH-005：数据能拿到了，但每一条都要签名。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch5/</id>
      <title type="text">Python 爬虫接口签名实战：参数签名 + 会话绑定（CH-005）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch5/"/>
      <summary type="text">本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名，而且取数只认一个「会话标识」（uuid）—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名，而且取数只认一个「会话标识」（uuid）—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。</p></blockquote>
<section><h2>现象：接口返回 <code>forbidden</code>，什么都不说<a href="#现象接口返回-forbidden什么都不说"><span>#</span></a></h2><p>打开靶场页 <code>/numbers?challenge=CH-005</code>，页面正常渲染出前 4 页数字。F12 → Network，取数请求长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/challenges/CH-005/data?page=1&amp;uuid=9f1c...&amp;hash=8b1d...&amp;t=1790936209983&amp;s=XJ2026</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>四个看起来像随机的参数，外加一个固定值 <code>s</code>。手动去掉 <code>hash</code> 再发一次，得到的是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"error"</span><span>: </span><span>"forbidden"</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>没有「缺少参数」也没有「签名错误」——服务端故意什么都不说。这种时候就要回到页面自己的请求里找线索。</p></section>
<section><h2>先看页面做了什么<a href="#先看页面做了什么"><span>#</span></a></h2><p>刷新靶场页时还有一个 <strong>POST</strong> 请求，它比取数接口更值得看：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>POST /api/challenges/CH-005/session</span></div></div><div><div><div>2</div></div><div><span>{"uuid": "9f1c...(标准 UUID)"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应里会把页数信息给它。也就是说：</p><ol>
<li>页面在浏览器里用 <code>crypto.randomUUID()</code> 生成一个 uuid；</li>
<li>调 <code>/session</code> 把这个 uuid <strong>绑定到当前登录账号</strong>（这一步需要登录 Cookie）；</li>
<li>之后取数接口<strong>只认这个 uuid</strong>（不再要求登录 Cookie），并把「这一页的和」记在 uuid 名下。</li>
</ol><p>这也解释了题面为什么要求「把 5 页都取一遍」：某页没取过，提交时会被点名。</p></section>
<section><h2>签名怎么算<a href="#签名怎么算"><span>#</span></a></h2><p>签名算法同样在前端 bundle 里（<code>NumbersChallengeView.vue</code>），用 <code>crypto.subtle.digest("SHA-256", ...)</code> 算：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>raw  = f"/numbers|{page}|{timestamp}|{salt}"     # 注意前导斜杠</span></div></div><div><div><div>2</div></div><div><span>hash = sha256(raw).hexdigest()                   # 十六进制，不是 Base64</span></div></div><div><div><div>3</div></div><div><span>t    = 毫秒时间戳</span></div></div><div><div><div>4</div></div><div><span>s    = "XJ2026"                                  # 就是盐本身，明文带在参数里</span></div></div><div><div><div>5</div></div><div><span>uuid = 页面生成、已经绑定过的那个</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>服务端比对时忽略大小写（<code>hmac.compare_digest(digest.lower(), ...)</code>）。</p><p>⚠️ 三个最容易翻车的地方：</p>

<table><thead><tr><th>坑</th><th>现象</th></tr></thead><tbody><tr><td><code>raw</code> 里少了前导斜杠（写成 <code>numbers|...</code>）</td><td>永远 403</td></tr><tr><td>把摘要写成 Base64</td><td>永远 403</td></tr><tr><td>用别人（或另一个浏览器）的 uuid</td><td>403；提交时则是 409「不属于当前账号」</td></tr></tbody></table></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-005：答案 = 5 页共 50 个数字的总和（提交时带 uuid）</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>两个关键点：</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>1. 签名 raw 是 f"/numbers|{page}|{timestamp}|{salt}"，**带前导斜杠**，结果是 sha256 的 hex</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>2. 取数只认 uuid —— 这个 uuid 要先由页面（或你）调 /session 绑定到账号</span></div></div><div><div><div>6</div></div><div><span><span>     </span></span><span>（绑定那次请求需要登录 Cookie，之后取数就不需要了）</span></div></div><div><div><div>7</div></div><div><span>"""</span></div></div><div><div><div>8</div></div><div><span>import</span><span> hashlib</span></div></div><div><div><div>9</div></div><div><span>import</span><span> time</span></div></div><div><div><div>10</div></div><div><span>import</span><span> uuid </span><span>as</span><span> uuid_lib</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>15</div></div><div><span><span>BIND_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-005/session"</span></div></div><div><div><div>16</div></div><div><span><span>DATA_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-005/data"</span></div></div><div><div><div>17</div></div><div><span><span>ROUTE</span><span> </span><span>=</span><span> </span></span><span>"/numbers"</span><span>                  </span><span># 签名里的路径，带前导斜杠</span></div></div><div><div><div>18</div></div><div><span><span>SALT</span><span> </span><span>=</span><span> </span></span><span>"XJ2026"</span></div></div><div><div><div>19</div></div><div><span><span>TOTAL_PAGES</span><span> </span><span>=</span><span> </span></span><span>5</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></span></div></div><div><div><div>22</div></div><div><span><span>session.headers.</span><span>update</span><span>({</span></span></div></div><div><div><div>23</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"application/json, text/plain, */*"</span><span>,</span></div></div><div><div><div>24</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/numbers?challenge=CH-005"</span><span>,</span></div></div><div><div><div>25</div></div><div><span>    </span><span>"User-Agent"</span><span>: (</span></div></div><div><div><div>26</div></div><div><span>        </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "</span></div></div><div><div><div>27</div></div><div><span>        </span><span>"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>29</div></div><div><span>})</span></div></div><div><div><div>30</div></div><div><span># 只有「绑定 uuid」这一步需要登录态</span></div></div><div><div><div>31</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>32</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span>def</span><span> </span><span>sign</span><span>(</span><span>page</span><span>:</span><span> </span><span>int</span><span>,</span><span><span> </span><span>timestamp</span></span><span>:</span><span> </span><span>str</span><span>,</span><span><span> </span><span>salt</span></span><span>:</span><span> </span><span>str</span><span> </span><span>=</span><span> </span><span><span>SALT</span><span>) -&gt; </span></span><span>str</span><span>:</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>raw </span><span>=</span><span> </span><span>f</span><span>"</span><span>{</span><span>ROUTE</span><span>}</span><span>|</span><span>{</span><span>page</span><span>}</span><span>|</span><span>{</span><span>timestamp</span><span>}</span><span>|</span><span>{</span><span>salt</span><span>}</span><span>"</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span>    </span><span># 如果你怎么算都是 403，先打出来对一下服务端那行的哈希：</span></div></div><div><div><div>39</div></div><div><span>    </span><span>#   sha256("/numbers|1|1790936209983|XJ2026")</span></div></div><div><div><div>40</div></div><div><span>    </span><span>#        = 3a03b48eda815c1a0fac05d817831fa9d2787e836b47d0cfdbf7431e5f8a2a21</span></div></div><div><div><div>41</div></div><div><span>    </span><span>return</span><span><span> hashlib.</span><span>sha256</span><span>(raw.</span><span>encode</span><span>()).</span><span>hexdigest</span><span>()</span></span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div>
</div></div><div><div><div>44</div></div><div><span>def</span><span> </span><span>bind_account</span><span>():</span></div></div><div><div><div>45</div></div><div><span>    </span><span>"""生成 uuid 并绑定到当前账号；页面就是这么干的。"""</span></div></div><div><div><div>46</div></div><div><span><span>    </span></span><span>uuid_value </span><span>=</span><span> </span><span>str</span><span><span>(uuid_lib.</span><span>uuid4</span><span>())</span></span></div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>response </span><span>=</span><span> session.</span><span>post</span><span>(</span><span>BIND_URL</span><span>, </span><span>json</span><span><span>=</span><span>{</span></span><span>"uuid"</span><span>: uuid_value}, </span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>48</div></div><div><span>    </span><span>if</span><span><span> response.status_code </span><span>==</span><span> </span></span><span>401</span><span>:</span></div></div><div><div><div>49</div></div><div><span>        </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>"绑定失败：这一步需要登录 Cookie"</span><span>)</span></div></div><div><div><div>50</div></div><div><span><span>    </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>51</div></div><div><span>    </span><span>print</span><span>(</span><span>"已绑定 uuid："</span><span><span>, uuid_value, response.</span><span>json</span><span>())</span></span></div></div><div><div><div>52</div></div><div><span>    </span><span>return</span><span> uuid_value</span></div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>56</div></div><div><span><span>    </span></span><span>uuid_value </span><span>=</span><span> </span><span>bind_account</span><span>()</span></div></div><div><div><div>57</div></div><div>
</div></div><div><div><div>58</div></div><div><span><span>    </span></span><span>total </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>59</div></div><div><span>    </span><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, </span><span>TOTAL_PAGES</span><span> </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>60</div></div><div><span><span>        </span></span><span>timestamp </span><span>=</span><span> </span><span>str</span><span>(</span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>))</span></div></div><div><div><div>61</div></div><div><span><span>        </span></span><span>response </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>DATA_URL</span><span>, </span><span>params</span><span><span>=</span><span>{</span></span></div></div><div><div><div>62</div></div><div><span>            </span><span>"page"</span><span>: page,</span></div></div><div><div><div>63</div></div><div><span>            </span><span>"uuid"</span><span>: uuid_value,</span></div></div><div><div><div>64</div></div><div><span>            </span><span>"hash"</span><span><span>: </span><span>sign</span><span>(page, timestamp),</span></span></div></div><div><div><div>65</div></div><div><span>            </span><span>"t"</span><span>: timestamp,</span></div></div><div><div><div>66</div></div><div><span>            </span><span>"s"</span><span><span>: </span><span>SALT</span><span>,</span></span></div></div><div><div><div>67</div></div><div><span><span>        </span></span><span>}, </span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>68</div></div><div>
</div></div><div><div><div>69</div></div><div><span>        </span><span>if</span><span><span> response.status_code </span><span>==</span><span> </span></span><span>403</span><span>:</span></div></div><div><div><div>70</div></div><div><span>            </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span></div></div><div><div><div>71</div></div><div><span>                </span><span>"403：签名不对。自查 ——</span><span>\n</span><span>"</span></div></div><div><div><div>72</div></div><div><span>                </span><span>'  · raw 是 "/numbers|&lt;page&gt;|&lt;timestamp&gt;|&lt;salt&gt;"（带前导斜杠）</span><span>\n</span><span>'</span></div></div><div><div><div>73</div></div><div><span>                </span><span>"  · 摘要是 sha256 的 hex（不是 Base64）</span><span>\n</span><span>"</span></div></div><div><div><div>74</div></div><div><span>                </span><span>"  · uuid 得是绑定过的那个"</span></div></div><div><div><div>75</div></div><div><span><span>            </span></span><span>)</span></div></div><div><div><div>76</div></div><div><span><span>        </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>77</div></div><div>
</div></div><div><div><div>78</div></div><div><span><span>        </span></span><span>numbers </span><span>=</span><span> response.</span><span>json</span><span>()[</span><span>"numbers"</span><span>]</span></div></div><div><div><div>79</div></div><div><span><span>        </span></span><span>total </span><span>+=</span><span> </span><span>sum</span><span>(numbers)</span></div></div><div><div><div>80</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page</span><span>}</span><span> 页：</span><span>{</span><span>numbers</span><span>}</span><span> → 累计 </span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>81</div></div><div><span><span>        </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)</span></div></div><div><div><div>82</div></div><div>
</div></div><div><div><div>83</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>答案：</span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>84</div></div><div><span>    </span><span>print</span><span>(</span><span>"提交时记得带上同一个 uuid："</span></div></div><div><div><div>85</div></div><div><span>          </span><span>f</span><span>'POST /api/challenges/CH-005/submit </span><span>{{</span><span>"answer": </span><span>{</span><span>total</span><span>}</span><span>, "uuid": "</span><span>{</span><span>uuid_value</span><span>}</span><span>"</span><span>}}</span><span>'</span><span>)</span></div></div><div><div><div>86</div></div><div>
</div></div><div><div><div>87</div></div><div>
</div></div><div><div><div>88</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>89</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s5.py</code>（里面还留了「前导斜杠」那个坑的注释）。</p></section>
<section><h2>为什么「只带 uuid、不带 Cookie」也能取数<a href="#为什么只带-uuid不带-cookie也能取数"><span>#</span></a></h2><p>这是刻意设计的：绑定一次之后，uuid 就是这次采集会话的凭证。好处是采集脚本可以把 uuid 抄走、跨进程复用；坏处也一样 —— 因此有两条保护：</p><ul>
<li>uuid 必须存在（没绑定过 → 403）；</li>
<li><strong>提交答案时</strong>会核对 uuid 是否属于当前登录账号（不是自己的 → 409）。</li>
</ul><p>所以别指望用别人的 uuid 交卷。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>先看页面自己发的请求：除了取数接口，往往还有一个「初始化 / 绑定」接口。</li>
<li>403 且无解释 → 去前端 bundle 里搜签名关键词（<code>sha256</code>、<code>subtle</code>、<code>hash</code>）。</li>
<li>对一下 raw 字符串的<strong>每一段</strong>和分隔符（前导斜杠、竖线、字段顺序）。</li>
<li>确认摘要是 hex 还是 Base64 —— 这两者最容易混。</li>
<li>先取第 1 页打通，再写循环。</li>
<li>5 页都取过再提交（漏页会被点名）。</li>
<li>提交时带 uuid。</li>
<li>取数失败先分辨是 403（签名/uuid 问题）还是 503（后端 Redis 不可用）。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3><code>t</code> 有时间校验吗？<a href="#t-有时间校验吗"><span>#</span></a></h3><p>这一关的 <code>t</code> 只参与签名，服务端不校验时效（参考实现也是这么写的）。真实站点常常会校验时间窗口，所以养成「现算现用」的习惯没坏处。</p></section><section><h3>为什么要设计成「uuid 绑定」？<a href="#为什么要设计成uuid-绑定"><span>#</span></a></h3><p>真实业务里，前端 SDK 会生成一个 <code>sessionId</code> / <code>traceId</code> 串起整段会话，后端用它做风控统计。这里是把它做成了「取数凭证」，让学员体会「会话标识也能成为一道门」。</p></section><section><h3>少取一页会怎么样？<a href="#少取一页会怎么样"><span>#</span></a></h3><p>提交时会返回 409，并明确告诉你缺第几页 —— 这个提示是故意给的，因为「漏页」不算攻击面。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>签名参数、会话凭证这类设计在真实站点上很常见，逆向它们时要清楚边界：只在自己的靶场或获得授权的目标上练习，别拿别人的接口做压力测试。</p><p>去挑战广场提交，然后进 CH-006：这次换一套古典密码，签名与响应都用「你认识的算法」重新包了一遍。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch6/</id>
      <title type="text">Python 爬虫加密实战：ROT + Base32 + 凯撒 + HMAC-MD5 组合（CH-006）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch6/"/>
      <summary type="text">本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起：请求参数要被 ROT-N 两次，响应要按 ROT47 → Base32 → 凯撒反着剥三层，签名则是 HMAC-MD5 再做两次 ROT-…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起：请求参数要被 ROT-N 两次，响应要按 ROT47 → Base32 → 凯撒反着剥三层，签名则是 HMAC-MD5 再做两次 ROT-N。答案是 5 页共 50 个数字的总和。</p></blockquote>
<section><h2>现象：接口参数和响应都「不像人话」<a href="#现象接口参数和响应都不像人话"><span>#</span></a></h2><p>靶场页 <code>/cipher?challenge=CH-006</code>。F12 里能看到两个请求：</p><p><strong>1) 取参数（GET）</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/challenges/CH-006/init</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"rot13Offset"</span><span>: </span><span>7</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"rot47Offset"</span><span>: </span><span>31</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"caesarShift"</span><span>: </span><span>11</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"salt"</span><span>: </span><span>"9f2c1d0b7a4e5c88"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"total_pages"</span><span>: </span><span>5</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>"visible_pages"</span><span>: </span><span>4</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>"page_size"</span><span>: </span><span>10</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>四个参数就是这一关的四把钥匙（按账号派生，同一个账号每次一样）。</p><p><strong>2) 取数据（POST）</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>POST /api/challenges/CH-006/data</span></div></div><div><div><div>2</div></div><div><span>X-ROT-Sign: XywUaIsNnAvLxYdcIhSnyqcJBsDpvSJr</span></div></div><div><div><div>3</div></div><div><span>{"rotParams": "{\"Qwde\": 1, \"Wqjprx...\": ...}"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"encrypted"</span><span>: </span><span>true</span><span>, </span><span>"rotData"</span><span>: </span><span>"p&amp;XmzB1#kQ...（一串 ASCII 可见字符）"</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>签名头是字母混排（看着像凯撒），请求体和响应体都是可见字符但读不懂 —— 这就是典型的「古典密码套娃」。</p></section>
<section><h2>四把钥匙分别管什么<a href="#四把钥匙分别管什么"><span>#</span></a></h2>

<table><thead><tr><th>参数</th><th>用在哪</th></tr></thead><tbody><tr><td><code>rot13Offset</code>（1-25）</td><td>① 请求参数 JSON 做<strong>两次</strong> ROT-N；② 签名摘要（hex）也做<strong>两次</strong> ROT-N</td></tr><tr><td><code>rot47Offset</code>（1-93）</td><td>响应体最外层：ASCII 33~126 的循环平移（ROT47 的推广）</td></tr><tr><td><code>caesarShift</code>（1-25）</td><td>响应体最内层：对 Base32 解出来的 JSON 文本做凯撒</td></tr><tr><td><code>salt</code>（16 位 hex）</td><td>HMAC-MD5 的<strong>密钥</strong>，同时也出现在签名 raw 里</td></tr></tbody></table></section>
<section><h2>两个必须记住的坑<a href="#两个必须记住的坑"><span>#</span></a></h2><p><strong>坑 1：ROT-N 做两次 ≠ 原样。</strong> 只有 N=13 时两次才会互相抵消（罗13表）。这里是 N=7、N=11 之类的值，所以：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>rot_letters</span><span>(</span><span>rot_letters</span><span>(</span></span><span>"hello"</span><span>, </span><span>7</span><span>), </span><span>7</span><span>)   </span><span># "spaar" —— 不是 hello</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>签名只能用 <strong>+N 做两遍</strong>（服务端就是这么算的），不能偷懒直接用 HMAC-MD5 的结果。</p><p><strong>坑 2：Base32 解码要补齐填充。</strong> 去掉噪声字符后长度可能不是 8 的倍数，要自己补 <code>=</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>cleaned </span><span>=</span><span> </span></span><span>""</span><span><span>.</span><span>join</span><span>(c </span></span><span>for</span><span> c </span><span>in</span><span><span> text.</span><span>upper</span><span>() </span></span><span>if</span><span> c </span><span>in</span><span> </span><span>"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"</span><span>)</span></div></div><div><div><div>2</div></div><div><span><span>base64.</span><span>b32decode</span><span>(cleaned </span><span>+</span><span> </span></span><span>"="</span><span><span> </span><span>*</span><span> ((</span><span>-</span></span><span>len</span><span><span>(cleaned)) </span><span>%</span><span> </span></span><span>8</span><span>))</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>响应是怎么加密的（正序）<a href="#响应是怎么加密的正序"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>明文 JSON</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>→ 凯撒(caesarShift)          只动字母</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>→ Base32 编码                变成 A-Z2-7</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>→ ROT-N(rot47Offset)         在 ASCII 33~126 上平移 → 可见字符</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>解密就是反过来：<strong>ROT47 反向 → Base32 解码 → 凯撒反向 → json.loads</strong>。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-006：答案 = 5 页共 50 个数字的总和"""</span></div></div><div><div><div>2</div></div><div><span>import</span><span> base64</span></div></div><div><div><div>3</div></div><div><span>import</span><span> hashlib</span></div></div><div><div><div>4</div></div><div><span>import</span><span> hmac</span></div></div><div><div><div>5</div></div><div><span>import</span><span> json</span></div></div><div><div><div>6</div></div><div><span>import</span><span> time</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>11</div></div><div><span><span>ROUTE</span><span> </span><span>=</span><span> </span></span><span>"/cipher"</span><span>                    </span><span># 签名里带前导斜杠</span></div></div><div><div><div>12</div></div><div><span><span>INIT_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-006/init"</span></div></div><div><div><div>13</div></div><div><span><span>DATA_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-006/data"</span></div></div><div><div><div>14</div></div><div><span><span>TOTAL_PAGES</span><span> </span><span>=</span><span> </span></span><span>5</span></div></div><div><div><div>15</div></div><div><span><span>ALPHABET</span><span> </span><span>=</span><span> </span></span><span>"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span># ---------- 三个基础变换 ----------</span></div></div><div><div><div>19</div></div><div><span>def</span><span> </span><span>rot_letters</span><span>(</span><span>text</span><span>,</span><span><span> </span><span>offset</span></span><span>):</span></div></div><div><div><div>20</div></div><div><span>    </span><span>"""凯撒 / ROT-N：只动字母，大小写各自成环（offset 允许为负 = 反向）。"""</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>step </span><span>=</span><span> offset </span><span>%</span><span> </span><span>26</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>out </span><span>=</span><span> []</span></div></div><div><div><div>23</div></div><div><span>    </span><span>for</span><span> char </span><span>in</span><span> </span><span>str</span><span>(text):</span></div></div><div><div><div>24</div></div><div><span>        </span><span>if</span><span> </span><span>"a"</span><span><span> </span><span>&lt;=</span><span> char </span><span>&lt;=</span><span> </span></span><span>"z"</span><span>:</span></div></div><div><div><div>25</div></div><div><span><span>            </span></span><span>out.</span><span>append</span><span>(</span><span>chr</span><span>((</span><span>ord</span><span><span>(char) </span><span>-</span><span> </span></span><span>97</span><span><span> </span><span>+</span><span> step) </span><span>%</span><span> </span></span><span>26</span><span><span> </span><span>+</span><span> </span></span><span>97</span><span>))</span></div></div><div><div><div>26</div></div><div><span>        </span><span>elif</span><span> </span><span>"A"</span><span><span> </span><span>&lt;=</span><span> char </span><span>&lt;=</span><span> </span></span><span>"Z"</span><span>:</span></div></div><div><div><div>27</div></div><div><span><span>            </span></span><span>out.</span><span>append</span><span>(</span><span>chr</span><span>((</span><span>ord</span><span><span>(char) </span><span>-</span><span> </span></span><span>65</span><span><span> </span><span>+</span><span> step) </span><span>%</span><span> </span></span><span>26</span><span><span> </span><span>+</span><span> </span></span><span>65</span><span>))</span></div></div><div><div><div>28</div></div><div><span>        </span><span>else</span><span>:</span></div></div><div><div><div>29</div></div><div><span><span>            </span></span><span>out.</span><span>append</span><span>(char)</span></div></div><div><div><div>30</div></div><div><span>    </span><span>return</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(out)</span></span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>def</span><span> </span><span>rot_printable</span><span>(</span><span>text</span><span>,</span><span><span> </span><span>offset</span></span><span>):</span></div></div><div><div><div>34</div></div><div><span>    </span><span>"""ROT-N over ASCII 33..126（ROT47 的推广）。"""</span></div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>step </span><span>=</span><span> offset </span><span>%</span><span> </span><span>94</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>out </span><span>=</span><span> []</span></div></div><div><div><div>37</div></div><div><span>    </span><span>for</span><span> char </span><span>in</span><span> </span><span>str</span><span>(text):</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>code </span><span>=</span><span> </span><span>ord</span><span>(char)</span></div></div><div><div><div>39</div></div><div><span><span>        </span></span><span>out.</span><span>append</span><span>(</span><span>chr</span><span><span>((code </span><span>-</span><span> </span></span><span>33</span><span><span> </span><span>+</span><span> step) </span><span>%</span><span> </span></span><span>94</span><span><span> </span><span>+</span><span> </span></span><span>33</span><span>) </span><span>if</span><span> </span><span>33</span><span><span> </span><span>&lt;=</span><span> code </span><span>&lt;=</span><span> </span></span><span>126</span><span> </span><span>else</span><span> char)</span></div></div><div><div><div>40</div></div><div><span>    </span><span>return</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(out)</span></span></div></div><div><div><div>41</div></div><div>
</div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>def</span><span> </span><span>base32_decode</span><span>(</span><span>text</span><span>):</span></div></div><div><div><div>44</div></div><div><span><span>    </span></span><span>cleaned </span><span>=</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(char </span></span><span>for</span><span> char </span><span>in</span><span> </span><span>str</span><span><span>(text).</span><span>upper</span><span>() </span></span><span>if</span><span> char </span><span>in</span><span><span> </span><span>ALPHABET</span><span>)</span></span></div></div><div><div><div>45</div></div><div><span>    </span><span>return</span><span><span> base64.</span><span>b32decode</span><span>(cleaned </span><span>+</span><span> </span></span><span>"="</span><span><span> </span><span>*</span><span> ((</span><span>-</span></span><span>len</span><span><span>(cleaned)) </span><span>%</span><span> </span></span><span>8</span><span><span>)).</span><span>decode</span><span>()</span></span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div>
</div></div><div><div><div>48</div></div><div><span># ---------- 签名 / 参数 / 解密 ----------</span></div></div><div><div><div>49</div></div><div><span>def</span><span> </span><span>sign</span><span>(</span><span>config</span><span>,</span><span><span> </span><span>page</span></span><span>,</span><span><span> </span><span>timestamp</span></span><span>):</span></div></div><div><div><div>50</div></div><div><span>    </span><span>"""HMAC-MD5(raw, salt) 的 hex，再做两次 ROT-N（N = rot13Offset）。"""</span></div></div><div><div><div>51</div></div><div><span><span>    </span></span><span>raw </span><span>=</span><span> </span><span>f</span><span>"</span><span>{</span><span>ROUTE</span><span>}</span><span>|</span><span>{</span><span>page</span><span>}</span><span>|</span><span>{</span><span>timestamp</span><span>}</span><span>|</span><span>{</span><span>config[</span><span>'salt'</span><span>]</span><span>}</span><span>"</span></div></div><div><div><div>52</div></div><div><span><span>    </span></span><span>digest </span><span>=</span><span> hmac.</span><span>new</span><span>(config[</span><span>"salt"</span><span><span>].</span><span>encode</span><span>(), raw.</span><span>encode</span><span>(), hashlib.md5).</span><span>hexdigest</span><span>()</span></span></div></div><div><div><div>53</div></div><div><span><span>    </span></span><span>offset </span><span>=</span><span> config[</span><span>"rot13Offset"</span><span>]</span></div></div><div><div><div>54</div></div><div><span>    </span><span>return</span><span><span> </span><span>rot_letters</span><span>(</span><span>rot_letters</span><span>(digest, offset), offset)</span></span></div></div><div><div><div>55</div></div><div>
</div></div><div><div><div>56</div></div><div>
</div></div><div><div><div>57</div></div><div><span>def</span><span> </span><span>encode_params</span><span>(</span><span>config</span><span>,</span><span><span> </span><span>page</span></span><span>,</span><span><span> </span><span>timestamp</span></span><span>):</span></div></div><div><div><div>58</div></div><div><span>    </span><span>"""参数是「两次 ROT-N 之后的 JSON 字符串」，直接当字段值发出去。"""</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>raw </span><span>=</span><span> json.</span><span>dumps</span><span>({</span><span>"page"</span><span>: page, </span><span>"timestamp"</span><span>: timestamp}, </span><span>separators</span><span><span>=</span><span>(</span></span><span>","</span><span>, </span><span>":"</span><span>))</span></div></div><div><div><div>60</div></div><div><span><span>    </span></span><span>offset </span><span>=</span><span> config[</span><span>"rot13Offset"</span><span>]</span></div></div><div><div><div>61</div></div><div><span>    </span><span>return</span><span><span> </span><span>rot_letters</span><span>(</span><span>rot_letters</span><span>(raw, offset), offset)</span></span></div></div><div><div><div>62</div></div><div>
</div></div><div><div><div>63</div></div><div>
</div></div><div><div><div>64</div></div><div><span>def</span><span> </span><span>decrypt</span><span>(</span><span>config</span><span>,</span><span><span> </span><span>rot_data</span></span><span>):</span></div></div><div><div><div>65</div></div><div><span>    </span><span>"""ROT47 还原 → Base32 解码 → 凯撒还原 → JSON。"""</span></div></div><div><div><div>66</div></div><div><span><span>    </span></span><span>unrotated </span><span>=</span><span> </span><span>rot_printable</span><span>(rot_data, </span><span>-</span><span>config[</span><span>"rot47Offset"</span><span>])</span></div></div><div><div><div>67</div></div><div><span><span>    </span></span><span>unbased </span><span>=</span><span> </span><span>base32_decode</span><span>(unrotated)</span></div></div><div><div><div>68</div></div><div><span>    </span><span>return</span><span><span> json.</span><span>loads</span><span>(</span><span>rot_letters</span><span>(unbased, </span><span>-</span><span>config[</span></span><span>"caesarShift"</span><span>]))</span></div></div><div><div><div>69</div></div><div>
</div></div><div><div><div>70</div></div><div>
</div></div><div><div><div>71</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>72</div></div><div><span><span>    </span></span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></div></div><div><div><div>73</div></div><div><span><span>    </span></span><span>session.headers.</span><span>update</span><span>({</span></div></div><div><div><div>74</div></div><div><span>        </span><span>"Content-Type"</span><span>: </span><span>"application/json"</span><span>,</span></div></div><div><div><div>75</div></div><div><span>        </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/cipher?challenge=CH-006"</span><span>,</span></div></div><div><div><div>76</div></div><div><span>        </span><span>"User-Agent"</span><span>: </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36"</span><span>,</span></div></div><div><div><div>77</div></div><div><span><span>    </span></span><span>})</span></div></div><div><div><div>78</div></div><div><span><span>    </span></span><span>session.cookies.</span><span>set</span><span>(</span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>79</div></div><div><span>                        </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>80</div></div><div>
</div></div><div><div><div>81</div></div><div><span><span>    </span></span><span>config </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>INIT_URL</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span><span>).</span><span>json</span><span>()</span></span></div></div><div><div><div>82</div></div><div><span>    </span><span>print</span><span>(</span><span>"拿到钥匙："</span><span>, {k: config[k] </span><span>for</span><span> k </span><span>in</span></div></div><div><div><div>83</div></div><div><span><span>                      </span></span><span>(</span><span>"rot13Offset"</span><span>, </span><span>"rot47Offset"</span><span>, </span><span>"caesarShift"</span><span>, </span><span>"salt"</span><span>)})</span></div></div><div><div><div>84</div></div><div>
</div></div><div><div><div>85</div></div><div><span><span>    </span></span><span>total </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>86</div></div><div><span>    </span><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, </span><span>TOTAL_PAGES</span><span> </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>87</div></div><div><span><span>        </span></span><span>timestamp </span><span>=</span><span> </span><span>str</span><span>(</span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>))</span></div></div><div><div><div>88</div></div><div><span><span>        </span></span><span>response </span><span>=</span><span> session.</span><span>post</span><span>(</span><span>DATA_URL</span><span>, </span><span>headers</span><span><span>=</span><span>{</span></span></div></div><div><div><div>89</div></div><div><span>            </span><span>"X-ROT-Sign"</span><span><span>: </span><span>sign</span><span>(config, page, timestamp),</span></span></div></div><div><div><div>90</div></div><div><span><span>        </span></span><span>}, </span><span>data</span><span><span>=</span><span>json.</span><span>dumps</span><span>({</span></span></div></div><div><div><div>91</div></div><div><span>            </span><span>"rotParams"</span><span><span>: </span><span>encode_params</span><span>(config, page, timestamp),</span></span></div></div><div><div><div>92</div></div><div><span><span>        </span></span><span>}), </span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>93</div></div><div>
</div></div><div><div><div>94</div></div><div><span>        </span><span>if</span><span><span> response.status_code </span><span>==</span><span> </span></span><span>403</span><span>:</span></div></div><div><div><div>95</div></div><div><span>            </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>"403：签名不对。先确认 raw 是 '/cipher|page|timestamp|salt'，再做两遍 ROT-N"</span><span>)</span></div></div><div><div><div>96</div></div><div><span><span>        </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>97</div></div><div>
</div></div><div><div><div>98</div></div><div><span><span>        </span></span><span>payload </span><span>=</span><span> </span><span>decrypt</span><span>(config, response.</span><span>json</span><span>()[</span><span>"rotData"</span><span>])</span></div></div><div><div><div>99</div></div><div><span><span>        </span></span><span>numbers </span><span>=</span><span> payload[</span><span>"data"</span><span>]</span></div></div><div><div><div>100</div></div><div><span><span>        </span></span><span>total </span><span>+=</span><span> </span><span>sum</span><span>(numbers)</span></div></div><div><div><div>101</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page</span><span>}</span><span> 页：</span><span>{</span><span>numbers</span><span>}</span><span> → 累计 </span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>102</div></div><div><span><span>        </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)</span></div></div><div><div><div>103</div></div><div>
</div></div><div><div><div>104</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>答案：</span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>105</div></div><div>
</div></div><div><div><div>106</div></div><div>
</div></div><div><div><div>107</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>108</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s6.py</code>。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>先拿 <code>/init</code> 的参数，别硬编码（每个账号不同）。</li>
<li>自己实现一遍三个变换，并用「正着加密、反着解密」自测一次。</li>
<li>签名 403 → 检查 raw 的四段拼接顺序与分隔符、摘要是不是 hex、ROT 是不是做了<strong>两遍</strong>。</li>
<li>解密报错 → 打印中间结果：<code>unrotated</code> 应该是 Base32 字符（A-Z2-7），不是的话说明 <code>rot47Offset</code> 用错了。</li>
<li>Base32 解码前先清洗字符（去掉 <code>?</code>、空格、小写要转大写）并补 <code>=</code>。</li>
<li>参数 JSON 用 <code>separators=(",", ":")</code>（不要有空格），两边必须完全一致。</li>
<li>网页上只放出前 4 页，第 5 页必须靠接口。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>这是真加密吗？<a href="#这是真加密吗"><span>#</span></a></h3><p>不是。ROT/凯撒/Base32 都是<strong>编码或古典替换</strong>，没有密钥强度可言；它们的价值只是「挡一挡随手抓包的人」。真正能扛的是后面的 MAC 签名和会话校验。</p></section><section><h3>为什么签名用 MD5 而不是 SHA-256？<a href="#为什么签名用-md5-而不是-sha-256"><span>#</span></a></h3><p>这里只是把常见套路凑齐。HMAC-MD5 作为 MAC 在「防篡改」场景仍然可用，但<strong>不要</strong>把它当哈希用在口令上（那是另一回事）。</p></section><section><h3><code>rot13Offset</code> 为什么叫这个名字？<a href="#rot13offset-为什么叫这个名字"><span>#</span></a></h3><p>因为它就是 ROT-N 的偏移量，N=13 时正好是经典的 ROT13（自逆）。这里的 N 是随机的，所以不能自逆 —— 这就是第一个坑。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>学习加密伪装的目的，是能看懂、能定位、能写报告，而不是去构造攻击。靶场里随便试；真实站点上，抓取前先确认授权与规则。</p><p>去挑战广场提交，然后进 CH-007：数据会变成「一个字都看不到」的字体库映射。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch7/</id>
      <title type="text">Python 爬虫字体映射实战：HTML 里一个字都没有（CH-007）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch7/"/>
      <summary type="text">本文是 LearnSpider 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来：页面 HTML 里只有随机类名，文字全在字体库（SVG 文字地图）里，而「每个类名要把图挪多少像素」是接口加密下发的。目标是拿到 5 段…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来：页面 HTML 里只有随机类名，<strong>文字全在字体库（SVG 文字地图）里，而「每个类名要把图挪多少像素」是接口加密下发的</strong>。目标是拿到 5 段古文、把它们各自的暗号字按页码拼起来。</p></blockquote>
<section><h2>现象：页面上有字，源码里没有字<a href="#现象页面上有字源码里没有字"><span>#</span></a></h2><p>打开 <code>/verse?challenge=CH-007</code>，页面上是一段竖排/横排的古文。但「查看源代码」里搜不到任何一个正文词：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"next-glyph cb68626"</span><span>&gt;&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"next-glyph c9ce688"</span><span>&gt;&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>每个字都是一个<strong>空的 span + 随机类名</strong>。字去哪了？去看 <code>&lt;head&gt;</code> 里多出来的两个东西：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"stylesheet"</span><span> </span><span>href</span><span>=</span><span>"/playground/verse/glyph.css"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>style</span><span> </span><span>id</span><span>=</span><span>"verse-glyph-positions"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>.cb68626</span><span>{background-position:</span><span><span>-0</span><span>px</span></span><span> </span><span><span>-0</span><span>px</span></span><span>}</span></div></div><div><div><div>4</div></div><div><span>  </span><span>.c9ce688</span><span>{background-position:</span><span><span>-20</span><span>px</span></span><span> </span><span><span>-0</span><span>px</span></span><span>}</span></div></div><div><div><div>5</div></div><div><span>  </span><span>...</span></div></div><div><div><div>6</div></div><div><span>&lt;</span><span>/</span><span>style</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>glyph.css</code> 是<strong>静态</strong>的字体库样式：格子多大、背景图是哪张；</li>
<li>第二段 <code>&lt;style&gt;</code> 是<strong>页面运行时拼出来的</strong>，内容来自取数接口的响应 —— 这才是这一关的核心。</li>
</ul></section>
<section><h2>字体库长什么样<a href="#字体库长什么样"><span>#</span></a></h2><div><figure><figcaption><span>/playground/verse/glyph.css</span></figcaption><pre><code><div><div><div>1</div></div><div><span>.next-glyph</span><span><span>{display:</span><span>inline-block</span><span>;width:</span></span><span><span>20</span><span>px</span></span><span>;height:</span><span><span>26</span><span>px</span></span><span><span>;overflow:</span><span>hidden</span><span>;</span></span></div></div><div><div><div>2</div></div><div><span><span>            </span></span><span>background-image:</span><span>url</span><span>(</span><span>'/playground/verse/glyphs.svg'</span><span><span>);background-repeat:</span><span>no-repeat</span><span>}</span></span></div></div><div><div><div>3</div></div><div><span>/* 背景图 800x208，每行 40 个字 */</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span>/playground/verse/glyphs.svg</span></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>svg</span><span> </span><span>xmlns</span><span>=</span><span>"http://www.w3.org/2000/svg"</span><span> </span><span>width</span><span>=</span><span>"800"</span><span> </span><span>height</span><span>=</span><span>"208"</span><span> </span><span>viewBox</span><span>=</span><span>"0 0 800 208"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>text</span><span> </span><span>x</span><span>=</span><span>"0"</span><span> </span><span>y</span><span>=</span><span>"20"</span><span> </span><span>font-size</span><span>=</span><span>"20"</span><span> </span><span>font-family</span><span>=</span><span>"Songti SC, SimSun, serif"</span><span>&gt;赋下托川白美禁然凌喜南荆各嫠而海未予波夫安绝世江 旗与中立耳侣游寄粟成甚鹿响蛟流酒&lt;/</span><span>text</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>&lt;</span><span>text</span><span> </span><span>x</span><span>=</span><span>"0"</span><span> </span><span>y</span><span>=</span><span>"46"</span><span> </span><span>font-size</span><span>=</span><span>"20"</span><span> ...&gt;口悲万纵星今和驾临共固棹扣歌羡曹上水色空；知天御’泛窈七诵吹酾子苏之间光况桂东藏&lt;/</span><span>text</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>...</span></div></div><div><div><div>5</div></div><div><span>&lt;/</span><span>svg</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两个重要事实：</p><ol>
<li>这张图里是<strong>整篇古文去重后的字符</strong>，顺序<strong>被打乱了</strong>（不是正文顺序）；</li>
<li>每个字占一格：宽 20px（= 字号，中文一个字正好一格）、高 26px，每行 40 个字。</li>
</ol><p>所以「光有字体库」拼不出正文 —— 它只是一张字表，谁在哪一格要看接口给的坐标。</p></section>
<section><h2>接口给了什么<a href="#接口给了什么"><span>#</span></a></h2><p>先看协议：这一关是 <code>URL 编码 + HMAC-SHA256 + XOR</code> 混合加密。三个请求/响应细节：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET  /api/challenges/CH-007/init</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>-&gt; {"hmacKey": "…32 位 hex…", "xorKey": 137, "total_pages": 5, "visible_pages": 4}</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>POST /api/challenges/CH-007/data</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>header  X-URL-Sign = base64(HMAC-SHA256(f"/verse|{page}|{timestamp}", hmacKey))</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>body    {"params": &lt;URL 编码后的 JSON 字符串&gt;}</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>响应    {"encrypted": true, "xorKey": 137,</span></div></div><div><div><div>8</div></div><div><span><span>           </span></span><span>"data": &lt;URL 编码( Base64( 逐字节 XOR(明文 JSON, xorKey) ) )&gt;}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>解密顺序（<strong>从外往里剥</strong>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>URL 解码 → Base64 解码 → 逐字节 XOR(xorKey) → utf-8 → json.loads</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>解出来的明文<strong>没有正文、没有暗号字本身</strong>，只有「类名 + 坐标」：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"page"</span><span>: </span><span>1</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"layout"</span><span>: {</span><span>"font_size"</span><span>: </span><span>20</span><span>, </span><span>"cell_width"</span><span>: </span><span>20</span><span>, </span><span>"line_height"</span><span>: </span><span>28</span><span>, </span><span>"chars_per_row"</span><span>: </span><span>15</span><span>,</span></div></div><div><div><div>4</div></div><div><span>             </span><span>"rows"</span><span>: </span><span>10</span><span>, </span><span>"padding"</span><span>: </span><span>14</span><span>, </span><span>"width"</span><span>: </span><span>328</span><span>, </span><span>"height"</span><span>: </span><span>308</span><span>},</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"glyphs"</span><span>: [</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>{</span><span>"c"</span><span>: </span><span>"cb68626"</span><span>, </span><span>"x"</span><span>: </span><span>0</span><span>,   </span><span>"y"</span><span>: </span><span>0</span><span>},</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{</span><span>"c"</span><span>: </span><span>"c9ce688"</span><span>, </span><span>"x"</span><span>: </span><span>20</span><span>,  </span><span>"y"</span><span>: </span><span>0</span><span>},</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{</span><span>"c"</span><span>: </span><span>"cf33753"</span><span>, </span><span>"x"</span><span>: </span><span>40</span><span>,  </span><span>"y"</span><span>: </span><span>0</span><span>}</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>10</div></div><div><span>  </span><span>"key_index"</span><span>: </span><span>37</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>glyphs</code> 数组的顺序 = <strong>阅读顺序</strong>；</li>
<li>每个元素的 <code>(x, y)</code> = 这个字在<strong>字体库里的格子左上角坐标</strong>；</li>
<li><code>key_index</code> = 暗号字排在第几位（从 0 数）；</li>
<li><code>layout</code> 只是页面排版用的（字号/行距/每行几个字），跟字体库的格子无关 —— 别拿它算行列。</li>
</ul></section>
<section><h2>还原公式<a href="#还原公式"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>行 = y // 26          # 格子高</span></div></div><div><div><div>2</div></div><div><span>列 = x // 20          # 格子宽</span></div></div><div><div><div>3</div></div><div><span>字 = glyphs.svg 第 &lt;行&gt; 行文本的第 &lt;列&gt; 个字</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>按 <code>glyphs</code> 的顺序对每个元素做一遍，拼起来就是这一段原文。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-007：答案 = 5 段各自的暗号字按页码拼起来（共 5 个字）"""</span></div></div><div><div><div>2</div></div><div><span>import</span><span> base64</span></div></div><div><div><div>3</div></div><div><span>import</span><span> hashlib</span></div></div><div><div><div>4</div></div><div><span>import</span><span> hmac</span></div></div><div><div><div>5</div></div><div><span>import</span><span> json</span></div></div><div><div><div>6</div></div><div><span>import</span><span> re</span></div></div><div><div><div>7</div></div><div><span>import</span><span> time</span></div></div><div><div><div>8</div></div><div><span>import</span><span> urllib.parse</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>13</div></div><div><span><span>ROUTE</span><span> </span><span>=</span><span> </span></span><span>"/verse"</span></div></div><div><div><div>14</div></div><div><span><span>INIT_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-007/init"</span></div></div><div><div><div>15</div></div><div><span><span>DATA_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-007/data"</span></div></div><div><div><div>16</div></div><div><span><span>SHEET_CSS_PATH</span><span> </span><span>=</span><span> </span></span><span>"/playground/verse/glyph.css"</span><span>     </span><span># 静态字体库样式（不需登录）</span></div></div><div><div><div>17</div></div><div><span><span>TOTAL_PAGES</span><span> </span><span>=</span><span> </span></span><span>5</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>def</span><span> </span><span>sign</span><span>(</span><span>hmac_key</span><span>,</span><span><span> </span><span>page</span></span><span>,</span><span><span> </span><span>timestamp</span></span><span>):</span></div></div><div><div><div>21</div></div><div><span>    </span><span>"""base64(HMAC-SHA256("/verse|page|timestamp", hmacKey))"""</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>raw </span><span>=</span><span> </span><span>f</span><span>"</span><span>{</span><span>ROUTE</span><span>}</span><span>|</span><span>{</span><span>page</span><span>}</span><span>|</span><span>{</span><span>timestamp</span><span>}</span><span>"</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>digest </span><span>=</span><span> hmac.</span><span>new</span><span>(hmac_key.</span><span>encode</span><span>(), raw.</span><span>encode</span><span>(), hashlib.sha256).</span><span>digest</span><span>()</span></div></div><div><div><div>24</div></div><div><span>    </span><span>return</span><span><span> base64.</span><span>b64encode</span><span>(digest).</span><span>decode</span><span>()</span></span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>def</span><span> </span><span>encode_params</span><span>(</span><span>page</span><span>,</span><span><span> </span><span>timestamp</span></span><span>):</span></div></div><div><div><div>28</div></div><div><span>    </span><span>return</span><span><span> urllib.parse.</span><span>quote</span><span>(json.</span><span>dumps</span><span>({</span></span><span>"page"</span><span>: page, </span><span>"timestamp"</span><span>: timestamp},</span></div></div><div><div><div>29</div></div><div><span>                                        </span><span>separators</span><span><span>=</span><span>(</span></span><span>","</span><span>, </span><span>":"</span><span>)), </span><span>safe</span><span>=</span><span>""</span><span>)</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>def</span><span> </span><span>decrypt</span><span>(</span><span>url_encoded</span><span>,</span><span><span> </span><span>xor_key</span></span><span>):</span></div></div><div><div><div>33</div></div><div><span>    </span><span>"""URL 解码 → Base64 解码 → 逐字节 XOR → utf-8 → JSON"""</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>masked </span><span>=</span><span> base64.</span><span>b64decode</span><span>(urllib.parse.</span><span>unquote</span><span>(url_encoded))</span></div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>plain </span><span>=</span><span> </span><span>bytes</span><span><span>(byte </span><span>^</span><span> xor_key </span></span><span>for</span><span> byte </span><span>in</span><span> masked)</span></div></div><div><div><div>36</div></div><div><span>    </span><span>return</span><span><span> json.</span><span>loads</span><span>(plain.</span><span>decode</span><span>(</span></span><span>"utf-8"</span><span>))</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>def</span><span> </span><span>parse_font_map</span><span>(</span><span>session</span><span>):</span></div></div><div><div><div>40</div></div><div><span>    </span><span>"""从静态字体库扒出格子宽高和一行一行的字。"""</span></div></div><div><div><div>41</div></div><div><span><span>    </span></span><span>css </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> </span><span>SHEET_CSS_PATH</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span>).text</span></div></div><div><div><div>42</div></div><div><span><span>    </span></span><span>head </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"</span><span>\.</span><span>next-glyph</span><span>\{</span><span>(</span></span><span><span>[</span><span>^</span></span><span>}</span><span>]*</span><span><span>)</span><span>\}</span><span>"</span></span><span><span>, css).</span><span>group</span><span>(</span></span><span>1</span><span>)</span></div></div><div><div><div>43</div></div><div><span><span>    </span></span><span>cell_w </span><span>=</span><span> </span><span>int</span><span><span>(re.</span><span>search</span><span>(</span></span><span>r</span><span><span>"width:</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>px"</span></span><span><span>, head).</span><span>group</span><span>(</span></span><span>1</span><span>))</span></div></div><div><div><div>44</div></div><div><span><span>    </span></span><span>cell_h </span><span>=</span><span> </span><span>int</span><span><span>(re.</span><span>search</span><span>(</span></span><span>r</span><span><span>"height:</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>px"</span></span><span><span>, head).</span><span>group</span><span>(</span></span><span>1</span><span>))</span></div></div><div><div><div>45</div></div><div><span><span>    </span></span><span>svg_path </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"url</span><span>\(</span><span>'</span><span>(</span></span><span><span>[</span><span>^</span></span><span>'</span><span>]+</span><span><span>)</span><span>'</span><span>\)</span><span>"</span></span><span><span>, head).</span><span>group</span><span>(</span></span><span>1</span><span>)</span></div></div><div><div><div>46</div></div><div>
</div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>svg </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> svg_path, </span><span>timeout</span><span>=</span><span>10</span><span>).text</span></div></div><div><div><div>48</div></div><div><span><span>    </span></span><span>rows </span><span>=</span><span> re.</span><span>findall</span><span>(</span><span>r</span><span>"&lt;text</span><span><span>[</span><span>^</span></span><span>&gt;</span><span>]*</span><span><span>&gt;</span><span>(</span><span>.</span></span><span>*?</span><span><span>)</span><span>&lt;/text&gt;"</span></span><span>, svg, re.S)</span></div></div><div><div><div>49</div></div><div><span>    </span><span>return</span><span> cell_w, cell_h, rows</span></div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div>
</div></div><div><div><div>52</div></div><div><span>def</span><span> </span><span>decode</span><span>(</span><span>glyphs</span><span>,</span><span><span> </span><span>cell_w</span></span><span>,</span><span><span> </span><span>cell_h</span></span><span>,</span><span><span> </span><span>rows</span></span><span>):</span></div></div><div><div><div>53</div></div><div><span>    </span><span>"""坐标 → 行/列 → 字。"""</span></div></div><div><div><div>54</div></div><div><span><span>    </span></span><span>chars </span><span>=</span><span> []</span></div></div><div><div><div>55</div></div><div><span>    </span><span>for</span><span> glyph </span><span>in</span><span> glyphs:</span></div></div><div><div><div>56</div></div><div><span><span>        </span></span><span>row </span><span>=</span><span> glyph[</span><span>"y"</span><span><span>] </span><span>//</span><span> cell_h</span></span></div></div><div><div><div>57</div></div><div><span><span>        </span></span><span>col </span><span>=</span><span> glyph[</span><span>"x"</span><span><span>] </span><span>//</span><span> cell_w</span></span></div></div><div><div><div>58</div></div><div><span><span>        </span></span><span>chars.</span><span>append</span><span>(rows[row][col])</span></div></div><div><div><div>59</div></div><div><span>    </span><span>return</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(chars)</span></span></div></div><div><div><div>60</div></div><div>
</div></div><div><div><div>61</div></div><div>
</div></div><div><div><div>62</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>63</div></div><div><span><span>    </span></span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></div></div><div><div><div>64</div></div><div><span><span>    </span></span><span>session.headers.</span><span>update</span><span>({</span></div></div><div><div><div>65</div></div><div><span>        </span><span>"Content-Type"</span><span>: </span><span>"application/json"</span><span>,</span></div></div><div><div><div>66</div></div><div><span>        </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/verse?challenge=CH-007"</span><span>,</span></div></div><div><div><div>67</div></div><div><span>        </span><span>"User-Agent"</span><span>: </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36"</span><span>,</span></div></div><div><div><div>68</div></div><div><span><span>    </span></span><span>})</span></div></div><div><div><div>69</div></div><div><span><span>    </span></span><span>session.cookies.</span><span>set</span><span>(</span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>70</div></div><div><span>                        </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>71</div></div><div>
</div></div><div><div><div>72</div></div><div><span><span>    </span></span><span>config </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>INIT_URL</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span><span>).</span><span>json</span><span>()</span></span></div></div><div><div><div>73</div></div><div><span><span>    </span></span><span>cell_w, cell_h, rows </span><span>=</span><span> </span><span>parse_font_map</span><span>(session)</span></div></div><div><div><div>74</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"字体库：格子 </span><span>{</span><span>cell_w</span><span>}</span><span>x</span><span>{</span><span>cell_h</span><span>}</span><span>，</span><span>{</span><span>len</span><span>(rows)</span><span>}</span><span> 行</span><span>\n</span><span>"</span><span>)</span></div></div><div><div><div>75</div></div><div>
</div></div><div><div><div>76</div></div><div><span><span>    </span></span><span>keys </span><span>=</span><span> []</span></div></div><div><div><div>77</div></div><div><span>    </span><span>for</span><span> page </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, </span><span>TOTAL_PAGES</span><span> </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>78</div></div><div><span><span>        </span></span><span>timestamp </span><span>=</span><span> </span><span>str</span><span>(</span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>))</span></div></div><div><div><div>79</div></div><div><span><span>        </span></span><span>response </span><span>=</span><span> session.</span><span>post</span><span>(</span><span>DATA_URL</span><span>, </span><span>headers</span><span><span>=</span><span>{</span></span></div></div><div><div><div>80</div></div><div><span>            </span><span>"X-URL-Sign"</span><span><span>: </span><span>sign</span><span>(config[</span></span><span>"hmacKey"</span><span>], page, timestamp),</span></div></div><div><div><div>81</div></div><div><span><span>        </span></span><span>}, </span><span>data</span><span><span>=</span><span>json.</span><span>dumps</span><span>({</span></span><span>"params"</span><span><span>: </span><span>encode_params</span><span>(page, timestamp)}), </span></span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>82</div></div><div>
</div></div><div><div><div>83</div></div><div><span>        </span><span>if</span><span><span> response.status_code </span><span>==</span><span> </span></span><span>403</span><span>:</span></div></div><div><div><div>84</div></div><div><span>            </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>"403：签名不对。自查 —— raw 是 '/verse|page|timestamp'，"</span></div></div><div><div><div>85</div></div><div><span>                             </span><span>"结果是 HMAC-SHA256 的 Base64，路径带前导斜杠"</span><span>)</span></div></div><div><div><div>86</div></div><div><span><span>        </span></span><span>response.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>87</div></div><div>
</div></div><div><div><div>88</div></div><div><span><span>        </span></span><span>body </span><span>=</span><span> response.</span><span>json</span><span>()</span></div></div><div><div><div>89</div></div><div><span><span>        </span></span><span>payload </span><span>=</span><span> </span><span>decrypt</span><span>(body[</span><span>"data"</span><span>], </span><span>int</span><span>(body[</span><span>"xorKey"</span><span>]))</span></div></div><div><div><div>90</div></div><div><span><span>        </span></span><span>glyphs </span><span>=</span><span> payload[</span><span>"glyphs"</span><span>]</span></div></div><div><div><div>91</div></div><div>
</div></div><div><div><div>92</div></div><div><span><span>        </span></span><span>text </span><span>=</span><span> </span><span>decode</span><span>(glyphs, cell_w, cell_h, rows)</span></div></div><div><div><div>93</div></div><div><span><span>        </span></span><span>key </span><span>=</span><span> </span><span>decode</span><span>([glyphs[payload[</span><span>"key_index"</span><span>]]], cell_w, cell_h, rows)</span></div></div><div><div><div>94</div></div><div><span><span>        </span></span><span>keys.</span><span>append</span><span>(key)</span></div></div><div><div><div>95</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page</span><span>}</span><span> 段：</span><span>{</span><span>len</span><span>(text)</span><span>}</span><span> 字 → 暗号字「</span><span>{</span><span>key</span><span>}</span><span>」"</span><span>)</span></div></div><div><div><div>96</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"          开头：</span><span>{</span><span>text[:</span><span>24</span><span>]</span><span>}</span><span>…"</span><span>)</span></div></div><div><div><div>97</div></div><div><span><span>        </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)</span></div></div><div><div><div>98</div></div><div>
</div></div><div><div><div>99</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>答案：</span><span>{</span><span>''</span><span><span>.</span><span>join</span><span>(keys)</span></span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>100</div></div><div>
</div></div><div><div><div>101</div></div><div>
</div></div><div><div><div>102</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>103</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s7.py</code>。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>源码里搜不到正文 → 看 <code>&lt;head&gt;</code> 里有没有 <code>@font-face</code>、<code>background-image</code>、动态 <code>&lt;style&gt;</code>。</li>
<li>找到静态字体库（<code>glyph.css</code> / <code>glyphs.svg</code>），先把格子宽高和每行字数弄清楚。</li>
<li>别用页面排版参数算行列（<code>layout.line_height</code> 和字体库格子高是两回事）。</li>
<li>签名 403 → 检查 raw 是否 <code>"/verse|page|timestamp"</code>、结果是否 <strong>Base64</strong>。</li>
<li>解密报错 → 记住顺序是 <strong>URL → Base64 → XOR → utf-8</strong>，XOR 的 key 是响应里的 <code>xorKey</code>（不在 init 里）。</li>
<li>坐标是<strong>格子左上角</strong>，直接整除格子宽高即可，别做四舍五入。</li>
<li>字体库顺序是打乱的：不要试图按顺序读图。</li>
<li>网页只放出前 4 段，第 5 段必须调接口。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>为什么不用 <code>@font-face</code> 而是背景图？<a href="#为什么不用-font-face-而是背景图"><span>#</span></a></h3><p>真实站点两种都有：<code>@font-face</code> 靠字体文件的 <code>cmap</code> 做映射，背景图靠 <code>background-position</code> 做映射。这一关用的是第二种（更容易看清「坐标」这件事）。想练第一种，看下一关 CH-008。</p></section><section><h3>字体库能提前缓存吗？<a href="#字体库能提前缓存吗"><span>#</span></a></h3><p>可以，它是静态的、和账号无关。但<strong>坐标是加密下发的、每个账号不同</strong>，所以缓存字体库只能省一次请求，不能省解密。</p></section><section><h3>为什么我的第 5 段总取不到？<a href="#为什么我的第-5-段总取不到"><span>#</span></a></h3><p>网页上的分页标签只有 4 个（第 5 个是 disabled）。要拿第 5 段，必须自己构造请求 —— 这正是「页面上看不到的页，脚本才看得到」的练习点。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>字体映射是电商、点评类站点最常用的数字/文字保护手段之一。学会它就是学会「怎么把视觉还原成数据」；用的时候请遵守目标站点的规则与数据授权。</p><p>去挑战广场提交，然后进 CH-008：字体还在，但这次换成了真正的「字体文件 + 码位」。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch8/</id>
      <title type="text">Python 爬虫字体加密实战：码位混淆 + 自定义字体文件（CH-008）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch8/"/>
      <summary type="text">本文是 LearnSpider 第 8 关的解密教程。这一关是真正的字体加密：价格和销量是一串 Uncode 私有区字符（\ue1a2），站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量，提交价格总和（保留…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 8 关的解密教程。这一关是<strong>真正的字体加密</strong>：价格和销量是一串 Uncode 私有区字符（<code>\ue1a2</code>），站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量，提交价格总和（保留 2 位小数）。</p></blockquote>
<section><h2>现象：价格栏里没有数字<a href="#现象价格栏里没有数字"><span>#</span></a></h2><p>打开 <code>/font?challenge=CH-008</code>，表格里商品名正常，价格和销量看起来也正常。但用控制台读文本：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'.notice tbody tr'</span><span>)[</span><span>0</span><span>].</span><span>querySelectorAll</span><span>(</span><span>'td'</span><span>)[</span><span>2</span><span>].</span><span>textContent</span></div></div><div><div><div>2</div></div><div><span>// "\ue1a2\ue867\uea85\ue263\uf63c\ue57b"   ← 一串私有区字符</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>Array</span><span>.</span></span><span>from</span><span><span>(</span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'span.font-glyph'</span><span>)).</span><span>slice</span><span>(</span><span>0</span><span>,</span><span>3</span><span>).</span><span>map</span><span><span>(</span><span>s</span><span> </span></span><span>=&gt;</span><span><span> </span><span>s</span><span>.</span></span><span>className</span><span>)</span></div></div><div><div><div>2</div></div><div><span>// ["font-glyph ge1a2", "font-glyph ge867", "font-glyph gea85"]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三点确认这是「字体加密」而不是编码问题：</p><ol>
<li>字符是<strong>合法的 Unicode 字符</strong>（私有区 <code>U+E000</code>~<code>U+F8FF</code>），只是没有语义；</li>
<li>页面上看得见数字 —— 说明有东西把它们画出来了；</li>
<li>那个东西是 <strong>CSS 背景图 + 字体文件</strong>，不是 <code>response.encoding</code> 的问题。</li>
</ol><p>（对照第 7 关：CH-007 是类名 + 静态字形图；这一关多了<strong>一份按账号派生的字体文件</strong>做中间层。）</p></section>
<section><h2>三层结构<a href="#三层结构"><span>#</span></a></h2><p><strong>① 页面 HTML</strong>：只有码位 + 类名，没有数字。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"font-glyph ge1a2"</span><span>&gt;</span><span>&amp;#xE1A2;</span><span>&lt;/</span><span>span</span><span>&gt;&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"font-glyph ge867"</span><span>&gt;</span><span>&amp;#xE867;</span><span>&lt;/</span><span>span</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>② 字形图（静态、全站公用）</strong></p><div><figure><figcaption><span>/playground/verse/font/digits.css</span></figcaption><pre><code><div><div><div>1</div></div><div><span>.font-glyph</span><span><span>{display:</span><span>inline-block</span><span>;width:</span></span><span><span>16</span><span>px</span></span><span>;height:</span><span><span>20</span><span>px</span></span><span><span>;overflow:</span><span>hidden</span><span>;color:</span><span>transparent</span><span>;</span></span></div></div><div><div><div>2</div></div><div><span><span>            </span></span><span>background-image:</span><span>url</span><span>(</span><span>'/playground/verse/font/digits.svg'</span><span><span>);background-repeat:</span><span>no-repeat</span><span>}</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!-- /playground/verse/font/digits.svg：0-9 和 .,+-，打乱顺序，一格一个 text --&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>text</span><span> </span><span>x</span><span>=</span><span>"0"</span><span> </span><span>y</span><span>=</span><span>"16"</span><span> </span><span>font-size</span><span>=</span><span>"16"</span><span>&gt;6&lt;/</span><span>text</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;</span><span>text</span><span> </span><span>x</span><span>=</span><span>"16"</span><span> </span><span>y</span><span>=</span><span>"16"</span><span> </span><span>font-size</span><span>=</span><span>"16"</span><span>&gt;1&lt;/</span><span>text</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span>&lt;</span><span>text</span><span> </span><span>x</span><span>=</span><span>"32"</span><span> </span><span>y</span><span>=</span><span>"16"</span><span> </span><span>font-size</span><span>=</span><span>"16"</span><span>&gt;9&lt;/</span><span>text</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>③ 字体文件（按账号派生，公开可下载）</strong></p><p><code>GET /api/challenges/CH-008/data</code> 会给出 <code>font_url</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"font_url"</span><span>: </span><span>"/playground/verse/font/23-3f1a9c0b2e.svg"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"row_count"</span><span>: </span><span>30</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"rows"</span><span>: [{</span><span>"name"</span><span>: </span><span>"云南普洱饼"</span><span>, </span><span>"price"</span><span>: </span><span>"</span><span>\ueb02\ue867</span><span>…"</span><span>, </span><span>"sales"</span><span>: </span><span>"</span><span>\ue3c0</span><span>…"</span><span>}]</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span>/playground/verse/font/23-3f1a9c0b2e.svg</span></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>svg</span><span> </span><span>xmlns</span><span>=</span><span>"http://www.w3.org/2000/svg"</span><span>&gt;&lt;</span><span>defs</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>font</span><span> </span><span>id</span><span>=</span><span>"LSFont"</span><span> </span><span>horiz-adv-x</span><span>=</span><span>"16"</span><span>&gt;                                  </span><span>&lt;!-- 格子宽 16 --&gt;</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>&lt;</span><span>font-face</span><span> </span><span>font-family</span><span>=</span><span>"LSFont"</span><span> </span><span>units-per-em</span><span>=</span><span>"20"</span><span> </span><span>ascent</span><span>=</span><span>"16"</span><span> </span><span>descent</span><span>=</span><span>"-4"</span><span>/&gt;  </span><span>&lt;!-- 格子高 20，基线在格子里 16 处 --&gt;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&lt;</span><span>glyph</span><span> </span><span>unicode</span><span>=</span><span><span>"</span><span>&amp;</span></span><span>#xEB02</span><span><span>;</span><span>"</span></span><span> </span><span>d</span><span>=</span><span>"M80 16 L96 16 L96 36 L80 36 Z"</span><span>/&gt;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>&lt;</span><span>glyph</span><span> </span><span>unicode</span><span>=</span><span><span>"</span><span>&amp;</span></span><span>#xE867</span><span><span>;</span><span>"</span></span><span> </span><span>d</span><span>=</span><span>"M0 36 L16 36 L16 56 L0 56 Z"</span><span>/&gt;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>&lt;/</span><span>font</span><span>&gt;</span></div></div><div><div><div>7</div></div><div><span>&lt;/</span><span>defs</span><span>&gt;&lt;/</span><span>svg</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>d</code> 里那个矩形的左上角 <code>(x, y)</code> 就是这个码位对应字形<strong>在字形图里的落点</strong>。</p><blockquote><p>真字体的 <code>d</code> 是矢量轮廓；这里简化成了「落点框」，但<strong>需要你做的事没变</strong>：把字体信息和字形图对上。</p></blockquote></section>
<section><h2>还原公式<a href="#还原公式"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>码位 → 字体里的 (x, y) → 在字形图里找同一个 (x, y) 的那个字</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果想按行列取（也可以）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>列 = x // 16</span></div></div><div><div><div>2</div></div><div><span>行 = (y - ascent) // 20        # ascent = 16，来自 &lt;font-face&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-008：答案 = 30 件商品价格之和（保留 2 位小数）</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>要点：</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>1. 价格/销量是私有区码位，每个字符实例一个独立码位（同一个 8 在不同位置也不同）</span></div></div><div><div><div>5</div></div><div><span><span>     </span></span><span>—— 所以频率统计没用，必须一个码位一个码位查字体</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>2. 字体文件按账号派生（URL 里带短签），别人的字体对不上你的码位</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>3. 字体里的 (x, y) 是「字形在字形图里的落点」，字形图里的 &lt;text&gt; 用的是同一个坐标</span></div></div><div><div><div>8</div></div><div><span>"""</span></div></div><div><div><div>9</div></div><div><span>import</span><span> re</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>14</div></div><div><span><span>DATA_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-008/data"</span></div></div><div><div><div>15</div></div><div><span><span>SHEET_CSS_PATH</span><span> </span><span>=</span><span> </span></span><span>"/playground/verse/font/digits.css"</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></span></div></div><div><div><div>18</div></div><div><span><span>session.headers.</span><span>update</span><span>({</span></span></div></div><div><div><div>19</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"*/*"</span><span>,</span></div></div><div><div><div>20</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/font?challenge=CH-008"</span><span>,</span></div></div><div><div><div>21</div></div><div><span>    </span><span>"User-Agent"</span><span>: </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36"</span><span>,</span></div></div><div><div><div>22</div></div><div><span>})</span></div></div><div><div><div>23</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>24</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>def</span><span> </span><span>parse_font</span><span>(</span><span>text</span><span>):</span></div></div><div><div><div>28</div></div><div><span>    </span><span>"""字体文件 -&gt; (格子宽, 格子高, 基线高度, {码位: (x, y)})。"""</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>cell_w </span><span>=</span><span> </span><span>int</span><span><span>(re.</span><span>search</span><span>(</span></span><span>r</span><span><span>'horiz-adv-x="</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>"'</span></span><span><span>, text).</span><span>group</span><span>(</span></span><span>1</span><span>))</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>cell_h </span><span>=</span><span> </span><span>int</span><span><span>(re.</span><span>search</span><span>(</span></span><span>r</span><span><span>'units-per-em="</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>"'</span></span><span><span>, text).</span><span>group</span><span>(</span></span><span>1</span><span>))</span></div></div><div><div><div>31</div></div><div><span><span>    </span></span><span>ascent </span><span>=</span><span> </span><span>int</span><span><span>(re.</span><span>search</span><span>(</span></span><span>r</span><span><span>'ascent="</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>"'</span></span><span><span>, text).</span><span>group</span><span>(</span></span><span>1</span><span>))</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span><span>    </span></span><span>cmap </span><span>=</span><span> {}</span></div></div><div><div><div>34</div></div><div><span>    </span><span>for</span><span> hex_code, path </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span><span>'&lt;glyph unicode="&amp;#x</span><span>(</span></span><span>[</span><span>0-9A-Fa-f</span><span>]+</span><span><span>)</span><span>;" d="</span><span>(</span></span><span><span>[</span><span>^</span></span><span>"</span><span>]+</span><span><span>)</span><span>"/&gt;'</span></span><span>, text):</span></div></div><div><div><div>35</div></div><div><span><span>        </span></span><span>numbers </span><span>=</span><span> [</span><span>int</span><span>(value) </span><span>for</span><span> value </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span>"-</span><span>?</span><span>\d</span><span>+</span><span>"</span><span>, path)]</span></div></div><div><div><div>36</div></div><div><span><span>        </span></span><span>cmap[</span><span>int</span><span>(hex_code, </span><span>16</span><span><span>)] </span><span>=</span><span> (numbers[</span></span><span>0</span><span>], numbers[</span><span>1</span><span>])</span></div></div><div><div><div>37</div></div><div><span>    </span><span>return</span><span> cell_w, cell_h, ascent, cmap</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div><span>def</span><span> </span><span>parse_sheet</span><span>(</span><span>sess</span><span>):</span></div></div><div><div><div>41</div></div><div><span>    </span><span>"""字形图 -&gt; {(x, y): 字符}。"""</span></div></div><div><div><div>42</div></div><div><span><span>    </span></span><span>css </span><span>=</span><span> sess.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> </span><span>SHEET_CSS_PATH</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span>).text</span></div></div><div><div><div>43</div></div><div><span><span>    </span></span><span>head </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"</span><span>\.</span><span>font-glyph</span><span>\{</span><span>(</span></span><span><span>[</span><span>^</span></span><span>}</span><span>]*</span><span><span>)</span><span>\}</span><span>"</span></span><span><span>, css).</span><span>group</span><span>(</span></span><span>1</span><span>)</span></div></div><div><div><div>44</div></div><div><span><span>    </span></span><span>svg_path </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"url</span><span>\(</span><span>'</span><span>(</span></span><span><span>[</span><span>^</span></span><span>'</span><span>]+</span><span><span>)</span><span>'</span><span>\)</span><span>"</span></span><span><span>, head).</span><span>group</span><span>(</span></span><span>1</span><span>)</span></div></div><div><div><div>45</div></div><div>
</div></div><div><div><div>46</div></div><div><span><span>    </span></span><span>svg </span><span>=</span><span> sess.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> svg_path, </span><span>timeout</span><span>=</span><span>10</span><span>).text</span></div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>cells </span><span>=</span><span> {}</span></div></div><div><div><div>48</div></div><div><span>    </span><span>for</span><span> x, y, char </span><span>in</span><span><span> re.</span><span>findall</span><span>(</span></span><span>r</span><span><span>'&lt;text x="</span><span>(</span><span>-</span></span><span>?</span><span>\d</span><span>+</span><span><span>)</span><span>" y="</span><span>(</span><span>-</span></span><span>?</span><span>\d</span><span>+</span><span><span>)</span><span>"</span></span><span><span>[</span><span>^</span></span><span>&gt;</span><span>]*</span><span><span>&gt;</span><span>(</span></span><span><span>[</span><span>^</span></span><span>&lt;</span><span>]</span><span><span>)</span><span>&lt;/text&gt;'</span></span><span>, svg):</span></div></div><div><div><div>49</div></div><div><span><span>        </span></span><span>cells[(</span><span>int</span><span>(x), </span><span>int</span><span><span>(y))] </span><span>=</span><span> char</span></span></div></div><div><div><div>50</div></div><div><span>    </span><span>return</span><span> cells</span></div></div><div><div><div>51</div></div><div>
</div></div><div><div><div>52</div></div><div>
</div></div><div><div><div>53</div></div><div><span>def</span><span> </span><span>decode</span><span>(</span><span>text</span><span>,</span><span><span> </span><span>cmap</span></span><span>,</span><span><span> </span><span>cells</span></span><span>):</span></div></div><div><div><div>54</div></div><div><span>    </span><span>"""一串码位 -&gt; 真数字。"""</span></div></div><div><div><div>55</div></div><div><span>    </span><span>return</span><span> </span><span>""</span><span><span>.</span><span>join</span><span>(cells[cmap[</span></span><span>ord</span><span>(char)]] </span><span>for</span><span> char </span><span>in</span><span> text)</span></div></div><div><div><div>56</div></div><div>
</div></div><div><div><div>57</div></div><div>
</div></div><div><div><div>58</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>data </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>DATA_URL</span><span>, </span><span>timeout</span><span>=</span><span>10</span><span>)</span></div></div><div><div><div>60</div></div><div><span>    </span><span>if</span><span><span> data.status_code </span><span>==</span><span> </span></span><span>401</span><span>:</span></div></div><div><div><div>61</div></div><div><span>        </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>"401：这个接口要登录态（Cookie 没填对？）"</span><span>)</span></div></div><div><div><div>62</div></div><div><span><span>    </span></span><span>data.</span><span>raise_for_status</span><span>()</span></div></div><div><div><div>63</div></div><div><span><span>    </span></span><span>payload </span><span>=</span><span> data.</span><span>json</span><span>()</span></div></div><div><div><div>64</div></div><div>
</div></div><div><div><div>65</div></div><div><span><span>    </span></span><span>font </span><span>=</span><span> session.</span><span>get</span><span>(</span><span>BASE</span><span> </span><span>+</span><span> payload[</span><span>"font_url"</span><span>], </span><span>timeout</span><span>=</span><span>10</span><span>).text</span></div></div><div><div><div>66</div></div><div><span><span>    </span></span><span>cell_w, cell_h, ascent, cmap </span><span>=</span><span> </span><span>parse_font</span><span>(font)</span></div></div><div><div><div>67</div></div><div><span><span>    </span></span><span>cells </span><span>=</span><span> </span><span>parse_sheet</span><span>(session)</span></div></div><div><div><div>68</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"字体：格子 </span><span>{</span><span>cell_w</span><span>}</span><span>x</span><span>{</span><span>cell_h</span><span>}</span><span>（ascent </span><span>{</span><span>ascent</span><span>}</span><span>），</span><span>{</span><span>len</span><span>(cmap)</span><span>}</span><span> 个码位；"</span></div></div><div><div><div>69</div></div><div><span>          </span><span>f</span><span>"字形图 </span><span>{</span><span>len</span><span>(cells)</span><span>}</span><span> 格</span><span>\n</span><span>"</span><span>)</span></div></div><div><div><div>70</div></div><div>
</div></div><div><div><div>71</div></div><div><span><span>    </span></span><span>total </span><span>=</span><span> </span><span>0.0</span></div></div><div><div><div>72</div></div><div><span>    </span><span>for</span><span> index, row </span><span>in</span><span> </span><span>enumerate</span><span>(payload[</span><span>"rows"</span><span>], </span><span>start</span><span>=</span><span>1</span><span>):</span></div></div><div><div><div>73</div></div><div><span><span>        </span></span><span>price </span><span>=</span><span> </span><span>decode</span><span>(row[</span><span>"price"</span><span>], cmap, cells)</span></div></div><div><div><div>74</div></div><div><span><span>        </span></span><span>sales </span><span>=</span><span> </span><span>decode</span><span>(row[</span><span>"sales"</span><span>], cmap, cells)</span></div></div><div><div><div>75</div></div><div><span><span>        </span></span><span>total </span><span>+=</span><span> </span><span>float</span><span>(price)</span></div></div><div><div><div>76</div></div><div><span>        </span><span>if</span><span><span> index </span><span>&lt;=</span><span> </span></span><span>5</span><span>:</span></div></div><div><div><div>77</div></div><div><span>            </span><span>print</span><span>(</span><span>f</span><span>"  </span><span>{</span><span>index</span><span>:&gt;2</span><span>}</span><span>. </span><span>{</span><span>row[</span><span>'name'</span><span>]</span><span>}</span><span>  价格 </span><span>{</span><span>price</span><span>}</span><span>  销量 </span><span>{</span><span>sales</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>78</div></div><div>
</div></div><div><div><div>79</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>共 </span><span>{</span><span>len</span><span>(payload[</span><span>'rows'</span><span>])</span><span>}</span><span> 件商品，价格合计：</span><span>{</span><span>round</span><span>(total, </span><span>2</span><span>)</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>80</div></div><div>
</div></div><div><div><div>81</div></div><div>
</div></div><div><div><div>82</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>83</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s8.py</code>。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>先确认「页面正常但 DOM 是私有区字符」—— 这是字体加密的典型现场，不是编码问题。</li>
<li>看元素的 <code>font-family</code> / 背景图，找到字形图和字体文件。</li>
<li>抓取接口（<code>/api/challenges/CH-008/data</code>）拿<strong>你自己账号</strong>的 <code>font_url</code>。</li>
<li>解析字体文件里的三个尺寸：<code>horiz-adv-x</code>（格子宽）、<code>units-per-em</code>（格子高）、<code>ascent</code>（基线）。</li>
<li>解析字形图，把 <code>(x, y) → 字符</code> 建成字典。</li>
<li>码位 → 字体里的 <code>(x, y)</code> → 查字典。用<strong>同一个坐标</strong>直接命中，别自己猜行列。</li>
<li>想按行列算就记住：<code>行 = (y - ascent) // 格子高</code>。</li>
<li>每个字符实例码位不同 → 别写「频率统计」那套。</li>
<li>求和后 <code>round(..., 2)</code>。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>字体文件能给别人用吗？<a href="#字体文件能给别人用吗"><span>#</span></a></h3><p>不能。它是按账号派生的：<strong>别人的字体里码位和你的码位不是一回事</strong>。所以别去抄别人的字体，直接取自己账号的。</p></section><section><h3>为什么字体里的 <code>d</code> 是一个矩形，不是字形轮廓？<a href="#为什么字体里的-d-是一个矩形不是字形轮廓"><span>#</span></a></h3><p>这是靶场的简化：真字体里 <code>d</code> 是矢量轮廓，需要和标准字体做轮廓比对才能知道是哪个字。这里把「轮廓」换成了「落点框」，让你专注在「字体信息 + 字形图 → 字符」这条链路上。</p></section><section><h3>为什么同一个数字有时能被 OCR 认出来？<a href="#为什么同一个数字有时能被-ocr-认出来"><span>#</span></a></h3><p>因为字形图里的数字本身是正常字体画的。你的眼睛（或者 ddddocr）看截图是能认出来的 —— 但<strong>正确做法是解析映射</strong>，不是截屏 OCR：截图方案在真实站点上会因为滚动、懒加载、多设备渲染而崩掉。</p></section><section><h3>私有区字符会不会被 requests 弄坏？<a href="#私有区字符会不会被-requests-弄坏"><span>#</span></a></h3><p>不会。它们是合法字符，只要 <code>response.json()</code> 正常解析就行。要留意的是<strong>别用 <code>.encode("ascii")</code></strong> 之类的操作。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>字体加密是保护数字（价格、销量、票房）的常见手段。还原它属于「把视觉变成数据」，请在授权范围内使用：靶场随便试，真实站点先确认数据授权与抓取规则。</p><p>去挑战广场提交，然后进 CH-009：数据会换成「加密报文 + 每页都要过验证码」的那一套。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://jsnote.top/posts/ls-ch9/</id>
      <title type="text">Python 爬虫验证码实战：AES 加密报文 + 每页算术验证码（CH-009）</title>
      <published>2026-10-02T10:46:38.000Z</published>
      <updated>2026-10-02T10:46:38.000Z</updated>
      <author><name>xiajiao</name></author>
      <link rel="alternate" href="https://jsnote.top/posts/ls-ch9/"/>
      <summary type="text">本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了：请求/响应都是 AES-ECB 加密的 hex，还要带一个前端写死的 Adam-AppKey，而且每翻一页都得先过一道算术验证码。目标是取 10 页…</summary>
      <content type="html"><![CDATA[<blockquote><p>本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了：请求/响应都是 <strong>AES-ECB 加密的 hex</strong>，还要带一个前端写死的 <code>Adam-AppKey</code>，而且<strong>每翻一页都得先过一道算术验证码</strong>。目标是取 10 页共 100 条备案公告，把每条批复文号最后一段数字加起来。</p></blockquote>
<section><h2>现象：报文是「一段十六进制」<a href="#现象报文是一段十六进制"><span>#</span></a></h2><p>靶场页 <code>/projects?challenge=CH-009</code>，点页码会弹出验证码窗。F12 → Network 里最值得看的是两个请求：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>POST /api/challenges/CH-009/captcha</span></div></div><div><div><div>2</div></div><div><span>Content-Type: application/json;charset=utf-8</span></div></div><div><div><div>3</div></div><div><span>Adam-AppKey: 3b08c9a1-924b-3876-9129-cca5976edfa9</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>&lt;一段十六进制字符串&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应也不是 JSON，而是<strong>另一段十六进制</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>4f 2a 1b ... (hex)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>用 <code>JSON.parse</code> 直接解析当然失败 —— 因为它是 AES 密文。这一关的代码里只要有一处顺序写错，你看到的就是「一堆乱码」。</p></section>
<section><h2>三件事要同时做对<a href="#三件事要同时做对"><span>#</span></a></h2>

<table><thead><tr><th>环节</th><th>做法</th></tr></thead><tbody><tr><td>报文加解密</td><td>AES-ECB + PKCS7，key = <code>Nr2bNQtm5o9A0qqz</code>（前端 JS 里）；<strong>请求体 = <code>AES(JSON).hex</code>，响应体也是 hex</strong></td></tr><tr><td>访问凭证</td><td>请求头 <code>Adam-AppKey: 3b08c9a1-924b-3876-9129-cca5976edfa9</code>，不带就 403</td></tr><tr><td>验证码</td><td><strong>每页一张、用后即焚</strong>：先 POST <code>/captcha</code> 拿图，算出 <code>8+2=?</code>，再把 <code>captchaId</code> + <code>yzm</code> 一起放进列表请求的参数里</td></tr></tbody></table><p>解出来的结构（和真实站点一致）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"code"</span><span>: </span><span>"0"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"data"</span><span>: {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"list"</span><span>: [</span></div></div><div><div><div>5</div></div><div><span><span>      </span></span><span>{</span><span>"projectName"</span><span>: </span><span>"六安特安新能源…光伏电站项目"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>       </span><span>"projectCode"</span><span>: </span><span>"2610-341504-04-01-309092"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>       </span><span>"itemName"</span><span>: </span><span>"固定资产投资项目备案（内资项目）"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>       </span><span>"unitName"</span><span>: </span><span>"六安市叶集区发展和改革委员会…"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>       </span><span>"approveResultName"</span><span>: </span><span>"通过"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>       </span><span>"itemHandleTime"</span><span>: </span><span>"2026/10/01 11:01:15"</span><span>}</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>],</span></div></div><div><div><div>12</div></div><div><span>    </span><span>"pageNumber"</span><span>: </span><span>1</span><span>, </span><span>"pageSize"</span><span>: </span><span>10</span><span>,</span></div></div><div><div><div>13</div></div><div><span>    </span><span>"totalPage"</span><span>: </span><span>10</span><span>, </span><span>"totalRow"</span><span>: </span><span>100</span><span>,</span></div></div><div><div><div>14</div></div><div><span>    </span><span>"firstPage"</span><span>: </span><span>true</span><span>, </span><span>"lastPage"</span><span>: </span><span>false</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>16</div></div><div><span>  </span><span>"message"</span><span>: </span><span>"查询成功"</span><span>,</span></div></div><div><div><div>17</div></div><div><span>  </span><span>"status"</span><span>: </span><span>200</span></div></div><div><div><div>18</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>出错时 <code>code</code> 不是 <code>"0"</code>，例如验证码不对：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"code"</span><span>: </span><span>"1"</span><span>, </span><span>"data"</span><span>: </span><span>null</span><span>, </span><span>"message"</span><span>: </span><span>"验证码错误或已过期"</span><span>, </span><span>"status"</span><span>: </span><span>200</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>验证码这一步最容易踩的坑<a href="#验证码这一步最容易踩的坑"><span>#</span></a></h2><ol>
<li><strong>每页一张、一次性的</strong>。想「先囤 10 张验证码再逐页用」不行 —— 每张用完即废。</li>
<li><strong>识别算术题要宽容</strong>。<code>8+2=?</code> 里的 <code>?</code> 经常被 OCR 认成 <code>9</code>（识别成 <code>8+2=9</code>），所以要用正则取<strong>前面那一段</strong>「数字 + 运算符 + 数字」：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>re.</span><span>search</span><span>(</span></span><span>r</span><span><span>"</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>\s</span></span><span>*</span><span>(</span><span>[</span><span>+</span><span>\-</span><span>*/</span><span>]</span><span><span>)</span><span>\s</span></span><span>*</span><span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>"</span></span><span>, normalised)   </span><span># 命中 8+2，忽略后面的噪声</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>认不出来就换一张重来</strong>，不要拿着一张坏图硬猜（会对不上，而且一直卡在同一张上）。</li>
</ol></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"""CH-009：答案 = 10 页共 100 条批复文号「最后一段数字」之和</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>三道坎：</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>1. 报文是 AES-ECB(PKCS7) 加密后的 hex：请求体自己加密，响应体自己解</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>2. 请求头要带 Adam-AppKey</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>3. 每翻一页都得先解一道算术验证码（一次性）</span></div></div><div><div><div>7</div></div><div><span>"""</span></div></div><div><div><div>8</div></div><div><span>import</span><span> base64</span></div></div><div><div><div>9</div></div><div><span>import</span><span> json</span></div></div><div><div><div>10</div></div><div><span>import</span><span> re</span></div></div><div><div><div>11</div></div><div><span>import</span><span> time</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>import</span><span> requests</span></div></div><div><div><div>14</div></div><div><span>from</span><span> Crypto.Cipher </span><span>import</span><span><span> </span><span>AES</span></span></div></div><div><div><div>15</div></div><div><span>from</span><span> Crypto.Util.Padding </span><span>import</span><span> pad, unpad</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>BASE</span><span> </span><span>=</span><span> </span></span><span>"https://spider.jsnote.top"</span></div></div><div><div><div>18</div></div><div><span><span>CAPTCHA_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-009/captcha"</span></div></div><div><div><div>19</div></div><div><span><span>LIST_URL</span><span> </span><span>=</span><span> </span></span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/api/challenges/CH-009/list"</span></div></div><div><div><div>20</div></div><div><span><span>APP_KEY</span><span> </span><span>=</span><span> </span></span><span>"3b08c9a1-924b-3876-9129-cca5976edfa9"</span><span>     </span><span># 前端写死的</span></div></div><div><div><div>21</div></div><div><span><span>AES_KEY</span><span> </span><span>=</span><span> </span></span><span>b</span><span>"Nr2bNQtm5o9A0qqz"</span><span>                        </span><span># 前端写死的</span></div></div><div><div><div>22</div></div><div><span><span>PAGE_SIZE</span><span> </span><span>=</span><span> </span></span><span>10</span></div></div><div><div><div>23</div></div><div><span><span>MAX_PAGES</span><span> </span><span>=</span><span> </span></span><span>10</span></div></div><div><div><div>24</div></div><div><span><span>MAX_RETRY</span><span> </span><span>=</span><span> </span></span><span>6</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>def</span><span> </span><span>encrypt</span><span>(</span><span>payload</span><span>:</span><span> </span><span>dict</span><span>) -&gt; </span><span>str</span><span>:</span></div></div><div><div><div>28</div></div><div><span>    </span><span>"""JSON -&gt; AES-ECB(PKCS7) -&gt; hex 文本（body 里放的就是这段 hex，不是 JSON）"""</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>raw </span><span>=</span><span> json.</span><span>dumps</span><span>(payload, </span><span>ensure_ascii</span><span>=</span><span>False</span><span>, </span><span>separators</span><span><span>=</span><span>(</span></span><span>","</span><span>, </span><span>":"</span><span><span>)).</span><span>encode</span><span>(</span></span><span>"utf-8"</span><span>)</span></div></div><div><div><div>30</div></div><div><span>    </span><span>return</span><span><span> </span><span>AES</span><span>.</span><span>new</span><span>(</span><span>AES_KEY</span><span>, </span><span>AES</span><span>.</span><span>MODE_ECB</span><span>).</span><span>encrypt</span><span>(</span><span>pad</span><span>(raw, </span><span>AES</span><span>.block_size)).</span><span>hex</span><span>()</span></span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>def</span><span> </span><span>decrypt</span><span>(</span><span>hex_text</span><span>:</span><span> </span><span>str</span><span>) -&gt; </span><span>dict</span><span>:</span></div></div><div><div><div>34</div></div><div><span>    </span><span>"""hex 文本 -&gt; AES-ECB -&gt; JSON"""</span></div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>clean </span><span>=</span><span> re.</span><span>sub</span><span>(</span><span>r</span><span>"\s</span><span>+</span><span>"</span><span>, </span><span>""</span><span>, </span><span>str</span><span>(hex_text))</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>plain </span><span>=</span><span> </span><span>unpad</span><span>(</span><span>AES</span><span>.</span><span>new</span><span>(</span><span>AES_KEY</span><span>, </span><span>AES</span><span>.</span><span>MODE_ECB</span><span>).</span><span>decrypt</span><span>(</span><span>bytes</span><span><span>.</span><span>fromhex</span><span>(clean)),</span></span></div></div><div><div><div>37</div></div><div><span><span>                  </span></span><span>AES</span><span>.block_size)</span></div></div><div><div><div>38</div></div><div><span>    </span><span>return</span><span><span> json.</span><span>loads</span><span>(plain.</span><span>decode</span><span>(</span></span><span>"utf-8"</span><span>))</span></div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span><span>session </span><span>=</span><span> requests.</span><span>Session</span><span>()</span></span></div></div><div><div><div>42</div></div><div><span><span>session.headers.</span><span>update</span><span>({</span></span></div></div><div><div><div>43</div></div><div><span>    </span><span>"Accept"</span><span>: </span><span>"*/*"</span><span>,</span></div></div><div><div><div>44</div></div><div><span>    </span><span>"Adam-AppKey"</span><span><span>: </span><span>APP_KEY</span><span>,</span></span></div></div><div><div><div>45</div></div><div><span>    </span><span>"Content-Type"</span><span>: </span><span>"application/json;charset=utf-8"</span><span>,</span></div></div><div><div><div>46</div></div><div><span>    </span><span>"Referer"</span><span>: </span><span>f</span><span>"</span><span>{</span><span>BASE</span><span>}</span><span>/projects?challenge=CH-009"</span><span>,</span></div></div><div><div><div>47</div></div><div><span>    </span><span>"User-Agent"</span><span>: </span><span>"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36"</span><span>,</span></div></div><div><div><div>48</div></div><div><span>    </span><span>"X-Requested-With"</span><span>: </span><span>"XMLHttpRequest"</span><span>,</span></div></div><div><div><div>49</div></div><div><span>})</span></div></div><div><div><div>50</div></div><div><span><span>session.cookies.</span><span>set</span><span>(</span></span><span>"session"</span><span>, </span><span>"&lt;把浏览器里的 session cookie 粘到这里&gt;"</span><span>,</span></div></div><div><div><div>51</div></div><div><span>                    </span><span>domain</span><span>=</span><span>"spider.jsnote.top"</span><span>)</span></div></div><div><div><div>52</div></div><div>
</div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div><span>def</span><span> </span><span>call</span><span>(</span><span>url</span><span>,</span><span><span> </span><span>payload</span></span><span>) -&gt; </span><span>dict</span><span>:</span></div></div><div><div><div>55</div></div><div><span><span>    </span></span><span>response </span><span>=</span><span> session.</span><span>post</span><span>(url, </span><span>data</span><span><span>=</span><span>encrypt</span><span>(payload), </span></span><span>timeout</span><span>=</span><span>20</span><span>)</span></div></div><div><div><div>56</div></div><div><span>    </span><span>if</span><span><span> response.status_code </span><span>==</span><span> </span></span><span>401</span><span>:</span></div></div><div><div><div>57</div></div><div><span>        </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>"401：需要登录态（Cookie 没填对？）"</span><span>)</span></div></div><div><div><div>58</div></div><div><span>    </span><span>if</span><span> </span><span>not</span><span> response.ok:</span></div></div><div><div><div>59</div></div><div><span>        </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>f</span><span>"</span><span>{</span><span>response.status_code</span><span>}</span><span>：</span><span>{</span><span>response.text[:</span><span>200</span><span>]</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>60</div></div><div><span>    </span><span>return</span><span><span> </span><span>decrypt</span><span>(response.text)</span></span></div></div><div><div><div>61</div></div><div>
</div></div><div><div><div>62</div></div><div>
</div></div><div><div><div>63</div></div><div><span>def</span><span> </span><span>solve_captcha</span><span>(</span><span>image_bytes</span><span>:</span><span> </span><span>bytes</span><span>) -&gt; </span><span>int</span><span>:</span></div></div><div><div><div>64</div></div><div><span>    </span><span>"""识别 `8+2=?` 并算答案。"""</span></div></div><div><div><div>65</div></div><div><span>    </span><span>import</span><span> ddddocr                                            </span><span># pip install ddddocr</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span><span>    </span></span><span>text </span><span>=</span><span> ddddocr.</span><span>DdddOcr</span><span>(</span><span>show_ad</span><span>=</span><span>False</span><span><span>).</span><span>classification</span><span>(image_bytes)</span></span></div></div><div><div><div>68</div></div><div><span><span>    </span></span><span>normalised </span><span>=</span><span> (text.</span><span>replace</span><span>(</span><span>"×"</span><span>, </span><span>"*"</span><span><span>).</span><span>replace</span><span>(</span></span><span>"✕"</span><span>, </span><span>"*"</span><span><span>).</span><span>replace</span><span>(</span></span><span>"╳"</span><span>, </span><span>"*"</span><span>)</span></div></div><div><div><div>69</div></div><div><span><span>                      </span></span><span>.</span><span>replace</span><span>(</span><span>"x"</span><span>, </span><span>"*"</span><span><span>).</span><span>replace</span><span>(</span></span><span>"X"</span><span>, </span><span>"*"</span><span>)</span></div></div><div><div><div>70</div></div><div><span><span>                      </span></span><span>.</span><span>replace</span><span>(</span><span>"÷"</span><span>, </span><span>"/"</span><span><span>).</span><span>replace</span><span>(</span></span><span>"／"</span><span>, </span><span>"/"</span><span>))</span></div></div><div><div><div>71</div></div><div><span><span>    </span></span><span>match </span><span>=</span><span> re.</span><span>search</span><span>(</span><span>r</span><span><span>"</span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>\s</span></span><span>*</span><span>(</span><span>[</span><span>+</span><span>\-</span><span>*/</span><span>]</span><span><span>)</span><span>\s</span></span><span>*</span><span><span>(</span><span>\d</span></span><span>+</span><span><span>)</span><span>"</span></span><span>, normalised)</span></div></div><div><div><div>72</div></div><div><span>    </span><span>if</span><span> </span><span>not</span><span> match:</span></div></div><div><div><div>73</div></div><div><span>        </span><span>raise</span><span> </span><span>ValueError</span><span>(</span><span>f</span><span>"验证码没认出来：</span><span>{</span><span>text</span><span>!r</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>74</div></div><div><span><span>    </span></span><span>left, operator, right </span><span>=</span><span> </span><span>int</span><span><span>(match.</span><span>group</span><span>(</span></span><span>1</span><span><span>)), match.</span><span>group</span><span>(</span></span><span>2</span><span>), </span><span>int</span><span><span>(match.</span><span>group</span><span>(</span></span><span>3</span><span>))</span></div></div><div><div><div>75</div></div><div><span>    </span><span>return</span><span> {</span><span>"+"</span><span><span>: left </span><span>+</span><span> right, </span></span><span>"-"</span><span><span>: left </span><span>-</span><span> right, </span></span><span>"*"</span><span><span>: left </span><span>*</span><span> right}.</span><span>get</span><span>(operator, left </span><span>//</span><span> right)</span></span></div></div><div><div><div>76</div></div><div>
</div></div><div><div><div>77</div></div><div>
</div></div><div><div><div>78</div></div><div><span>def</span><span> </span><span>fetch_page</span><span>(</span><span>page_number</span><span>:</span><span> </span><span>int</span><span>) -&gt; </span><span>dict</span><span>:</span></div></div><div><div><div>79</div></div><div><span>    </span><span>"""取一页：每页都要先拿一张新验证码；识别失败或答错都换一张重来。"""</span></div></div><div><div><div>80</div></div><div><span><span>    </span></span><span>last_message </span><span>=</span><span> </span><span>""</span></div></div><div><div><div>81</div></div><div><span>    </span><span>for</span><span> _attempt </span><span>in</span><span> </span><span>range</span><span><span>(</span><span>MAX_RETRY</span><span>):</span></span></div></div><div><div><div>82</div></div><div><span><span>        </span></span><span>captcha </span><span>=</span><span> </span><span>call</span><span>(</span><span>CAPTCHA_URL</span><span>, {</span><span>"ajaxRequestTimestamp"</span><span>: </span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>)})</span></div></div><div><div><div>83</div></div><div><span><span>        </span></span><span>image_base64 </span><span>=</span><span> </span><span>str</span><span>(captcha[</span><span>"data"</span><span>][</span><span>"captchaImage"</span><span><span>]).</span><span>split</span><span>(</span></span><span>","</span><span><span>)[</span><span>-</span></span><span>1</span><span>]</span></div></div><div><div><div>84</div></div><div>
</div></div><div><div><div>85</div></div><div><span>        </span><span>try</span><span>:</span></div></div><div><div><div>86</div></div><div><span><span>            </span></span><span>answer </span><span>=</span><span> </span><span>solve_captcha</span><span>(base64.</span><span>b64decode</span><span>(image_base64))</span></div></div><div><div><div>87</div></div><div><span>        </span><span>except</span><span> (</span><span>ValueError</span><span>, </span><span>ImportError</span><span>) </span><span>as</span><span> error:</span></div></div><div><div><div>88</div></div><div><span><span>            </span></span><span>last_message </span><span>=</span><span> </span><span>str</span><span>(error)</span></div></div><div><div><div>89</div></div><div><span><span>            </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)</span></div></div><div><div><div>90</div></div><div><span>            </span><span>continue</span></div></div><div><div><div>91</div></div><div>
</div></div><div><div><div>92</div></div><div><span><span>        </span></span><span>data </span><span>=</span><span> </span><span>call</span><span>(</span><span>LIST_URL</span><span>, {</span></div></div><div><div><div>93</div></div><div><span>            </span><span>"ajaxRequestTimestamp"</span><span>: </span><span>int</span><span><span>(time.</span><span>time</span><span>() </span><span>*</span><span> </span></span><span>1000</span><span>),</span></div></div><div><div><div>94</div></div><div><span>            </span><span>"pageSize"</span><span><span>: </span><span>PAGE_SIZE</span><span>,</span></span></div></div><div><div><div>95</div></div><div><span>            </span><span>"pageNumber"</span><span>: page_number,</span></div></div><div><div><div>96</div></div><div><span>            </span><span>"nameOrCode"</span><span>: </span><span>""</span><span>,</span></div></div><div><div><div>97</div></div><div><span>            </span><span>"queryType"</span><span>: </span><span>"first"</span><span>,</span></div></div><div><div><div>98</div></div><div><span>            </span><span>"selectType"</span><span>: </span><span>"pname"</span><span>,</span></div></div><div><div><div>99</div></div><div><span>            </span><span>"captchaId"</span><span>: captcha[</span><span>"data"</span><span>][</span><span>"captchaId"</span><span>],</span></div></div><div><div><div>100</div></div><div><span>            </span><span>"yzm"</span><span>: </span><span>str</span><span>(answer),</span></div></div><div><div><div>101</div></div><div><span><span>        </span></span><span>})</span></div></div><div><div><div>102</div></div><div><span>        </span><span>if</span><span><span> data.</span><span>get</span><span>(</span></span><span>"code"</span><span><span>) </span><span>==</span><span> </span></span><span>"0"</span><span>:</span></div></div><div><div><div>103</div></div><div><span>            </span><span>return</span><span> data[</span><span>"data"</span><span>]</span></div></div><div><div><div>104</div></div><div>
</div></div><div><div><div>105</div></div><div><span><span>        </span></span><span>last_message </span><span>=</span><span> </span><span>f</span><span>"</span><span>{</span><span><span>data.</span><span>get</span><span>(</span></span><span>'code'</span><span>)</span><span>}</span><span> </span><span>{</span><span><span>data.</span><span>get</span><span>(</span></span><span>'message'</span><span>)</span><span>}</span><span>"</span></div></div><div><div><div>106</div></div><div><span><span>        </span></span><span>time.</span><span>sleep</span><span>(</span><span>0.5</span><span>)</span></div></div><div><div><div>107</div></div><div>
</div></div><div><div><div>108</div></div><div><span>    </span><span>raise</span><span> </span><span>SystemExit</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page_number</span><span>}</span><span> 页连续 </span><span>{</span><span>MAX_RETRY</span><span>}</span><span> 次都没过：</span><span>{</span><span>last_message</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>109</div></div><div>
</div></div><div><div><div>110</div></div><div>
</div></div><div><div><div>111</div></div><div><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>112</div></div><div><span><span>    </span></span><span>total </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>113</div></div><div><span><span>    </span></span><span>rows_seen </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>114</div></div><div><span><span>    </span></span><span>total_page </span><span>=</span><span> </span><span>0</span></div></div><div><div><div>115</div></div><div>
</div></div><div><div><div>116</div></div><div><span>    </span><span>for</span><span> page_number </span><span>in</span><span> </span><span>range</span><span>(</span><span>1</span><span><span>, </span><span>MAX_PAGES</span><span> </span><span>+</span><span> </span></span><span>1</span><span>):</span></div></div><div><div><div>117</div></div><div><span><span>        </span></span><span>page </span><span>=</span><span> </span><span>fetch_page</span><span>(page_number)</span></div></div><div><div><div>118</div></div><div><span><span>        </span></span><span>total_page </span><span>=</span><span> </span><span>int</span><span><span>(page.</span><span>get</span><span>(</span></span><span>"totalPage"</span><span>) </span><span>or</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>119</div></div><div>
</div></div><div><div><div>120</div></div><div><span>        </span><span>for</span><span> row </span><span>in</span><span><span> page.</span><span>get</span><span>(</span></span><span>"list"</span><span>, []):</span></div></div><div><div><div>121</div></div><div><span><span>            </span></span><span>tail </span><span>=</span><span> </span><span>str</span><span>(row[</span><span>"projectCode"</span><span><span>]).</span><span>rsplit</span><span>(</span></span><span>"-"</span><span>, </span><span>1</span><span><span>)[</span><span>-</span></span><span>1</span><span>]      </span><span># 2610-341504-04-01-429215 -&gt; 429215</span></div></div><div><div><div>122</div></div><div><span>            </span><span>if</span><span><span> tail.</span><span>isdigit</span><span>():</span></span></div></div><div><div><div>123</div></div><div><span><span>                </span></span><span>total </span><span>+=</span><span> </span><span>int</span><span>(tail)</span></div></div><div><div><div>124</div></div><div><span><span>                </span></span><span>rows_seen </span><span>+=</span><span> </span><span>1</span></div></div><div><div><div>125</div></div><div>
</div></div><div><div><div>126</div></div><div><span>        </span><span>print</span><span>(</span><span>f</span><span>"第 </span><span>{</span><span>page_number</span><span>}</span><span>/</span><span>{</span><span>total_page </span><span>or</span><span> </span><span>'?'</span><span>}</span><span> 页：</span><span>{</span><span>len</span><span><span>(page.</span><span>get</span><span>(</span></span><span>'list'</span><span>, []))</span><span>}</span><span> 条，"</span></div></div><div><div><div>127</div></div><div><span>              </span><span>f</span><span>"累计 </span><span>{</span><span>rows_seen</span><span>}</span><span> 条，小计 </span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>128</div></div><div>
</div></div><div><div><div>129</div></div><div><span>        </span><span>if</span><span><span> page.</span><span>get</span><span>(</span></span><span>"lastPage"</span><span>) </span><span>or</span><span><span> page_number </span><span>&gt;=</span><span> (total_page </span></span><span>or</span><span><span> </span><span>MAX_PAGES</span><span>):</span></span></div></div><div><div><div>130</div></div><div><span>            </span><span>break</span></div></div><div><div><div>131</div></div><div>
</div></div><div><div><div>132</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>\n</span><span>共 </span><span>{</span><span>rows_seen</span><span>}</span><span> 条，批复文号末段数字之和：</span><span>{</span><span>total</span><span>}</span><span>"</span><span>)</span></div></div><div><div><div>133</div></div><div>
</div></div><div><div><div>134</div></div><div>
</div></div><div><div><div>135</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>136</div></div><div><span><span>    </span></span><span>main</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>参考实现见 <code>参考代码/s11.py</code>；站点原始前端 JS（AES 那一段）存了一份 <code>参考代码/s9.js</code>。</p></section>
<section><h2>排查清单<a href="#排查清单"><span>#</span></a></h2><ol>
<li>响应不是 JSON → 先看<strong>响应体是不是 hex</strong>，是就说明要解密（别急着查编码）。</li>
<li>解密失败 → 检查 key 是不是前端写死的那个（在 bundle 里搜 <code>AES</code>、<code>ECB</code>、<code>Pkcs7</code>）。</li>
<li>403 且什么都不说 → 先看有没有带 <code>Adam-AppKey</code>。</li>
<li>携带 <code>Adam-AppKey</code> 之后响应变成 hex、不带你看到明文 JSON —— 说明这个头就是「加密模式」的开关（真实站点也这样）。</li>
<li>验证码：<strong>每页取一张</strong>；拿到图先算答案，再和 <code>captchaId</code> 一起提交。</li>
<li>OCR 认不全 → 用正则取「数字 + 运算符 + 数字」，别要求整串完全匹配。</li>
<li>识别失败/答错 → 换一张重来（写重试循环，别死磕一张图）。</li>
<li>翻页结束条件用响应的 <code>lastPage</code> / <code>totalPage</code>。</li>
<li>末段数字可能不是数字（脏数据）→ 先 <code>isdigit()</code> 再累加。</li>
</ol></section>
<section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><section><h3>为什么请求体是 hex 文本，不是 JSON？<a href="#为什么请求体是-hex-文本不是-json"><span>#</span></a></h3><p>因为前端用 CryptoJS 加密后直接当 body 发了（<code>Content-Type</code> 还写着 <code>application/json;charset=utf-8</code>，但那只是外观）。你在浏览器 DevTools 的 Payload 里看到的就是那段 hex。</p></section><section><h3>只用 requests 能做验证码吗？<a href="#只用-requests-能做验证码吗"><span>#</span></a></h3><p>能。这一关不需要浏览器：验证码是<strong>算术题图片</strong>，用 <code>ddddocr</code> 就能识别。真实站点上的点选、滑块、行为验证码才会需要浏览器自动化。</p></section><section><h3>识别率不够高怎么办？<a href="#识别率不够高怎么办"><span>#</span></a></h3><p>三招：① 重试（最重要）；② 识别前对图片做放大/灰度化；③ 对答案做范围校验（比如结果不可能是负数或大于 100，落在这个范围外就换一张）。</p></section><section><h3>为什么不干脆用 Playwright 一页页点？<a href="#为什么不干脆用-playwright-一页页点"><span>#</span></a></h3><p>可以，但那会把「10 页 × 每题都要 OCR」的成本转移到浏览器上，而且慢。真实项目里更常见的是「请求级脚本 + OCR」，浏览器只用来观察。</p></section></section>
<section><h2>最后提醒<a href="#最后提醒"><span>#</span></a></h2><p>验证码设计的目的是把「人」和「脚本」分开。练习里我们训练识别能力，用它去理解「为什么单靠验证码挡不住有耐心的爬虫」；在真实站点上，请遵守对方的访问规则，控制频率，别做批量抓取。</p><p>去挑战广场提交，恭喜打通 9 关。</p></section>]]></content>
    </entry>
</feed>
