szial头像

网络爬虫与 CDP 实战(四):动态参数和签名怎么来的?从请求追到 JavaScript

网络爬虫与 CDP 实战(四):动态参数和签名怎么来的?从请求追到 JavaScript

配套实验网站: 实验 13:请求调用链 · 实验 14:库存查询断点 · 实验 15:动态报价参数 · 实验 16:签名报价 · 实验 17:独立查询 · 实验 18:浏览器结算

你已经找到商品接口,Cookie 和 Token 也没问题。但 URL 中多了 ts、nonce、sign:每次点击按钮都会变化。复制一条成功请求,几分钟后失效;原样立即再发,也可能被拒绝。

遇到这种接口,先别把所有陌生参数叫“加密”。它们可能分别承担时间校验、重放检查和内容校验。真正有用的能力,是把每个字段接回生成它的代码,再用对照请求确认服务端规则。

这一篇串起六个实验:Initiator、XHR/Fetch 断点、动态参数、简单签名、纯 Python 复现与浏览器辅助计算。我们使用公开算法的自建教学接口,让分析过程有确定终点;从中延伸的则是任何网页接口分析都需要的字节契约、状态判断和维护成本。

跟着文章动手:在线实验站

为了让参数分析有一个可以反复验证的对象,我在 网页数据实验场 中准备了本篇的实验 13—18。你可以从首页的 Phase 4 进入,也可以直接点击下文每节的实验链接。页面脚本保持可读,便于你在 DevTools 中追踪调用链、暂停执行、核对参数,再用 Python 请求同一个在线接口。

网站使用固定的 24 件虚拟商品,页面负责提供业务操作,操作步骤和分析方法在本文中。下面的请求代码默认使用这个公开站点;如有自己的本地实例,可以设置 CDP_BASE_URL 切换地址。

1. 先记录变化,再追代码

对同一个商品点击两次,保留两条请求。观察哪些字段相同、哪些变化:

第一次:product_id=1&ts=...1000&nonce=9f3a2b1c&sign=...
第二次:product_id=1&ts=...1800&nonce=7e1d4c8a&sign=...

商品 ID 不变,时间戳和 nonce 变,签名也变。这是线索,但还不能仅凭外观证明算法。64 位十六进制串可能是 SHA-256 的结果,也可能只是某个编码产物;要看到计算调用和输入,才能确认。

把问题拆成四个:字段从哪里取得?在哪一步改变?服务端检查什么?成功使用后还能不能再次使用?这四个答案,直接决定脚本应保存状态、生成新值,还是调用浏览器计算。

2. 实验十三:Initiator 让请求回到源码

动手入口:打开请求调用链实验页。 刷新页面并点击查询操作,在 Network 中选中对应请求,沿 Initiator 查看发起位置和调用链,再回到源码核对。

Network 的 Initiator(发起者)会提供触发请求的脚本位置与调用链。在商品请求上进入 Initiator,先找到最接近网络发送的函数,再沿调用关系向外看。CDP 的 Network.requestWillBeSent 也提供相关 initiator 字段。[1]

# 片段:订阅在 Network.requestWillBeSent 上。
def on_request(event):
    if "/products/" not in event["request"]["url"]:
        return
    stack = event.get("initiator", {}).get("stack", {})
    for frame in stack.get("callFrames", []):
        print(frame["functionName"], frame["url"], frame["lineNumber"] + 1)

这里加 1,是把协议的 0 基行号转成编辑器常见的 1 基显示。异步调用还可能切断当前栈,initiator.stack 不能当成整个业务过程的完整录像。[1]

压缩代码怎样读得动

打开脚本后,先格式化,再围绕已知线索搜索:接口路径、参数名、最终 fetch() 调用,以及附近的数据构造函数。若站点提供 source map,DevTools 可以把生成代码映射回作者源码;没有 source map 时,也能从实际执行代码追查,但需要区分显示行号与真正的协议位置。[2]

不要一上来阅读几十万字符的整个包。已经确认的请求地址就是缩小范围的入口。同一个接口可能有多个调用点,页面加载与按钮点击也可能带不同默认值;你要分析的是这一次请求的路径。

从 Initiator 到源码之后,下一步不是立即翻译代码,而是暂停在输入仍可观察的位置。

3. 实验十四:断点位置决定你看见哪一阶段

动手入口:打开库存查询断点实验页。 分别使用页面上的 Fetch 与 XHR 查询操作。在 Sources 中设置请求断点,比较两种调用方式暂停时能看到的参数与调用栈。

XHR/Fetch 请求断点适合找到“哪段代码即将发这个请求”;源码位置断点适合停在某一行执行之前。Chrome 文档介绍了这两类断点和条件断点、日志点等用途。[3]

考虑下面这段最小代码:

let productId = 1;
const url = "/api/products/?product_id=" + productId;
console.log("准备请求", url);
fetch(url);

如果暂停在 fetch(url) 处,再改 productId,已经生成的 url 不会自动重新拼接。这就是“Scope 中变量变了,服务器收到的值没变”的原因之一。断在 URL 赋值之前,或直接检查最终 url,才与真实请求有关。

这条经验可推广到 JSON body、Header 对象和签名:参数在局部变量里变了,不代表已经完成的派生值会随之更新。要沿数据流确认哪个对象已经被复制、序列化或拼成字符串。

CDP 暂停现场需要读哪些字段

Debugger.paused 的 reason 解释暂停原因,callFrames 描述调用帧,hitBreakpoints 关联命中的断点。这里的 Debugger 调用帧位置在 location 内,不应照抄另一种 Runtime 调用帧的字段结构。需要在暂停作用域求值时,使用 Debugger.evaluateOnCallFrame 等针对调用帧的方法。[4]

还有一个程序层面的陷阱:浏览器已被断点暂停,如果同步等待某个依赖页面继续运行的 click() 或 evaluate() 完成,程序可能等住,永远走不到恢复代码。将触发动作、暂停事件处理和 Debugger.resume 的顺序安排清楚,比增加超时更有用。

如果只想观察某个值,多次暂停会扰乱时间戳和异步行为;日志点可以在不暂停的情况下记录值。此时要清楚“暂停可能让请求过期”也是实验环境带来的变量。[3]

4. 实验十五:时间戳检查新鲜度,nonce 检查重复

动手入口:打开动态报价参数实验页。 对同一商品连续发起报价,再尝试重复提交,记录时间戳、nonce 和返回结果。用这些实际请求验证下面的新鲜度与重放判断。

示例接口新增两个参数。ts 是毫秒时间戳,页面用 Date.now() 生成;服务端接受前后各两分钟的偏移。nonce 是一段随机十六进制串,服务端记住成功使用过的值,在记录有效期间拒绝重复。

两者承担不同职责。旧时间戳表示请求不够新鲜;新时间戳却不自动让旧 nonce 重新可用。只改 ts 后仍得到 nonce_reused,正好证明这两个校验不是一个东西。

对照请求案例预期能支持的判断
新 ts 与新 nonce200参数形状与窗口可接受
成功请求原样重放409,nonce_reused服务端记住了这次使用
只换时间戳,保留已用 nonce409新鲜度不能代替唯一性
使用过期时间戳400,ts_expired时间窗检查独立存在

这些状态码与原因是案例的接口约定,真实系统可能采用另一种表达。不能把“409 就代表 nonce”当通用规则。

从参数形状延伸到服务端状态

页面能生成随机串,但“一次性”不由随机串自己保证。服务端需要维护使用记录。公开实验站将使用记录保存到 Durable Objects 中,并保留到对应请求的新鲜度窗口结束。记录不会仅因 Worker 执行实例更换而丢失;窗口之外,时间戳校验先拒绝旧请求。这是有时限的重放防护,不是永久保存每个 nonce。

在多进程服务中,各进程若各自保存独立集合,同一 nonce 可能在另一进程被接受。若真实业务要求一致性,必须考虑共享记录、原子检查与写入、有效期以及记录淘汰策略。这是从一个短实验推到系统设计时必须补上的部分。

时间也类似。time.time() 的秒数若不转毫秒,就与 Date.now() 差三个数量级。服务器和客户端时钟可能有偏移;实验的对称窗口是一个设计选择,不代表所有接口都如此。生成参数后尽快发送,比把一整批时间戳提前算好再排队更容易满足新鲜度规则。

5. 实验十六:签名验证的是字节,不是“看起来一样”

动手入口:打开签名报价实验页。 先取得一次正常报价,再使用页面的备用核验或重复提交操作做对照,回到脚本查看签名输入,核对本文讨论的校验顺序。

现在再加入 sign。教学算法是:

data = product_id + "|" + nonce
raw  = data + ts + demo_secret
sign = SHA256(UTF8(raw)) 的小写十六进制表示

浏览器通常先构造字符串,再用 TextEncoder 得到字节,调用 crypto.subtle.digest()。这个 API 返回 Promise,所以观察或借用时需要等待结果;它的可用性还与安全上下文有关。[5]

Python 可以写成下面这个纯函数:

import hashlib

def create_sign(product_id: int, ts: int, nonce: str, secret: str) -> str:
    data = f"{product_id}|{nonce}"
    raw = data + str(ts) + secret
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

分隔符、拼接顺序和编码都是契约。不要觉得“字段都在”就够了,服务端比较的是特定算法的结果。

为什么重写算法时要分层对账

先固定商品 ID、时间戳、nonce 与 secret;分别打印两端的 data、raw 和最后的摘要。data 相同而 raw 不同,问题在时间戳或 secret;raw 相同但摘要不同,检查编码、输入类型与结果表示。

一个字段的 1、1.0、"1",经过不同构造过程不一定产生相同字节。价格 899.00 与 899 在金额意义上可以相等,作为签名字符串却不同。若接口签 JSON,字段排序、空白、Unicode 表示与数字格式也可能影响字节;只有接口明确规定规范化规则时,才按那套规则重构。

先比固定输入,是为了把时间变化从问题中拿掉。如果每次运行都重新生成所有参数,两端结果不同是预期现象,很难定位移植错误。

用先错后对,验证校验顺序

教学接口按 ts → sign → nonce 检查。用同一组新鲜参数先发错误签名,再发正确签名:第二次成功,说明第一次没有消耗 nonce。随后将成功请求原样重放,得到重复拒绝。

这是非常实用的证据方法:通过观察同一份状态在两次请求中的变化,推断服务端校验顺序。不过真实系统也可能有别的拒绝条件,结论应限制在已经验证的接口与场景内。

6. 延伸:哈希、签名、加密与认证,不要混成一件事

示例把 SHA-256 的结果叫作 sign,因为字段就叫这个名字。但它不是非对称数字签名,也不是把请求内容加密。摘要用于让双方比较内容,API 的业务命名不能替代密码学分类。

更关键的是,示例 secret 故意写在前端源码里,任何能取得脚本的客户端都能读取。它方便教学,不能凭此建立只有“可信客户端”才会拥有的长期秘密。参数签名能否承担身份认证,取决于密钥、状态与授权设计。

如果你在设计自己的接口,带密钥的消息认证可以考虑标准 HMAC 等成熟构造,而不是把 sha256(message + secret) 当通用安全方案。Python 的 hmac 模块提供相应实现与比较接口;实际协议还要明确消息编码、身份范围和重放规则。[6]

学习爬虫分析时,我们尊重实际接口的字节契约;评审接口设计时,则要问它究竟要证明内容、身份还是新鲜度。两种工作的问题不同,不能因为自己复现出了一个摘要,就推断整个系统安全或不安全。

7. 实验十七:把分析成果变成可脱离浏览器的请求

动手入口:打开独立查询实验页。 先在浏览器中完成“创建查询”和“提交查询”,记录接口流程,再运行下面的 Python 示例,验证它能否独立请求同一站点。

浏览器里算对一次,距离可重复运行的脚本还差一个步骤:把每次输入的来源也实现出来。教学练习提供 /labs/reimplement/challenge/,返回商品 ID 和一个尚未使用的 nonce,却不给时间戳与签名。下面将算法与 24 次请求接起来:

import os

import hashlib
import time
import httpx

BASE = os.environ.get("CDP_BASE_URL", "https://web-data-lab.pages.dev").rstrip("/")
DEMO_SECRET = "cdp-lab-demo-secret-2026"  # 仅是教学接口公开的演示值

def sign(product_id, ts, nonce):
    raw = f"{product_id}|{nonce}" + str(ts) + DEMO_SECRET
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

with httpx.Client(base_url=BASE, timeout=10.0, trust_env=False) as client:
    collected = {}
    for product_id in range(1, 25):
        issued = client.get("/labs/reimplement/challenge/",
                            params={"product_id": product_id})
        issued.raise_for_status()
        nonce = issued.json()["nonce"]
        ts = int(time.time() * 1000)
        response = client.get("/labs/sign/products/", params={
            "product_id": product_id, "ts": ts, "nonce": nonce,
            "sign": sign(product_id, ts, nonce),
        })
        response.raise_for_status()
        item = response.json()
        if item["product_id"] != product_id or not item["verified"]:
            raise ValueError("接口返回与请求商品不一致")
        collected[product_id] = item

    if set(collected) != set(range(1, 25)):
        raise ValueError("商品 ID 不完整")
    for item in collected.values():
        print(item["name"], item["unit_price"], item["stock"])

这段流程没有浏览器依赖,但它仍依赖服务端接口与算法版本。验证时应与另一条已确认的数据路径对账,至少比较 ID、价格和库存,而不是只检查请求状态。

一次性参数遇到超时,重试不是简单复制

如果请求发出后读取超时,你不一定知道服务端有没有执行。原样重试可能因 nonce 已消耗而失败;换 nonce 再发,又可能重复一项有副作用的业务操作。

对只读商品查询,可以在接口允许的规则内生成新参数恢复。对创建订单等操作,需要业务幂等键或状态查询来确认结果。重放防护与业务幂等不是同一机制:前者拒绝同一参数重复,后者处理一个业务意图可能被发送多次的情况。这是让脚本长期运行时,比“会算签名”更重要的一层。

8. 实验十八:算法太费劲时,借浏览器计算

动手入口:打开浏览器结算实验页。 打开结算页,观察准备结算与获取报告的请求,再在这个页面的执行上下文调用浏览器计算函数,把结果提交给在线接口验证。

若页面已经暴露 window.computeScore(salt),可以让浏览器执行,再把结果带回 Python:

# 片段:page 已加载包含计算函数的演示页,salt 来自该接口的实际流程。
score = await page.evaluate(
    "async (salt) => await window.computeScore(salt)", salt
)

Playwright 会等待返回的 Promise。参数通过 API 传递,而不是手工拼进表达式字符串,可以避免输入中的引号或换行破坏表达式。[7]

教学算分函数涉及 32 位整数操作。这类移植容易出错:JavaScript Math.imul 做 32 位整数乘法,>>> 0 常用于得到无符号结果;Python 的整数不会自动溢出,通常需要在对应步骤显式截断。最终一次截断,未必等价于每一步都截断。[8]

不能因此说“所有位运算都交给浏览器”。先拿固定输入建立测试向量,再评估移植成本。如果只需几个简单步骤,Python 更轻;若依赖复杂页面对象或经常变化的实现,浏览器辅助更省维护。

这份算分接口还提供一个反例:它检查 salt 的新鲜度,却不登记一次性 nonce,同一组 salt + score 在有效窗口内可以再次成功。因此“每次参数都变”不等于“服务端一定禁止重放”。状态要从请求结果与实现规则确认。

9. 延伸案例:三种采集架构,怎样比较成本

设想一个目录同步任务,每天要取得若干批商品。这是教学业务场景。架构可以有三种:纯 HTTP 与 Python 算法;浏览器只负责计算,HTTP 负责数据请求;浏览器完成页面流程,程序监听响应与整理数据。

路线主要依赖适合什么情况维护时最容易变的部分
纯 Python接口与算法契约输入清楚、计算可验证、数据量较大字节规则与算法版本
浏览器计算 + HTTP页面执行环境与接口计算难移植,但请求本身清楚页面初始化状态与函数入口
页面操作 + 响应监听页面行为与监听条件有复杂状态链,复现成本更高页面交互与完成条件

比较时要测浏览器启动与初始化成本、每批计算时间、错误恢复方式,以及升级页面后需要改多少代码。不能仅凭某种方案代码短就判断维护成本低。

对于浏览器辅助路线,计算函数可能依赖当前用户、已加载商品或页面内存;拿到一个 score 不代表可以移到另一会话使用。对于纯 Python 路线,算法对账通过也不代表分页和业务字段已经正确。把计算正确、凭证正确、结果完整三份证据分别留好,问题才容易定位。

下一篇将换一种数据协议:价格不是由你逐次请求,而是沿 WebSocket 持续推送。随后我们会在一个没有路线提示的门户里,把前面这些判断组合起来。

References

[1] Network domain. Chrome DevTools Protocol. Published/updated: not listed. Accessed: 2026-10-01. Used for: Initiator 与调用栈字段。

[2] Debug your original code instead of deployed with source maps. Chrome for Developers. Published/updated: not listed. Accessed: 2026-10-01. Used for: 源码映射的边界。

[3] Pause your code with breakpoints. Chrome for Developers. Published/updated: not listed. Accessed: 2026-10-01. Used for: 断点、条件与日志点。

[4] Debugger domain. Chrome DevTools Protocol. Published/updated: not listed. Accessed: 2026-10-01. Used for: 暂停现场、调用帧求值与恢复。

[5] SubtleCrypto: digest() method. MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: 摘要 API 的异步与上下文条件。

[6] hmac — Keyed-Hashing for Message Authentication. Python documentation. Published/updated: not listed. Accessed: 2026-10-01. Used for: 带密钥消息认证的标准实现。

[7] Evaluating JavaScript. Playwright Python. Published/updated: not listed. Accessed: 2026-10-01. Used for: 浏览器求值与参数传递。

[8] Math.imul(). MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: 32 位整数乘法与语言差异。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/szial/article/details/167075891

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--