<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>反爬 on</title><link>/tags/%E5%8F%8D%E7%88%AC/</link><description>Recent content in 反爬 on</description><generator>Hugo -- gohugo.io</generator><lastBuildDate>Wed, 12 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="/tags/%E5%8F%8D%E7%88%AC/index.xml" rel="self" type="application/rss+xml"/><item><title>字体混淆逆向手记：不用 OCR 还原被替换的题干</title><link>/post/font-obfuscation-reverse/</link><pubDate>Wed, 12 Aug 2026 00:00:00 +0000</pubDate><guid>/post/font-obfuscation-reverse/</guid><description>&lt;p>一个把题干字符替换掉、再用私有字体把字形映射回去的反爬方案，以及在它身上踩到的六个「不报错、但数据是错的」的坑。最终不用 OCR、不识别字形轮廓，把自己账号下的 11 个题库共 803 道题完整导出。&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">题库&lt;/th>
&lt;th style="text-align:right">试题&lt;/th>
&lt;th style="text-align:right">题干字符&lt;/th>
&lt;th style="text-align:right">采集轮次&lt;/th>
&lt;th style="text-align:right">混淆字体&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">11&lt;/td>
&lt;td style="text-align:right">803&lt;/td>
&lt;td style="text-align:right">42,273&lt;/td>
&lt;td style="text-align:right">46&lt;/td>
&lt;td style="text-align:right">94&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>说明：本文只讨论技术思路，目标平台、接口地址、题库名称等均已隐去。导出的是自己账号下的数据，仅用于个人离线复习。&lt;/p>
&lt;/blockquote>
&lt;h2 id="01-目标与约束">&lt;a href="#01-%e7%9b%ae%e6%a0%87%e4%b8%8e%e7%ba%a6%e6%9d%9f" class="header-anchor">&lt;/a>01 目标与约束
&lt;/h2>&lt;p>目标是把自己账号下的题库导出成可离线复习的资料：&lt;strong>题干、选项、正确答案、解析、来源、难度&lt;/strong>，并标注&lt;strong>错题&lt;/strong>。约束有三条：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>必须走真实浏览器。&lt;/strong> &lt;code>curl&lt;/code> 直连被 WAF 拦下，返回一个非标准状态码。&lt;/li>
&lt;li>&lt;strong>必须复用已登录会话。&lt;/strong> 错题记录是账号私有数据，匿名访问看不到。&lt;/li>
&lt;li>&lt;strong>不能污染练习记录。&lt;/strong> 平台只在答过之后才显示答案，但「替自己作答以套出答案」会改写错题统计，不可接受。&lt;/li>
&lt;/ul>
&lt;p>最终方案是通过 Chrome DevTools Protocol（CDP）驱动本机那个已登录的 Chrome，全程只读。&lt;/p>
&lt;h2 id="02-接入层chrome-150-把远程调试收紧了">&lt;a href="#02-%e6%8e%a5%e5%85%a5%e5%b1%82chrome-150-%e6%8a%8a%e8%bf%9c%e7%a8%8b%e8%b0%83%e8%af%95%e6%94%b6%e7%b4%a7%e4%ba%86" class="header-anchor">&lt;/a>02 接入层：Chrome 150 把远程调试收紧了
&lt;/h2>&lt;h3 id="http-发现端点已关闭">&lt;a href="#http-%e5%8f%91%e7%8e%b0%e7%ab%af%e7%82%b9%e5%b7%b2%e5%85%b3%e9%97%ad" class="header-anchor">&lt;/a>HTTP 发现端点已关闭
&lt;/h3>&lt;p>传统做法是 &lt;code>GET /json/version&lt;/code> 拿 WebSocket 地址。在 Chrome 150 上，&lt;code>/json&lt;/code>、&lt;code>/json/list&lt;/code>、&lt;code>/json/version&lt;/code>、&lt;code>/devtools/browser&lt;/code> &lt;strong>一律返回 404&lt;/strong>（换 &lt;code>Host&lt;/code> 头也一样）。带 token 的 WebSocket 端点仍然可用，token 写在用户配置目录的 &lt;code>DevToolsActivePort&lt;/code> 文件里：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">~/Library/Application Support/Google/Chrome/DevToolsActivePort
9222
/devtools/browser/&amp;lt;每次启动随机生成的 UUID&amp;gt;
&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="本机代理会劫持回环请求">&lt;a href="#%e6%9c%ac%e6%9c%ba%e4%bb%a3%e7%90%86%e4%bc%9a%e5%8a%ab%e6%8c%81%e5%9b%9e%e7%8e%af%e8%af%b7%e6%b1%82" class="header-anchor">&lt;/a>本机代理会劫持回环请求
&lt;/h3>&lt;p>环境里设了 &lt;code>http_proxy=127.0.0.1:&amp;lt;port&amp;gt;&lt;/code>，&lt;code>curl&lt;/code> 会把发往 &lt;code>127.0.0.1:9222&lt;/code> 的请求也交给代理，得到一个与 Chrome 无关的 404。排查时这个假象很有迷惑性 —— 端口明明在 &lt;code>LISTEN&lt;/code>。加 &lt;code>--noproxy '*'&lt;/code> 才看到 Chrome 的真实响应。&lt;/p>
&lt;h3 id="每次连接都要授权且只允许一个客户端">&lt;a href="#%e6%af%8f%e6%ac%a1%e8%bf%9e%e6%8e%a5%e9%83%bd%e8%a6%81%e6%8e%88%e6%9d%83%e4%b8%94%e5%8f%aa%e5%85%81%e8%ae%b8%e4%b8%80%e4%b8%aa%e5%ae%a2%e6%88%b7%e7%ab%af" class="header-anchor">&lt;/a>每次连接都要授权，且只允许一个客户端
&lt;/h3>&lt;p>Chrome 150 对每一条新建的 CDP 连接都会弹窗征求同意；browser 级端点同一时刻还&lt;strong>只接受一个客户端&lt;/strong>。最初的实现是「每个步骤起一个独立进程各连一次」，于是 11 个题库 × 多轮 × 多步骤 = 几十次弹窗，而且进程之间会互相把连接挤掉。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>改法：&lt;/strong> 改成单进程持有唯一一条连接跑完整批。整批只授权一次，也不再有争用。&lt;/p>
&lt;/blockquote>
&lt;h2 id="03-数据面测绘">&lt;a href="#03-%e6%95%b0%e6%8d%ae%e9%9d%a2%e6%b5%8b%e7%bb%98" class="header-anchor">&lt;/a>03 数据面测绘
&lt;/h2>&lt;p>先用 CDP 的 &lt;code>Network&lt;/code> 域把练习页加载时的请求全部录下来，逐个看返回。结论分三类：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>接口（已脱敏）&lt;/th>
&lt;th>内容&lt;/th>
&lt;th>状态&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>题库信息接口&lt;/td>
&lt;td>题库名、题目总数&lt;/td>
&lt;td>明文&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>作答记录接口&lt;/td>
&lt;td>每题作答记录（错误次数、最近一次结果）&lt;/td>
&lt;td>明文&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>预览接口&lt;/td>
&lt;td>&lt;strong>前 20 题的明文题干&lt;/strong>&lt;/td>
&lt;td>明文&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>题目顺序接口&lt;/td>
&lt;td>题目 ID 列表（题库存储顺序）&lt;/td>
&lt;td>明文，但顺序不可信&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>题目正文接口&lt;/td>
&lt;td>题目正文，10 题一批&lt;/td>
&lt;td>加密&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>正文接口返回密文，且&lt;strong>带签名校验&lt;/strong> —— 在页面里手动 &lt;code>fetch&lt;/code> 复现会被「数据校验失败」拒绝。所以整套方案不重放任何接口，一律&lt;strong>被动截获页面自己发出的请求&lt;/strong>，正文则从渲染后的 DOM 取。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>关键发现：只有题干被混淆。&lt;/strong> 选项、正确答案、解析、来源、难度用的都是系统字体，DOM 里就是真值，不需要任何处理。需要解码的只有题干这一个字段。错题判定用「错误次数 &amp;gt; 0」，实测数量与站点「我的错题」完全一致。&lt;/p>
&lt;/blockquote>
&lt;h2 id="04-反爬机制私有字体做字符替换">&lt;a href="#04-%e5%8f%8d%e7%88%ac%e6%9c%ba%e5%88%b6%e7%a7%81%e6%9c%89%e5%ad%97%e4%bd%93%e5%81%9a%e5%ad%97%e7%ac%a6%e6%9b%bf%e6%8d%a2" class="header-anchor">&lt;/a>04 反爬机制：私有字体做字符替换
&lt;/h2>&lt;p>题干元素上挂着一个哈希命名的 &lt;code>font-family&lt;/code>（形如 &lt;code>k&lt;/code> + 32 位十六进制）。站点把题干里的部分字符替换成别的字，再用这套私有字体把字形映射回真字。&lt;strong>眼睛看到的是对的，抓到的是错的。&lt;/strong>&lt;/p>
&lt;p>同一道题在两次会话里抓到的 DOM 文本并不相同 —— 每次会话换一套字体，替换的字也随之变化（下例为示意）：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>文本&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>会话 A&lt;/td>
&lt;td>依据相关&lt;strong>宫&lt;/strong>程，封闭式设备应装设状态显示装置。&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>会话 B&lt;/td>
&lt;td>依&lt;strong>戏&lt;/strong>相关规程，封闭&lt;strong>舞&lt;/strong>设备应装设状态显示装置。&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>真值&lt;/td>
&lt;td>依&lt;strong>据&lt;/strong>相关&lt;strong>规&lt;/strong>程，封闭&lt;strong>式&lt;/strong>设备应装设状态显示装置。&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>字体本身是一个&lt;strong>常见中文字体的 200 字子集&lt;/strong>。它的 cmap 有很强的误导性：某个码位（比如「宫」）对应的字形名就叫 &lt;code>uniXXXX&lt;/code>，但那个字形里装的其实是另一个字的轮廓。&lt;strong>光看字形名会被带偏&lt;/strong>，真正的映射藏在轮廓里。&lt;/p>
&lt;p>走到这一步，直觉的解法是识别字形轮廓，或者截图 OCR。两条路都不需要。&lt;/p>
&lt;h2 id="05-解码原理一条等价关系加上多轮取交集">&lt;a href="#05-%e8%a7%a3%e7%a0%81%e5%8e%9f%e7%90%86%e4%b8%80%e6%9d%a1%e7%ad%89%e4%bb%b7%e5%85%b3%e7%b3%bb%e5%8a%a0%e4%b8%8a%e5%a4%9a%e8%bd%ae%e5%8f%96%e4%ba%a4%e9%9b%86" class="header-anchor">&lt;/a>05 解码原理：一条等价关系，加上多轮取交集
&lt;/h2>&lt;p>用站点自己泄露的 20 条明文题干（预览接口）和对应的混淆文本逐字对齐，可以直接读出这套字体的替换映射。第一次做这个对齐时，20 条&lt;strong>长度全部一致、零冲突&lt;/strong>，说明替换是稳定的 1:1 码位映射。更重要的是，比对替换位置与字体 cmap 之后，浮现出一条等价关系：&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>字体 cmap 里的码位 ⟺ 被替换的字&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>也就是说，cmap 收录了哪 200 个码位，哪些位置就被动了手脚；反过来，&lt;strong>不在 cmap 里的字符一定是真字&lt;/strong>（它由系统字体渲染，站点根本没碰）。于是解码只需两条规则：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>规则 1&lt;/strong>：字符码位不在本题字体的 cmap 里 → 就是真字，直接采用。&lt;/li>
&lt;li>&lt;strong>规则 2&lt;/strong>：在 cmap 里 → 查锚点学到的映射，命中即真字。&lt;/li>
&lt;li>在 cmap 里但查不到 → 留待下一轮，等字体轮换后再解。&lt;/li>
&lt;/ol>
&lt;p>每个字符独立判定，全程只用 cmap 成员关系与锚点映射表，不涉及字形轮廓。&lt;/p>
&lt;h3 id="多轮取交集">&lt;a href="#%e5%a4%9a%e8%bd%ae%e5%8f%96%e4%ba%a4%e9%9b%86" class="header-anchor">&lt;/a>多轮取交集
&lt;/h3>&lt;p>字体每十几分钟轮换一套，&lt;strong>每套征用的 200 个码位各不相同&lt;/strong>。所以同一个位置只要在任意一轮里落到 cmap 之外，真值就被确定了。多采几轮取交集，缺口收敛得很快 —— 第一个题库 4,821 个字符位置的收敛过程是：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">第 1 轮 未解析 76 个位置 (1.58%) 完整题干 54/99
第 2 轮 未解析 14 个位置 (0.29%) 完整题干 87/99
第 3 轮 未解析 0 个位置 (0.00%) 完整题干 99/99
&lt;/code>&lt;/pre>&lt;/div>&lt;p>全部 11 个题库共采集 46 轮、涉及 94 套字体，最终全部收敛到 0。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>为什么没用 OCR：&lt;/strong> OCR 需要为技术术语的识别错误兜底，且无法自证正确。cmap 路线是确定性的：每个字符要么由「不在 cmap」直接判定，要么由锚点映射查表得出，两者都可以逐字回验。&lt;/p>
&lt;/blockquote>
&lt;h2 id="06-六个静默错误">&lt;a href="#06-%e5%85%ad%e4%b8%aa%e9%9d%99%e9%bb%98%e9%94%99%e8%af%af" class="header-anchor">&lt;/a>06 六个静默错误
&lt;/h2>&lt;p>这是整个项目里最花时间的部分。它们全都不抛异常，全都会产出看起来正常、实际是错的数据。按发现顺序：&lt;/p>
&lt;p>&lt;strong>1. 遍历途中字体轮换&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：前 20 题正确，之后大面积错字，但校验全过。&lt;/li>
&lt;li>根因：只在开头读一次 &lt;code>font-family&lt;/code>。遍历 100 多题要 4 分钟，期间字体换了，后半段用错了 cmap。&lt;/li>
&lt;li>修法：逐题记录字体，解码时按题取对应 cmap。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>2. 把存储顺序当成出题顺序&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：部分题库锚点对不上，题干与题目错配。&lt;/li>
&lt;li>根因：题目顺序接口返回的是题库存储顺序，与练习页实际出题顺序不一定一致。&lt;/li>
&lt;li>影响：错题标记和答案会挂到&lt;strong>别的题&lt;/strong>上。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>3. 改用批次顺序，依然错位&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：整体错开一格：第 21 位上放着第 20 题。&lt;/li>
&lt;li>根因：改用正文接口的批次参数拼顺序，但这些请求会&lt;strong>预取、乱序&lt;/strong>。&lt;/li>
&lt;li>结论：站点给出的所有「顺序」都不可信，必须换一种对齐方式。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>4. 翻页抓到重复题&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：题数对得上，但内容有重复 —— 意味着等量的题被漏掉。&lt;/li>
&lt;li>根因：点「下一题」后只等&lt;strong>题号&lt;/strong>变化，而题号先更新、题干后渲染，固定延时不够时就把上一题的内容配上了新题号。&lt;/li>
&lt;li>影响：&lt;strong>11 个题库里 10 个中招&lt;/strong>，最多的一个重复 12 道。&lt;/li>
&lt;li>修法：改为等题干内容真正变化，且连续两次读取稳定后才提取。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>5. 校验本身存在覆盖盲区&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：「20 条锚点逐字一致」照样放行了错误数据。&lt;/li>
&lt;li>根因：明文锚点只覆盖前 20 题。若字体是在第 50 题处轮换的，校验全过，但后面全错。&lt;/li>
&lt;li>修法：不再靠校验去筛坏数据，改为&lt;strong>从源头拒收&lt;/strong>：缺逐题字体或缺内容指纹的旧数据一律弃用。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>6. HTML 实体未反转义&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>现象：某题库两条锚点始终定位失败。&lt;/li>
&lt;li>根因：预览接口返回的是 &lt;code>&amp;amp;quot;&lt;/code>，而 DOM 里是渲染后的 &lt;code>&amp;quot;&lt;/code>，长度对不上。&lt;/li>
&lt;li>修法：锚点先做 &lt;code>html.unescape&lt;/code>。修复后全部题库锚点定位率一致达到 20/20。&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>这些坑的共同点：&lt;/strong> 它们都不抛异常。程序跑完、数字对得上、抽查也看着正常 —— 错的是&lt;code>工程&lt;/code>被写成了&lt;code>工措&lt;/code>、&lt;code>终结&lt;/code>被写成了&lt;code>终应&lt;/code>这种肉眼几乎不会停顿的地方。在这类任务里，&lt;strong>「没报错」不构成任何证据&lt;/strong>。&lt;/p>
&lt;/blockquote>
&lt;h2 id="07-内容指纹对齐">&lt;a href="#07-%e5%86%85%e5%ae%b9%e6%8c%87%e7%ba%b9%e5%af%b9%e9%bd%90" class="header-anchor">&lt;/a>07 内容指纹对齐
&lt;/h2>&lt;p>坑 2 和坑 3 指向同一个结论：不能再用任何位置信息去对应题目。替代方案是用题目&lt;strong>自身的内容&lt;/strong>做标识：&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>指纹 = 题型 | 题干长度 | 正确答案 | 各选项文字&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>这几项都可靠：选项和答案是系统字体渲染的，没被混淆；题干长度在 1:1 替换下不变。各轮之间、以及轮次与主体数据之间，全部靠指纹配对。锚点则用「内容兼容性搜索」定位 —— 长度相同、且所有不在 cmap 的位置逐字相同，并要求唯一命中。&lt;/p>
&lt;p>指纹碰撞（例如选项相同、长度也相同的判断题）会被显式识别出来，按各轮内部次序配对，并最终由锚点回验兜底。&lt;/p>
&lt;h2 id="08-流水线">&lt;a href="#08-%e6%b5%81%e6%b0%b4%e7%ba%bf" class="header-anchor">&lt;/a>08 流水线
&lt;/h2>&lt;p>五步，其中第 3–4 步循环执行直到收敛：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>抓取主体&lt;/strong>：选项 / 答案 / 解析 / 错题&lt;/li>
&lt;li>&lt;strong>明文锚点&lt;/strong>：前 20 题真值&lt;/li>
&lt;li>&lt;strong>字体轮次&lt;/strong>：字体 + 混淆题干&lt;/li>
&lt;li>&lt;strong>解码校验&lt;/strong>：cmap + 锚点回验；仍有缺口 → 等字体轮换后回到第 3 步&lt;/li>
&lt;li>&lt;strong>导出&lt;/strong>：Markdown + JSON&lt;/li>
&lt;/ol>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>脚本&lt;/th>
&lt;th>职责&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>cdp.mjs&lt;/code>&lt;/td>
&lt;td>连接本机 Chrome，含 &lt;code>DevToolsActivePort&lt;/code> token 读取与连接重试&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>config.mjs&lt;/code>&lt;/td>
&lt;td>路径约定、URL 构造、逐题遍历脚本（含内容稳定判定）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>steps.mjs&lt;/code>&lt;/td>
&lt;td>三个采集步骤的实现，抽成函数以复用同一条连接&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch.mjs&lt;/code>&lt;/td>
&lt;td>批处理驱动：单连接、轮流给未完成题库各采一轮&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>decode.py&lt;/code>&lt;/td>
&lt;td>指纹对齐 → 学映射 → 假设检查 → 逐位解码 → 锚点回验&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>export.py&lt;/code>&lt;/td>
&lt;td>合并导出 Markdown 与 JSON&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>audit.py&lt;/code>&lt;/td>
&lt;td>交付前自检，把踩过的每类坑固化成检查项&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>为什么轮流采而不是逐个题库跑完：&lt;/strong> 字体是&lt;strong>按时间全局轮换&lt;/strong>的。逐个题库串行处理，会在每个题库上重复空等轮换；轮流给所有未完成题库各采一轮，采完一圈通常字体已经换新，几乎不浪费等待。&lt;/p>
&lt;/blockquote>
&lt;h2 id="09-三道校验闸门">&lt;a href="#09-%e4%b8%89%e9%81%93%e6%a0%a1%e9%aa%8c%e9%97%b8%e9%97%a8" class="header-anchor">&lt;/a>09 三道校验闸门
&lt;/h2>&lt;p>最终保障不是「我觉得对了」，而是每一步都能被证伪。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>G1 假设检查（每轮执行）&lt;/strong>：统计两类反例 —— 被替换却不在 cmap（推翻规则 1）、在 cmap 却没被替换（推翻等价关系）。任一出现即弃用该轮。11 个题库全程为 0。&lt;/li>
&lt;li>&lt;strong>G2 锚点回验（每次解码）&lt;/strong>：解码结果必须与站点自己泄露的 20 条明文&lt;strong>逐字一致&lt;/strong>，不一致直接退出、拒绝输出。这道闸门在过程中确实拦下过多次错误结果。&lt;/li>
&lt;li>&lt;strong>G3 交付自检（全量）&lt;/strong>：重复题、未还原字符、题干缺失、锚点回验命中率、指纹唯一性、字段完整性六项，任一不过即报错退出。&lt;/li>
&lt;/ul>
&lt;p>自检还区分了一类&lt;strong>不是缺陷的缺失&lt;/strong>：从未作答过的题，平台本就不显示答案。这类题在导出中明确标注，而不是伪装成有答案，也没有替自己作答去套。&lt;/p>
&lt;h2 id="10-结果">&lt;a href="#10-%e7%bb%93%e6%9e%9c" class="header-anchor">&lt;/a>10 结果
&lt;/h2>&lt;p>11 个题库全部通过自检，合计 &lt;strong>803 题&lt;/strong>，题型分布为单选 364、判断 243、多选 195、简答 1。自检六项全绿：重复题 0、未还原字符 0、题干缺失 0、锚点回验 11 × 20/20 全中。&lt;/p>
&lt;p>每个题库产出三个文件：&lt;code>_全部试题.md&lt;/code>、&lt;code>_错题集.md&lt;/code>、&lt;code>.json&lt;/code>。JSON 与 Markdown 同源同内容，每题带 &lt;code>stemSource&lt;/code> 标明题干来源（明文锚点 / 解码还原）。&lt;/p>
&lt;h2 id="11-复现">&lt;a href="#11-%e5%a4%8d%e7%8e%b0" class="header-anchor">&lt;/a>11 复现
&lt;/h2>&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-bash" data-lang="bash">&lt;span style="color:#75715e"># 前提：Chrome 已开启远程调试，且在该 Chrome 里已登录目标平台&lt;/span>
node tools/batch.mjs &lt;span style="color:#75715e"># 读「考试目录」，批量跑完全流程&lt;/span>
.venv/bin/python tools/audit.py &lt;span style="color:#75715e"># 交付前自检&lt;/span>
&lt;/code>&lt;/pre>&lt;/div>&lt;p>中间数据落在 &lt;code>data/&amp;lt;paperId&amp;gt;/&lt;/code>，重跑导出不需要重新抓站。数据带 &lt;code>schema&lt;/code> 版本号，旧格式会被自动识别并重抓。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>安全提醒：&lt;/strong> 远程调试端口对&lt;strong>本机任意程序&lt;/strong>开放浏览器完全控制权，包括 cookie 与已登录会话。用完请及时关闭。请只导出自己账号下的数据，并遵守目标平台的使用条款。&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;p>本文记录的是一次具体的逆向过程，站点实现随时可能变化。工具链里的三道校验闸门正是为此设计 —— 混淆方式一旦改变，脚本会报错退出，而不是悄悄输出错数据。&lt;/p></description></item></channel></rss>