☰
企业信息站点双反爬拆解:动态签名与字体映射实战
2026/10/26 4:03:26 网站建设 项目流程

1. 先把问题拆开:这个站点到底在防什么

前段时间帮朋友看一个企业信息查询类站点(下文统一叫它“目标站点”),需求很朴素:把公开的工商信息按关键词批量整理成表格,方便做行业调研。手动复制粘贴显然不现实,但真动手写脚本就发现,这个站点的前端比想象中“讲究”。直接requests请求接口,返回的是{"code": 403, "msg": "签名校验失败"};换成浏览器里复制出来的完整 header,能拿到 200,但返回的 JSON 里所有数字都变成了方块或者莫名其妙的字符。这就是典型的双保险:header 里有动态签名字段,响应体里叠了一层字体反爬。这两个点单独拿出来都不算难,难的是它俩耦合在同一条链路上——签名算错你连混淆后的数据都看不到,字体映射没做你拿到了数据也读不懂。

这篇东西记录的就是完整的拆解过程:怎么从一次抓包定位到 header 里的加密参数,怎么把混淆过的 JS 函数单独抽出来在 Node 里跑通,怎么解析 woff 字体文件把码位还原成真实数字,以及最后怎么把两条链路串成一个能稳定跑的小工具。文末附了关键环节的源码结构,都是可以直接拿去改的。适合谁看?写过一点 Python 爬虫、能看懂浏览器 DevTools、但对 JS 混淆代码没什么概念的同行。不需要你会逆向工程,但需要你愿意在 Sources 面板里泡上两三个小时。

先把丑话说在前面:这类站点通常会在用户协议里限制自动化访问,我的建议是只做个人学习、小规模、低频的公开数据整理,控制请求间隔,别做分布式并发,别碰任何需要登录态才能看到的非公开字段。技术本身是中性的,但用在哪儿是自己的选择。

1.1 从一次抓包看 header 里的异常参数

打开目标站点的搜索页,F12 切到 Network,筛选 XHR,输入一个关键词触发查询,你会看到一条形如/api/search/xxxx的请求。把这个请求的 Request Headers 完整复制下来,你会发现除了常规的User-Agent、Accept、Referer、Cookie之外,多出了两三个“看着就不像标准头”的字段。我这次遇到的字段名是随机字母组合,比如x-ca-key、x-ca-sign,值分别是一段 32 位十六进制串和一个 13 位时间戳。

判断哪些字段是“动态签名”有个很土但很好用的方法:同一个页面刷新两次,同一个接口请求两次,对比 header 的差异。如果某个字段每次都变,且长度固定、字符集固定(纯 hex 或者 base64 字符集),那基本可以确定它是签名。如果它还跟时间戳字段成对出现,那签名的输入里大概率包含了时间戳——这也解释了为什么你复制昨天的 header 今天用会失效。

再进一步,把这个字段的值拿去和请求体、URL 参数做比对,有时能直接看出规律。比如 URL 是/api/search?key=abc&page=1,签名值里如果出现了abc的 MD5 前几位,那说明它是把参数拼起来做的摘要。我这次的情况是:x-ca-sign= MD5(排序后的参数串 + 时间戳 + 一段固定盐),其中盐值被混淆在 JS 里,肉眼搜不到。这一步只能靠读代码。

另外提醒一句,Cookie 里往往也有和签名联动的字段,比如某个token是页面加载时由 JS 种下的。抓包的时候一定要把 Cookie 完整保存,很多人在这一步只复制了 header 忘了 Cookie,后面排查半天以为是签名算错,其实是会话标识缺失。

1.2 字体反爬的表现与判定方法

签名搞定之后拿到的 JSON 长这样(简化):

{"companyName": "某某科技有限公司", "regCapital": "1\uE3A2\uE3A2\uE3A2万元", "regDate": "2019-\uE12A\uE12B-\uE12C"}

字符串里混着一堆\uE3A2这种码位。这些码位全部落在 Unicode 的私有使用区(PUA,U+E000–U+F8FF),正常中文字体根本不会占用这块区域。它们不是乱码,而是站点自定义字体里给每个字形分配的“假码位”——浏览器加载了页面上@font-face引入的那个 woff 文件后,\uE3A2就会被渲染成数字8的样子。人眼看到的是 8,机器读到的是\uE3A2。

判定方法很简单:打开页面源代码(Ctrl+U 查看原始 HTML,不是 Elements 面板),搜@font-face,通常会看到一段src: url("data:application/font-woff;charset=utf-8;base64,d09GRg...")。把 base64 解码存成.woff文件,扔进字体查看器里看一眼就明白了——里面通常只有十几个甚至几个字形,全是数字 0-9 或者常用汉字。

这个方案的精妙之处在于它是动态的。我实测发现,同一个页面刷新几次,@font-face里的 base64 内容会变,也就是码位到字形的对应关系会重新洗牌。今天\uE3A2是 8,明天可能就是 3。所以你不能靠“抓一次映射表存死”的方式干活,必须做到每次拿到新字体都能自动重建映射。这决定了后面的技术选型。

1.3 技术路线的先后顺序与工具准备

顺序不能乱。先解决 header 签名,再解决字体映射。原因很实际:字体文件本身也是通过接口下发的,签名不对你连字体都拿不到。很多人一上手就去研究字体,结果卡在请求 403 上,白白浪费时间。

工具清单我列一下,都不复杂:

环节工具用途
抓包分析浏览器 DevTools看请求、下断点、调试 JS
JS 调试DevTools Sources + 本地覆盖断点、调用栈回溯、格式化混淆代码
代码运行Node.js 16+把扣出来的函数跑起来自测
字体解析Python + fontTools解析 woff/woff2,读 cmap 和 glyf
请求库Python requests / httpx发请求、管会话
辅助一个趁手的编辑器大量文本对比、正则替换

版本上有个小坑:woff2 用的是 Brotli 压缩,老版本 fontTools 解析会报错。建议pip install fonttools brotli一起装上,brotli这个包专门负责解 woff2 的压缩层。另外 Node 侧如果要跑加密函数,尽量用和你浏览器版本接近的 Node,某些混淆代码会检测navigator.userAgent或者process是否存在,环境差异会导致结果不一致。

准备工作还有一个容易忽略的点:在浏览器里开启“本地覆盖”(Local Overrides)或者用 Sources 里的 Pretty Print。目标站点的 JS 是压缩过的,一行几万字符,变量名全是_0x1a2b这种。格式化之后虽然还是难读,但至少能折叠函数块,配合断点看调用栈会舒服很多。

2. header 加密参数的定位、扣取与验证

这是整个项目里最耗时的部分。目标站点的核心 JS 大概有 800KB,里面混着打乱的字符串数组、十六进制转义、控制流平坦化(一堆switch-case把正常逻辑打散)。硬读是不现实的,得靠“执行 + 观察”的方式逼近真相。核心思路一句话:让浏览器自己告诉你答案——通过在关键 API 上下钩子,把加密函数的入参和出参打印出来,然后顺着调用栈往回找。

2.1 用 hook 和断点把加密入口逼出来

第一步是在页面加载之前注入 hook。DevTools 的 Sources → Snippets 可以存一段代码,或者在 Console 里直接跑(注意要在页面初始化前,最稳的办法是在 Network 面板勾选“停用缓存”,然后在目标请求上打 XHR 断点,页面一暂停就在 Console 里注入)。

最有效的 hook 对象是XMLHttpRequest.prototype.setRequestHeader,因为任何走 XHR 的请求,header 最终都要经过这个函数:

(function () { const raw = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (/sign|key|token|auth/i.test(k)) { console.log("[header]", k, "=", v); console.log(new Error().stack); } return raw.apply(this, arguments); }; })();

跑完之后触发一次搜索,Console 里会打出 header 的键值,紧跟一段调用栈。栈顶通常是框架的封装代码,往下翻几层,能看到一个名字类似_0x4f2a或者encrypt的函数——那就是加密入口。

如果站点用的是fetch,hook 方式换成包装window.fetch,在init.headers里做文章。还有一种更狠的情况:站点自己实现了XMLHttpRequest的替代品,或者用 WebAssembly 做加密。前者可以通过 hookwindow.XMLHttpRequest构造函数来定位,后者就得上 wasm 反编译工具了,不在这次的范围里。

调用栈拿到的函数名是混淆过的,怎么确认它就是加密函数?我的做法是打断点 + 改参数:在栈顶那个函数的第一行下断点,重新触发请求,暂停后在 Console 里读它的形参。如果形参里有明文的时间戳、URL 路径、参数对象,那基本就是了。再往下单步几步,看返回值是不是能对上刚才 hook 打印出来的 header 值,对上就锁定。

还有一个技巧很多人不知道:DevTools 的XHR/fetch Breakpoints(Sources 面板右侧)。直接按 URL 关键字下断点,请求发起前一定暂停,这时候Scope面板里的Local变量能看到当前作用域的所有变量,比慢慢找代码快得多。

2.2 从混淆代码里扣出可独立运行的函数

锁定入口之后,下一步是把这段代码“搬”出来单独跑。这里有三个层次的做法,按成本从低到高排:

第一种是整体导出。把整个 JS 文件下载下来,用 Node 直接require或vm.runInContext跑。优点是省事,缺点是一般跑不起来——文件里会引用window、document、navigator、screen,甚至做一些环境检测(比如检查window上的某个属性是不是原生函数、检查debugger有没有生效)。缺一个就报错,补到崩溃。

第二种是定位到最小闭包再扣。断点暂停的时候,在 Console 里拿到函数体,把它所在的 IIFE(立即执行函数)整块复制出来,梳理它依赖的外部变量,一个个补。这是最常用的方式。

第三种是改写引用。如果加密逻辑被拆成了好几个函数,相互调用,可以在原文件里全局替换函数名,把混淆名字改成可读名字,再逐步剥离无关模块。这活儿枯燥但可控,适合逻辑复杂的情况。

我这次采用的是第二种。扣出来的函数结构大概是这样:

// sign.js —— 从混淆代码里还原的可读版本 function buildSign(params, ts) { const keys = Object.keys(params).sort(); // 字典序 const parts = []; for (const k of keys) { const v = params[k]; if (v === undefined || v === null || v === "") continue; // 空值不参与 parts.push(k + "=" + v); } const raw = parts.join("&") + "&ts=" + ts + "&key=" + SALT; return md5(raw).toLowerCase(); }

实际扣出来的代码当然没这么干净,字符串是被打散的,md5也是自定义实现。这里的关键经验是:先把输入输出对齐,再管内部实现。也就是说,先随便给一组参数,在浏览器里跑一次拿到正确签名,然后在 Node 里跑你扣出来的版本,两者结果一致就算成功,不需要完全理解每一行代码在干什么。

SALT这个盐值是重点。它通常藏在文件顶部的一个大字符串数组里,通过一个偏移函数取值,比如_0x2f1a(0x1d3)。找它的方法是:在加密函数的断点里,把raw字符串打印出来,盐值就在里面明文躺着。拿到之后直接把盐值硬编码进你的 Node 版本,省去还原数组的麻烦。

2.3 Node 环境下的补环境与自测

扣出来的代码在 Node 里跑,最大的障碍是环境变量缺失。做法是构造一个 sandbox,用vm模块隔离执行:

const fs = require("fs"); const vm = require("vm"); const code = fs.readFileSync("./sign.js", "utf8"); const sandbox = { window: {}, document: { cookie: "", createElement: () => ({ getContext: () => null }) }, navigator: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..." }, location: { href: "https://example.com/search", protocol: "https:" }, Date: Date, Math: Math, console: console, setTimeout: setTimeout, }; vm.createContext(sandbox); vm.runInContext(code, sandbox); const ts = Date.now(); const sign = sandbox.window.buildSign({ key: "测试", page: "1" }, ts); console.log(sign);

问题在于,你永远不知道代码里还偷偷用了什么。这时候可以给 sandbox 套一层Proxy,把未定义的属性访问打印出来,一次性补齐:

const misses = new Set(); const sandbox = new Proxy({}, { get(target, key) { if (key in target) return target[key]; if (typeof key === "symbol") return undefined; misses.add(key); return undefined; }, });

跑一轮之后打印misses,把里面需要的属性一个个补上。补的时候要注意返回值的类型,比如document.createElement返回的对象必须有getContext方法,否则调canvas指纹的时候会崩。补到不报错为止,一般要迭代三四轮。

注意:有些站点会做“反调试”,比如Function.prototype.toString检测你的函数是不是原生的、Date.now被改写导致时间戳不对。遇到签名怎么算都对不上、但代码看着没问题的情况,先检查一下Date.now的返回值是不是正常的 13 位。

最后一步是验证。我的做法是:在浏览器断点里打印出raw字符串和最终签名,同时在 Node 里用同样的入参跑一遍,把raw和签名都打印出来,逐字符对比。第一处不一致的位置就是问题所在——八成是参数排序、空值处理、编码时机这三者之一。

3. 字体反爬的破解思路:从码位到真实字符

签名跑通之后,请求能正常返回数据了,但里面全是 PUA 码位。这一部分的目标是建立一个“码位 → 真实字符”的映射表,并且这个表要能应对字体频繁更换。整个方案的核心思想是:码位不可信,字形可信。同一家公司的名字不管码位怎么变,渲染出来的字形轮廓是相对稳定的。所以我不去猜码位,我去比对字形。

3.1 woff 文件的获取与结构解析

字体文件的来源有两个:一是页面 HTML 里的@font-face内联 base64,二是通过接口单独下发(响应头里带Content-Type: font/woff2)。我这次是前者,直接从源码正则抠出 base64 部分解码就行:

import re, base64, io from fontTools.ttLib import TTFont html = session.get(url, headers=headers).text m = re.search(r'base64,([A-Za-z0-9+/=]+)"\)', html) font_bytes = base64.b64decode(m.group(1)) font = TTFont(io.BytesIO(font_bytes))

拿到TTFont对象之后,几个关键表要认识清楚。cmap表记录的是“码位 → 字形名”的对应,font.getBestCmap()返回一个{ord: glyphName}的字典,这就是当前这一次的映射关系;glyf表存的是每个字形的轮廓数据,包括坐标系里的点、轮廓分段;hmtx是水平度量;head里有unitsPerEm,即每个 em 单位多少个坐标点,一般是 1024 或者 2048。

直接读cmap能拿到码位和字形名的对应,但字形名通常是glyph00001、uniE3A2这种毫无语义的东西,你还是不知道它代表数字几。所以必须往下走到glyf表,把轮廓数据拿出来做比对。

有一个细节要注意:如果字体是 woff2 格式,TTFont读取的时候需要brotli库,没装会抛ImportError,报错信息不太直观,容易误判成文件损坏。另外有些站点的字体用了子集化(只保留用到的字形),字形数量很少,这对我们是好事,处理起来更快。

3.2 字形轮廓指纹的构建

思路是:把每个字形的轮廓点取出来,做归一化(平移到原点、缩放到统一尺寸),然后对坐标序列做哈希,得到一个“指纹”。同一个字符不管在哪个字体文件里,只要字形设计一致,指纹就一致。

import hashlib from fontTools.pens.recordingPen import RecordingPen def glyph_fingerprint(font, gname, ndigits=1): gs = font.getGlyphSet() pen = RecordingPen() gs[gname].draw(pen) pts = [] for op, args in pen.value: if op in ("moveTo", "lineTo"): pts.append(args[0]) elif op == "qCurveTo": pts.extend([p for p in args if p is not None]) elif op == "curveTo": pts.extend(args) if not pts: return None xs = [p[0] for p in pts] ys = [p[1] for p in pts] w = max(xs) - min(xs) h = max(ys) - min(ys) if w == 0 or h == 0: return None norm = [((x - min(xs)) / w, (y - min(ys)) / h) for x, y in pts] s = ";".join(f"{round(x, ndigits)},{round(y, ndigits)}" for x, y in norm) s += f"|n={len(pts)}" return hashlib.md5(s.encode()).hexdigest()

这段有几个设计考虑,我解释一下为什么这么写。

为什么用RecordingPen而不是直接读glyf的坐标数组?因为glyf表里的复合字形(composite glyph)是个引用,直接读会拿到一个偏移量而不是真实轮廓。glyphSet的draw方法会自动把复合字形展开,拿到的是最终绘制指令,更稳。

为什么归一化?因为同一个字符在不同字体文件里可能被整体缩放或者平移过(有的是 1024 unitsPerEm,有的是 2048)。归一化之后坐标范围统一到 0–1,缩放和位移的影响就消除了。

为什么保留点数n?作为辅助校验。两个字形如果归一化坐标完全一致但点数不同,说明其中一个是另一个的简化版本,通常不是同一个字符。

为什么取ndigits=1?太精确会导致同一字形因为渲染引擎的微小差异而哈希不同;太粗糙又会把 6 和 9 这种接近的字形搞混。实测 1 位小数(相当于把 0–1 分成 10 份)在数字字体上区分度足够,误判率很低。如果遇到像1和l这种极相似的,可以提到 2 位。

初始的“指纹 → 字符”映射表怎么来?人工标注一次。第一次拿到字体,把每个码位渲染出来(用浏览器的字体查看器,或者 PIL 把字形画成图片),肉眼认出是什么字符,手工填进 JSON。十几二十个字形,五分钟能搞定。之后所有的新字体都靠这张表自动匹配。

3.3 映射表的落地与增量维护

映射表存成一个 JSON 文件,结构就是{指纹: 字符}:

{ "a3f1c9...": "0", "b7d2e4...": "8", "c1a8f3...": "." }

每次请求拿到新字体,流程是这样:

def build_mapping(font, cache): mapping = {} unknown = [] cmap = font.getBestCmap() for codepoint, gname in cmap.items(): fp = glyph_fingerprint(font, gname) if fp is None: continue if fp in cache: mapping[chr(codepoint)] = cache[fp] else: unknown.append((hex(codepoint), gname, fp)) return mapping, unknown

mapping就是可以直接用来替换的字典,unknown是没命中的,打印出来人工补。

替换的时候用str.translate或者逐个字符替换:

def decode_text(text, mapping): return "".join(mapping.get(ch, ch) for ch in text)

实测下来,同一站点的数字字体设计基本固定,命中率通常在 95% 以上,偶尔出现“某某”这种名字里的汉字字形被替换、或者标点符号新加的情况,手工补一下就行。真正需要警惕的是站点有一天换了字体厂商,字形设计全变了,指纹全部失配。这个从unknown的数量上能立刻看出来,处理方式就是重新人工标注一次。

提示:如果站点的字体是一天换一次(很多企业信息站点是这样),你的脚本要能检测“今天的字体和昨天的指纹表重叠率”,低于阈值就告警,别硬跑出一堆错数据。

4. 完整链路联调:从请求到结构化数据

两条链路都通了之后,剩下的活儿是把它们串起来,并且让它稳定。这一部分看着简单,实际上最容易出问题——因为签名和字体都有“时效性”,任何一个环节慢了一拍,整条链路就断。我吃过好几次亏,比如签名算好了但请求发出去时过了 30 秒,服务端校验时间戳超时;或者字体缓存了一天没更新,第二天跑出来的数据全是错的。

4.1 一次完整请求的参数拼装

完整流程拆成六步:

  1. 用requests.Session()打开列表页,拿 Cookie 和初始@font-face字体。
  2. 解析字体,构造码位映射表(命中缓存则复用)。
  3. 构造业务参数,按字典序排序,拼接待签名字符串。
  4. 取当前毫秒时间戳,调 Node 侧的签名函数,拿到签名字段。
  5. 组装 header,发请求,处理响应。
  6. 用映射表替换响应里的 PUA 字符,落地成结构化数据。

第 4 步涉及跨语言调用,有两种做法。一是每次启动 Node 进程跑一次,缺点是慢(进程启动 100ms 起步),适合低频场景。二是把 Node 服务常驻,Python 通过 HTTP 或者标准输入输出通信。我选的是后者,用child_process起一个小服务,Python 侧发 JSON 过去拿结果,单次耗时能压到 5ms 以内。

第 5 步有个容易翻车的细节:header 里的某些字段和 URL 是绑定的。比如Referer必须和当前搜索关键词一致,Origin必须是站点主域,少了或者错了会返回 403 但错误信息不明确。我在排查这个问题的时候浪费了一小时,最后靠“逐字段删减对比”才定位到是Referer的问题。

第 6 步的清洗要注意,替换只针对 PUA 区间的字符,别用全局正则无差别替换,否则会把正常的公司名也改掉。安全写法是:

def is_pua(ch): return 0xE000 <= ord(ch) <= 0xF8FF def decode_text(text, mapping): return "".join(mapping.get(ch, ch) if is_pua(ch) else ch for ch in text)

4.2 数据清洗与校验

拿到解码后的数据,别急着入库,先做一轮校验。最基本的三个检查:金额字段能不能转成数字、日期字段符不符合YYYY-MM-DD格式、公司名里还有没有残留的 PUA 字符。前两个能发现字体映射错误(比如把 8 映射成了 3,金额量级明显不对),第三个能发现映射表漏项。

我还会做一层“交叉校验”:同一个公司如果在列表页和详情页都出现,两处的注册金额应该一致。不一致说明两次请求用了不同的字体,映射表在切换过程中出了岔子。这个检查成本很低,但能拦住 90% 的脏数据。

最后是频率控制。哪怕你签名算得再对,请求发太快一样会被限流,表现形式是返回一个验证页或者直接 403。我的做法是每次请求之间随机间隔 2–5 秒,单次任务不超过 200 条,跑完就停。这不是技术问题,是分寸问题,做数据的都懂。

5. 踩坑记录与排查速查表

这一节是我这半个月里真正花时间的部分。前面讲的都是“正确路径”,但实际过程中你遇到的多半是“看起来对但结果不对”。我把踩过的坑整理成表,遇到问题直接查。

5.1 签名对不上的七种典型原因

现象可能原因排查方法
一直 403,签名格式正确时间戳单位错了(秒 vs 毫秒)打印时间戳,看是 10 位还是 13 位
偶尔成功偶尔失败时间戳和服务端时间偏差过大检查本机时间,误差超过 5 分钟会失败
参数顺序敏感排序规则不是你以为的用sorted()默认字典序,注意大写字母排在小写前
空值参与签名空字符串该不该拼进去分别试“跳过空值”和“保留空值”两种
中文参数出问题编码时机错了试试先 urlencode 再签名,或者反过来
签名长度不对输出大小写或进制不同MD5 是 32 位 hex,注意大小写
换了参数就失败盐值或参数名漏了断点里把参与拼接的完整字符串打出来逐字比对

表格里最坑的是“编码时机”那条。中文关键词在 URL 里会被编码成%E6%B5%8B%E8%AF%95,签名的时候用的是编码前还是编码后的串,两个结果完全不同,但表面上都能发出请求。判断方法:用纯英文关键词测试,如果英文正常中文报错,那就是编码问题。

还有一条没进表格但很重要:Date.now()被 hook 过。有些站点的 JS 会重写Date.now,让你的 Node 环境和浏览器环境拿到的“当前时间”不一样,从而让签名失效。如果你在断点里看到Date.now()的返回值和系统时间对不上,那就是被改写了,得把改写逻辑也扣出来。

5.2 字体映射失效的场景

字体这块的坑相对少,但每个都很隐蔽。

第一个是复合字形。有些字体把数字用两个组件拼起来,直接读glyf的坐标会拿到空数组或者错误数据。用glyphSet().draw()就没这个问题,它会自动展开。我一开始就是直接读glyf,结果一半的字形指纹是None,排查了半天。

第二个是字形微小变动。站点偶尔会重新生成字体,坐标整体偏移 1–2 个单位。归一化能吸收掉大部分,但如果用了ndigits=2就会失配。我的做法是加一层“模糊匹配”:指纹精确匹配失败时,用坐标点的数量 + 边界框宽高比做二次匹配,命中率能补回来一部分。

第三个是符号和空格。有些字体把小数点和逗号也做了混淆,但glyph_fingerprint对只有一两个点的字形往往返回None(因为宽或高为 0)。这种情况下得单独处理,用“点的相对位置”做判断。

第四个是字体缓存过期。站点每天零点换字体,如果你的脚本跑过零点还在用旧的映射表,后半段数据就全错了。解决方法是每次请求前对比字体 base64 的哈希,变了就重建映射。

5.3 稳定性与合规边界的心得

跑了大概两周,我总结了几个让脚本“少出事故”的习惯。

第一个是把关键中间结果落盘。签名用的原始字符串、字体文件的 md5、每次请求的响应码,全部写进日志。出问题的时候不用重新复现,翻日志就能定位。我一开始嫌麻烦没做,结果有一次跑了两小时的数据全是错的,原因是字体映射表没更新,白跑。

第二个是把签名验证做成独立脚本。单独写一个脚本,给定参数在浏览器断点里跑一次、在 Node 里跑一次,比对结果。每次改代码之后先跑这个,通过了再跑主流程。这比直接跑主流程再猜哪里错了要快得多。

第三个是对异常做分类而非统一重试。403 可能是签名错、可能是限流、可能是 Cookie 过期,三种情况的处理方式完全不同。我的做法是看响应体里的msg字段,签名错就重建签名逻辑,限流就退避等待,Cookie 过期就重新初始化会话。统一重试只会让限流更严重。

最后一个心得是关于节奏的。这类站点的反爬策略通常有“阈值”设计,你在阈值以下怎么跑都没事,一旦超过就是硬拦。与其花时间研究怎么绕过,不如把单次任务的数据量控制在合理范围内,把精力放在数据清洗和存储上。我做这个工具的初衷是整理行业调研要用的公开信息,一天几百条足够了,没必要追求全量。

源码结构上,我最后整理成四个模块:sign/(Node 签名服务)、font/(字体解析与映射缓存)、spider/(请求与解析)、store/(数据落地)。每个模块都能单独跑测试,改一处不影响其他部分。这个拆分不是为了好看,是因为我改签名的次数远多于改字体的次数,模块化之后每次只动一小块,调试成本直线下降。

拆解这类前端加密的真实体会是:技术难度其实集中在“定位”而不是“理解”。一旦你在正确的位置下了断点,看到了加密函数的入参出参,剩下的就是体力活——补环境、对参数、抠代码。字体反爬更是如此,思路通了之后,代码量不超过一百行。真正花时间的是那些“看起来对但就是不对”的瞬间,而解决这些瞬间的办法只有一个:把中间结果全部打印出来,逐字符比对,别靠猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询