在抖音 Web 端做数据采集,绝大多数人第一次受阻的位置都高度一致。浏览器里接口返回正常,换成 requests 或 httpx 直连,要么拿到空数据,要么被重定向到验证页。把两次请求逐字段比对,差异几乎都集中在几个签名参数上,而 a_bogus 是其中校验最严格、逆向成本最高的一项。
本文的目标是把 a_bogus 从经过重度混淆的 JS 中剥离出来,再用 Python 稳定地调度它。我不会贴出某个特定版本的算法实现,因为这套签名逻辑平均几周就会迭代一次,固化的代码存活周期很短。真正有长期价值的是定位方法和复现架构,这两部分不随版本变动而失效。
参数定位:先确认它在请求里的位置
a_bogus 属于字节跳动自研的 Web 端签名体系,和移动端 X-Gorgon、X-Khronos 同源但实现完全独立。在 DevTools 的 Network 面板中,凡是带业务参数的接口(评论列表、视频搜索、用户作品聚合)都会把它挂在 query string 里,与之同行的通常有 msToken、_signature,以及从 ttwid cookie 派生的时间标识。
它的核心约束是参数强耦合:请求体或 query 中任意字段变动、时间戳偏移一秒,输出值都会整体翻变。这意味着它无法像长期 token 那样抓取一次复用,而是每次请求必须在线重新计算。新人常犯的错误是把浏览器里那条成功请求原样固化为常量,几分钟后重放便宣告失效,根因就在这里。
定位入口不要去读压缩后的 bundle。更直接的手段是在 Network 面板用 Initiator 列回溯调用栈,或者用条件断点卡住 XMLHttpRequest.prototype.send 与 fetch 的发送时机,参数拼装完成才会进入发送阶段,顺着栈帧上翻两层通常就能看到把 a_bogus 注入请求对象的那个函数。确认命中后,再把承载它的 JS 整文件下载到本地做静态分析。
混淆结构:认清你面对的是什么
下载下来的代码是典型的商业级混淆产物,由工具批量生成而非人工堆叠。理解它的组成能让你少走很多弯路。
字符串抽取与下标寻址。所有中文字段名、文案、常量被抽离进一个全局数组,源码里的 "comment/list" 在产物中表现为 O0o0o0[372]。更麻烦的是部分混淆器会在运行时对数组做循环位移或异或还原,静态提取下标映射会失效,需要在本地把位移逻辑先跑一遍再建立对照表。
控制流平坦化。这是耗时最长的部分。原本线性执行的语句被打散成多个基本块,外层套一个由状态变量驱动的分发器循环,每个基本块执行完更新状态再跳回分发点。顺序阅读代码不可行,要么手动恢复状态机转移关系,要么借助基于 AST 的工具(acorn 解析、escodegen 还原)做基本块重排。
反调试与垃圾分支。混淆层会插入 debugger 硬断点、对 Function.prototype.toString 做返回值校验以识别被劫持的函数,以及依赖 Date.now 与 performance.now 时间差判断开发者工具是否开启。另有大量恒真判断、死分支和算完即弃的中间变量,纯为干扰视线,不参与签名运算,确认后可整段跳过。
环境指纹采集。a_bogus 在运算时会读取大量运行时环境特征,包括通过 WEBGL_debug_renderer_info 拿到的 UNMASKED_RENDERER_WEBGL 渲染器字符串、canvas 绘制后取像素计算的视觉噪声、navigator 系列属性、字体枚举结果以及 performance 的计时序列。这也是把算法直接逐行翻译成 Python 后经常校验失败的根本原因:Node 与真实浏览器在这些输入上的采样值并不一致,任一字段偏差都会让整串签名作废。
还原主函数:用运行时验证替代肉眼硬读
我倾向于先把 JS 落地到本地用 Node 驱动,而不是在源码层面死磕。在疑似入口函数和出口处分别插入 console.log,把每次调用的入参结构和返回值完整打印,再与浏览器真实请求的参数逐一比对。当本地驱动产出的 a_bogus 能对齐浏览器那条记录时,入口和参数映射就算坐实了。
变量名在这个阶段可以随意重命名,不影响执行。把 O0o0o0、llII11 之类替换成 stringTable、dispatcher、signEntry 后,代码骨架会立刻清晰。另外,针对基于时间差的反调试逻辑,可以把 Date.now 与 performance.now 在本地桩函数里固定为常量,避免签名过程因检测到调试环境而短路返回空值。
待主干理清后会发现,真正的签名核心往往就是单个函数,输入是业务参数加一个环境对象,输出即那一长串 a_bogus。其余层层嵌套的调用多数在准备指纹,找准主入口后都是可剥离的枝节。
Python 复现:两种架构的取舍
复现路径本质上只有两条,区别在运行边界落在哪里。
方案一,Python 进程内调用 JS。把还原后的 JS 存为文件,通过 pyexecjs 或 subprocess 拉起 Node 执行其中的签名函数,取回结果再拼装到请求。优点是无需理解 a_bogus 内部运算,上游 JS 更版时只替换文件即可,Python 侧代码基本不动。代价是要随包携带 Node 运行时,且每次调用都承受进程冷启动开销。
针对冷启动问题,更成熟的落地形态是把签名函数包成一个常驻的轻量 HTTP 服务(比如用 FastAPI 暴露一个 /sign 接口),多个采集 worker 通过本地回环复用同一个 Node 实例,既消除重复启动成本,也便于把签名能力从业务逻辑里解耦出来。
方案二,将算法迁移为纯 Python 实现。顺着 JS 逻辑把字符串拼接、位运算、哈希迭代逐句重写。这种实现零外部依赖、吞吐高,但每次上游改算法都得完整重做。更要命的是浮点与位运算语义要对齐:JS 的 >>> 0 无符号右移在 Python 里要用 value & 0xFFFFFFFF 等效表达,Math.imul 的 32 位有符号乘法也得自行实现,否则中间态稍有偏差,最终签名即对不上。
我的做法通常是先用方案一打通链路、验证数据可用性,等业务稳定且调用量上来后,再挑选核心算法逐步向方案二迁移。赶进度时,能调 JS 就别硬翻。
验证复现是否正确的方法很直接:拿浏览器那条真实请求的完整参数喂给你的函数,看产出能否与浏览器生成值对齐。能对齐说明入口和参数映射都找对了;对不上先别怀疑算法,多半是某个环境指纹字段漏传或传错,回去对照 console.log 的输入输出逐项排查,比盲目改代码快得多。
工程化封装:配置、日志与重试
下面是经过实战打磨的封装示例,引入了 dataclass 统一配置、结构化日志、指数退避重试以及自定义异常类型,把签名能力收敛成一个可注入的依赖。
python
import execjs
import logging
import time
from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable, Dict
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("abogus.signer")
class SignError(RuntimeError):
"""签名计算失败,通常由 JS 运行时异常或返回空值触发。"""
def retry_with_backoff(times: int, base_delay: float = 0.2) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
last_err: Exception | None = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as err:
last_err = err
logger.warning("签名第 %d 次失败: %s", attempt + 1, err)
if attempt < times - 1:
time.sleep(base_delay * (2 ** attempt))
raise SignError(str(last_err)) from last_err
return wrapper
return decorator
@dataclass
class SignerConfig:
js_path: str = "sign.js"
node_runtime: str = "node"
max_retry: int = 3
def __post_init__(self) -> None:
if self.max_retry < 1:
raise ValueError("max_retry 必须不小于 1")
class ABogusSigner:
def __init__(self, config: SignerConfig) -> None:
self.config = config
with open(config.js_path, "r", encoding="utf-8") as fh:
self._ctx = execjs.compile(fh.read())
@retry_with_backoff(times=3)
def sign(self, params: Dict[str, Any]) -> str:
result: str = self._ctx.call("getSign", params)
if not result:
raise SignError("签名函数返回空字符串")
logger.info("a_bogus 生成成功, 长度 %d", len(result))
return result
几个细节值得展开。SignerConfig 用 __post_init__ 做参数合法性校验,配置错误在实例化阶段就暴露,而不是等到第一次请求才崩。retry_with_backoff 在三次重试之间做了指数退避,避免外部 JS 运行时抖动时瞬间打满重试。自定义 SignError 把签名失败从通用异常里区分出来,上层采集逻辑可以按异常类型决定是重试、换 IP 还是丢弃任务。类型注解贯穿入参出参,协作者和一段时间后的你自己都能从签名直接看出契约。
别忽视风控的另一条线:IP
a_bogus 解决的是请求参数合法性,但服务端风控还会把签名、IP、行为序列三方关联。即使签名完全正确,单一出口 IP 在短时窗内高频请求,仍会被判定为机器流量并触发验证码或限流,此时返回的不再是数据。
分散请求出口靠代理池。我自己在短视频采集场景长期使用亿牛云代理,他们提供动态短效 IP 与隧道代理两种形态,隧道模式按请求自动轮换出口,对接这套采集链路时只需在 httpx 的 transport 层挂上代理地址即可,改造量很小。需要提醒的是,换 IP 必须配合合理的请求间隔与请求头一致性,单纯堆 IP 数量而忽略节奏模拟,风控仍然会基于行为特征命中。
收尾
a_bogus 这类签名参数表面复杂,拆解后无非是定位入口、还原混淆、在 Python 侧建立稳定调度三件事,顺序不能乱。真正消耗工时的永远是混淆还原这一步,因为混淆手法持续进化,但追调用栈、打运行时日志、对照输入输出验证的方法论不受版本影响。链路打通之后,把配置校验、结构化日志、退避重试这些工程细节补完,比钻研单个算法技巧更能支撑长期运维。