爬虫请求体payload的三种格式解析与动态签名排查实战
2026/9/9 5:30:31 网站建设 项目流程

爬虫这行干久了你会发现,大部分请求被拒,不是UA没伪装好、不是代理池不够大,而是请求体里的payload出了问题。有次我帮一个刚入门的朋友排查脚本,他跑了一周的采集任务突然拿不到数据,代码也没报错,日志打印出来response还带着200。我让他把抓包面板和requests实际发出的请求体逐字段对了一遍,才发现后端某天开始要求payload里必须携带一个时间戳字段,旧脚本还在发空数据,服务端直接返回了空列表。这种情况遇到多了,我慢慢把payload的处理方式归纳成三类,每一类的解法逻辑完全不同。

如果你正在用Python写爬虫,无论你用的是requests还是httpx,只要你需要模拟登录、翻页、提交搜索条件,就绕不开payload。这篇文章我会直接把三种常见形式拆开讲清楚,每一类从抓包怎么看、参数怎么组、到常见翻车点都过一遍。同时还会聊一个很多人卡过的现象:PyCharm里脚本运行完毕只显示“Process finished with exit code 0”,但没有输出数据,这大概率也是payload相关的坑。

1. 先搞懂payload在爬虫请求里的位置和作用

1.1 payload不是神秘黑魔法,它只是请求体

很多教程里直接甩出一句“把payload放进post请求”,从没解释过payload是什么。说得直白一点:浏览器在向服务器发POST、PUT、PATCH这类需要携带业务数据的请求时,请求正文(Body)里放的完整数据块,就是payload。你在Chrome开发者工具里打开任意一个提交表单的接口,切到Payload标签页看到的内容,就是待发送的请求体。

它是怎么传输的?HTTP协议本身不关心你的业务字段叫什么,只把请求体的内容当作一串字节流传过去。服务器拿到字节流后,再根据请求头里的Content-Type决定怎么解析。所以处理payload的第一原则永远只有一个:Content-Type是什么,请求体就必须按什么格式组织

比如网页登录时最常见的表单提交,Content-Type是application/x-www-form-urlencoded,那payload就是一长串username=xxx&password=yyy&remember=1这种键值对。另一个常见接口呢,Content-Type是application/json,那payload就必须是合法的JSON文本。这两种写法在抓包工具里看起来差不多,但在代码里混用的时候,服务器就会翻脸。

1.2 三类核心格式对照:一眼识别该用哪种写法

从爬虫实战的角度,绝大多数payload只有下面三种。我建议你把这张对照表存下来,进抓包面板第一件事就是看Content-Type和Payload的形态,然后决定requests里该传什么参数。

Content-TypePayload 形态requests 推荐写法
application/x-www-form-urlencodedkey=value&key2=value2data={"key": "value"}(字典自动编码)
application/json{"key": "value"}json={"key": "value"}(自动序列化并改Header)
multipart/form-data一段带boundary分隔符的混合内容,文件与字段混合data={...}, files={...}(requests自动生成boundary)

除了这三种,偶尔也会遇到text/plainapplication/x-protobuf和二进制流。前者通常出现在某些老系统或者上传接口需要自定义格式时,后者多见于抖音、小红书这类App的接口,做了protobuf序列化,这种就属于另一套玩法了。先掌握这三种最常见的,能覆盖至少八成业务场景。

这三种格式对应的处理代码并不复杂,但有一堆细节坑。下面我分别展开讲。

2. 第一类:urlencoded表单型payload,细节容易在不知不觉中丢掉

2.1 用字典传data和手动组串,哪个更可靠

这是爬虫里最常见的payload类型,传统网站的登录、搜索、翻页接口几乎都是这种。对Python requests来说,最简单的做法是传一个字典给data参数:

import requests login_data = { "username": "test_user", "password": "123456", "remember": "1", } resp = requests.post( "https://example.com/api/login", data=login_data, headers={"User-Agent": "Mozilla/5.0 ..."}, )

data传的是字典时,requests内部会按application/x-www-form-urlencoded的规则把字典编码成username=test_user&password=123456&remember=1,同时自动设置Content-Type。这是最稳的做法,因为键值对的排序和转义都由库处理。

但也有人喜欢自己先拼好字符串再丢进去:

payload_str = "username=test_user&password=123456&remember=1" resp = requests.post(url, data=payload_str, headers=...)

不是不行,但一旦字段值里有中文、空格、特殊符号比如&=,你就必须手动做URL编码,不然服务端解析会乱套。例如密码是abc&123这种,直接拼字符串会变成两个字段,而requests的字典方式会自动把&转成%26。所以我的习惯是:能用字典就不用字符串,除非是某些需要精确控制字段顺序的老接口。

2.2 字段顺序敏感的老接口怎么办

有些后端老接口或者特殊业务系统,解析逻辑写得不严谨,对字段顺序有隐含依赖。字典在Python 3.7+是有序的,只要你按抓包看到的顺序依次插入字段,最终编码出来的body顺序就不会变:

payload = {} payload["app_id"] = "10001" payload["method"] = "get_goods_detail" payload["goods_id"] = "8888"

这样组出来的请求体和浏览器里看到的一致。不过真实场景里这种接口很少,更多时候顺序错了也能通,如果你遇到某个接口必须严格按顺序传,优先怀疑后端是拿原始body字符串做签名校验的。

2.3 翻车点:同一个字段出现多次、数组、空值

urlencoded类型有几种反直觉的情况。第一,某些搜索接口允许同一个关键字传多次,例如tag=python&tag=爬虫,这时不能直接用字典,因为字典键重复会覆盖。需要用元组列表:

payload = [("tag", "python"), ("tag", "爬虫"), ("page", "1")]

requests遇到这种结构,会自动编码成两个tag字段。如果你用dict,最终只会剩下最后一个tag值。

第二,有些接口的payload里带空字符串或者null,很多新手不知道空字符串该不该带。后端如果对字段做了必填判断但允许空值,那"remark": ""和直接不传这个字段是两回事。是否传空,取决于你抓包时浏览器实际发送的body长什么样。抓包里出现了,就原样传;抓包里没出现,就不要脑补。

第三,有些表单里会用数组结构,比如多选下拉框提交成hobby=1&hobby=2&hobby=3。跟重复字段一样,用字典传就会丢数据。所以在处理urlencoded时,遇到重复键或数组,统一改用list of tuples。

3. 第二类:application/json结构体,值类型是最大的暗礁

3.1 json参数和手动dict.dumps的区别

现在的爬虫目标,尤其是App接口和前后端分离的站点,payload基本都走JSON。这种请求体的Content-Type是application/json,你的body应该是一段被序列化之后的JSON文本。

在requests里有两种等价写法:

# 写法一:通过json参数自动序列化 resp = requests.post(url, json={"name": "测试", "page": 1}) # 写法二:手动序列化 import json payload_str = json.dumps({"name": "测试", "page": 1}) resp = requests.post( url, data=payload_str, headers={"Content-Type": "application/json"} )

第一种写法更省事,requests会自动把Python字典变成JSON字符串,并且帮你设置Content-Typeapplication/json。第二种适合你要对JSON文本做额外加工的场景,比如某些接口的payload里带多余的空格也会被服务端做严格签名字符串校验。

这里有一个非常典型的坑:用json=传参时,requests会无视你在headers里手动设置的Content-Type,强制改写为application/json。也就是说你别想用json参数传一个别的类型。反过来,如果你用data传了一段已经被序列化好的JSON字符串,却忘了设置Content-Type为application/json,服务端很可能按urlencoded去解析,结果一个参数都拿不到。

3.2 Python类型与JSON类型的隐式转换

JSON对象看起来和Python字典差不多,但两者之间类型并不完全等同。最典型的区别是:

语义PythonJSON
布尔值True / Falsetrue / false
空值Nonenull
数字int / floatnumber

新手写爬虫时最容易犯的错误,是直接在代码里写:

payload = {"is_vip": True, "coupon": None}

json=传的时候,requests内部会用json库序列化,会把True变成trueNone变成null,最终发送的body没问题。但如果你用data=str(payload)这种野路子,就会把Python语法原封不动发出去,服务端看到TrueNone直接解析失败。

如果接口本身对布尔值和空值要求特别严格,比如签名接口参与摘要计算的字段必须原样无变化,你可能就需要手动构造JSON字符串,保留精确格式:

payload_text = '{"is_vip": true, "coupon": null}'

保证发送出去的body和浏览器一模一样。调试这类接口时,最好把最终发送的body打印出来看一次,别凭感觉判断。

3.3 JSON里嵌套的深层结构与动态字段

JSON类型的payload另一个麻烦是嵌套层级深。比如一个商品筛选接口的请求体可能是这样的:

{ "query": { "keyword": "手机", "filters": [ {"field": "brand", "values": ["华为", "小米"]}, {"field": "price", "range": {"min": 1000, "max": 5000}} ], "sort": {"by": "default", "order": 1}, "page": { "num": 1, "size": 20 } }, "extra": { "from": "search_home", "scene": "normal" } }

用Python处理这种嵌套结构,核心是慢一点,一层层构建字典,而不是试图一次性把整个JSON写成一坨。写之前先在抓包里把浏览器实际发送的payload原样复制下来,缩进格式化,用JSONPath或直接肉眼阅读,结构理清楚后,再按层级用代码构建:

payload = { "query": { "keyword": keyword, "filters": [ {"field": "brand", "values": brand_list}, {"field": "price", "range": {"min": min_price, "max": max_price}}, ], "sort": {"by": sort_by, "order": order}, "page": {"num": page_num, "size": page_size}, }, "extra": {"from": "search_home", "scene": "normal"}, }

这样做的意义是,到时候你需要调整搜索词、翻页、修改价格区间时,只改动外围变量,结构本身的可靠性不受影响。

JSON payload里也常有一些动态字段。最常见的是时间戳、随机数和token。有些接口的payload里会带一个sign,通过MD5、SHA256、HMAC等方式对若干字段和值进行签名。遇到这种接口,必须定位前端JS中签名的计算逻辑,在Python里复刻,或者直接用无头浏览器环境执行JS。这种内容我更愿意归到第四部分讲,因为处理思路已经不单纯是“格式对不对”的问题了。

3.4 处理JSON型payload时的内容安全提醒

如果你想采集的目标站点是某个电商平台、内容社区,写爬虫前务必看一下它的Robots协议和用户条款。合法的爬虫范围包括:你拥有数据的平台、你有权限访问的接口、以及目标方允许抓取的数据。不要把接口的签名参数逆向当作炫耀的资本,尤其不要拿别人的核心业务数据做商业用途。逆向学习建议在自己的测试站点或已经取得授权的接口上做实验,这既是保护自己,也是这行的基本操守。

4. 第三类:动态校验类payload——token、时间戳和签名参数的应对思路

4.1 multipart/form-data 和文件上传型payload

先说一种特殊形态。有些接口的payload在抓包里看起来杂乱无章,带一串boundary分隔线,把各字段和文件内容混合在一起,这就是multipart/form-data。它经常出现在头像上传、图片识别、附件提交这类接口上。在requests里处理起来其实很简单:

files = { "file": ("test.png", open("test.png", "rb"), "image/png"), } data = { "scene": "ocr", "user_id": "10001", } resp = requests.post( "https://example.com/api/upload", data=data, files=files, )

requests会自动根据data字段和files字段生成multipart格式,并附带boundary。你需要做的就是让代码里的字段名跟抓包里保持一致。文件上传接口里,有些后端会对文件头部做嗅探,检查真实格式是否和声明的Content-Type一致,如果强行把文本文件后缀改成png会报错。

另一类更值得一提的场景是:明明你在浏览器里看到的是JSON格式,但后端要求Payload签名动态支付。类似京东爬虫场景里常见的风控对抗,表面上Payload看起来就是JSON,但里面每个固定字段都可能过了一层签名算法,后端会对请求体的完整性做校验,body里被篡改任何一个值都会直接拒绝。这种就不能靠调参数解决了。

4.2 动态payload的标准解决思路:定位与执行

碰到payload里带一个看似随机或加密的参数,比如常见的sign_signaturetokennonce,常规思路是三步走。

第一步,确定加密字段从哪里来。浏览器里把Payload和Headers连起来看,如果这个值在第一次页面返回的HTML或某个初始化接口里,那通常是服务端下发的。比如登录接口需要的csrf_token,一般藏在页面meta标签里,或者藏在Cookie里,爬虫可以先GET一次拿到再拼到POST payload里。这种情况最简单,本质上是动态参数,但不是加密参数。

第二步,如果值是通过JS计算出来的,那就需要找到计算入口。在Sources面板里搜索这个参数名,定位到赋值语句,然后顺藤摸瓜找到生成函数。运气好的话,加密算法是MD5、SHA256、AES的某一种简单组合;运气不好,会遇到Webpack打包的混淆代码。

第三步,在执行层面有三条路可走。第一,把算法用Python复刻,适合逻辑简单且不含环境检测的加密;第二,用Python调用JS执行环境,比如PyExecJSjs2py,把从页面上扒下来的加密函数原封不动跑一遍;第三,用无头浏览器或本地调试工具直接执行JS函数,适合强环境验证的场景。

以PyExecJS为例,常规使用方式是把需要执行的JS函数提取出来,放进一个独立的js文件,然后在Python中加载并调用:

import execjs with open("encrypt.js", "r", encoding="utf-8") as f: js_code = f.read() ctx = execjs.compile(js_code) sign = ctx.call("getSign", "field_value", "timestamp")

你可能会遇到JS执行环境和浏览器环境有差异、某些DOM API在Node里不存在的问题,通常可通过补环境来解决。这个过程比较枯燥,但这是逆向类爬虫的基础功。

提示:动态payload的处理难度上限很高。如果你只是在做数据采集,优先考虑把目标集中在没有复杂风控的接口上,或者寻找移动端H5的低版本入口,很多时候能用更低的成本解决问题。逆向加密参数的初衷应该是理解数据结构和学习验证体系,而不是制造对目标业务的攻击压力。

4.3 请求体里由Token、Cookie和时间戳协同构成的“隐形payload”

有些接口的Payload看起来只是一个简单的ID和页码,但服务端校验时并不只看body本身,还会把Cookie里的某个值、Header里的X-Token、以及body里的时间戳拼在一起做时效验证。这类场景里,payload未必复杂,真正的坑是你少传了某个配合的Cookie后,服务端对请求体解析不出来或者直接拒绝。排查时要养成一个习惯:把完整请求的headers、cookie、body三者放到同一个日志上下文里看。单独只盯着payload,往往解决不了问题。

我遇到过一个典型案例:某内容平台的搜索接口,body里只要传keywordpage,但第一次访问必须先访问首页拿到一个session_id,放在Cookie里。如果直接用requests.post而跳过前置请求,接口返回403,body怎么调都无效。这就是典型的“隐形payload链”——请求体本身不复杂,但它依赖另一个请求的状态。

5. PyCharm只显示Process finished with exit code 0?先检查是不是payload没发对

5.1 现象本身说明什么

搜索热词里有一类问题出现频率特别高:爬虫代码在PyCharm里运行,控制台没有输出预期数据,也不报红,最后一行只有“Process finished with exit code 0”。很多新手以为代码没进入爬虫逻辑,其实是代码正常执行完毕了,exit code 0意味着Python进程没有抛出异常,只是你的代码逻辑没有把内容打印出来,或者请求发出去后收到的是个空响应。

最常见的原因有三个:一是脚本只在有数据时才打印,而请求返回的结果本身就是空的;二是代码走到了异常分支,但异常被吞掉或者只写进了日志文件;三是请求直接拿不到数据,比如被拦截、被重定向,或者需要动态payload的接口只发了一个空body过去,服务器返回200但数据列表为空。

而payload相关的坑通常是最后一个。因为许多反爬策略不会直接返403或封IP,而是对非法请求返回一个“成功但没数据”的响应,例如返回状态码200、错误码0、但data为空数组。如果你把返回结果直接打印出来,就会看见一个空列表。没有异常、没有报错、退出码0,一切看起来正常,但就是没数据。

5.2 完整排查链路:从response到原始请求体对照

如果你也遇到了这种“静默失败”,按下面的链路一步步排查:

第一步,把收到的响应原文打印出来,不要只打印解析结果。在请求代码后面临时加一行:

resp = requests.post(url, json=payload, headers=headers) print(resp.status_code) print(resp.text) # 先看原文

如果resp.text里是一个带message字段的JSON,比如{"code": -1, "msg": "invalid request"},那问题基本确认在请求构造上。

第二步,检查你发出的请求体格式是否和浏览器里完全一致。打印出最终编码后的body:

req = requests.Request("POST", url, json=payload, headers=headers) prepped = req.prepare() print(prepped.body)

这一步能直接看到requests实际编码完成后的body长什么样。和浏览器F12里Payload标签页的内容做对比,逐字段检查。

第三步,检查headers里是否少了关键项。很多服务端要求Content-TypeOriginRefererX-Requested-With同时到位。尤其当接口返回的是403的HTML而不是JSON时,八成是Referer或Origin没过。

第四步,如果body看起来没问题,检查是不是登录态失效。有些payload本身是固定的,但接口会校验Cookie或Authorization头,一旦过期,就返回200加空数据。你要做的就是重新登录,刷新Cookie。

第五步,在代码里对异常分支做打印。不要只写try...except: pass,至少把异常信息打出来:

try: resp = requests.post(url, json=payload, headers=headers, timeout=10) resp.raise_for_status() except Exception as e: print("请求异常:", repr(e))

做完这套链路,80%的“静默失败”都能定位到具体环节。

5.3 我的一次真实排查经验

有次爬一个需要登录的报表接口,脚本在PyCharm里跑,只显示exit code 0,完全没有输出。我第一反应是代码的print位置不对,但怎么改都没用。后来把response.text打印出来,发现返回了一串很长的JS重定向逻辑。原因是那个接口对没有正确Referer的请求会302到登录页,而requests默认会跟随重定向,最终拿到的HTML根本不是接口数据。

我把headers里补上了完整的Referer,并关闭自动重定向看响应头:

resp = requests.post(url, json=payload, headers=headers, allow_redirects=False) print(resp.status_code) print(resp.headers.get("Location"))

结果发现每次都会被302到同一个登录页。最终排查下来,是payload里的一个动态token字段过期了,我每次请求用的是同一个旧token,被服务端识别后强制跳转。更新token获取逻辑后恢复正常。

这类现象非常容易误判。如果控制台只显示exit code 0,不要急着去看代码逻辑,先确认请求发出后服务端到底回了什么,再回头怼payload。

6. 把payload处理沉淀成一套高效工作流

6.1 建立请求构造与字段清单

处理payload最怕的是什么?是改着改着忘了哪些字段是固定的、哪些是从哪个接口返回的、哪些又需要动态计算。我个人习惯在项目里维护一份请求字段配置,把每个接口的请求要素列清楚。

比如针对一个搜索接口,我会整理成这样一份内部笔记:

接口名: search URL: POST https://example.com/api/search Content-Type: application/json 固定字段: appid=android_touch, scene=search 动态字段: keyword — 搜索词,从业务层传入 page — 页码,循环翻页时递增 ts — 时间戳,int(time.time()) sign — md5(appid + keyword + page + ts + secret),小写 必需Header: Cookie: 从登录接口返回后存储 Referer: https://example.com/search

有了这张表,写代码的时候就可以直接照着搭结构,不需要每次重新抓包分析。字段多了之后,建议把每个字段的来源写清楚,是恒定值、用户输入、上个接口返回,还是本地计算。来源一清,排查问题时就能快速定位到某个环节。

6.2 封装一个统一请求函数,把payload的差异收敛到内部

实际拿一个站点练手的时候,不要为每个接口单独写一遍requests.post。可以把请求过程封装一下,这样大部分格式不一致的问题在入口处就被消化了:

import requests import json import time SESSION = requests.Session() def build_sign(params: dict, secret: str) -> str: """ 以示例后端为例,签名规则为: 把params中所有非空值按key排序,拼成 k=v&k2=v2,再拼上secret做md5 """ sorted_items = sorted( (k, str(v)) for k, v in params.items() if v is not None ) raw = "&".join(f"{k}={v}" for k, v in sorted_items) + secret # 实际项目里请按目标站点的签名规则替换 import hashlib return hashlib.md5(raw.encode("utf-8")).hexdigest() def send_request(url, method="POST", payload=None, headers=None, cookies=None, need_sign=False, secret="", timeout=10): headers = headers or {} if not payload: payload = {} # 记录请求详情便于复盘 print(f"[请求] {method} {url}") print(f"[Payload] {json.dumps(payload, ensure_ascii=False)}") if method.upper() == "POST": if isinstance(payload, dict) and need_sign: payload["ts"] = int(time.time()) payload["sign"] = build_sign(payload, secret) resp = SESSION.post(url, json=payload, headers=headers, cookies=cookies, timeout=timeout) else: resp = SESSION.get(url, params=payload, headers=headers, cookies=cookies, timeout=timeout) # 打印响应状态和前200字符,便于快速判断是否正常 print(f"[响应] {resp.status_code}, {resp.text[:200]}") return resp

这个封装的思路很朴素,就是让所有请求经过同一套打印、签名、错误处理逻辑。你可能会觉得多写几行代码没必要,但实际跑采集任务时,这一层能帮你节省大量试错时间。

6.3 从Payload出发的日常调试工具链

最后分享几个我常用的辅助手段。第一,浏览器F12里的Payload标签页和请求头必须配合看,只看Body不看Content-Type是白搭。第二,抓包可以用Charles或Fiddler,但现在的HTTPS站点多了,如果手机端抓包困难,可以优先在电脑浏览器里把接口完整请求复制成cURL命令,再用工具直接转成Python代码。第三,如果你经常做爬虫,建议在PyCharm里装一个HTTP Client插件,它能直接模拟POST请求并保留历史请求,调payload比反复改代码快得多。

以上提到的三种payload处理方式,基本覆盖了常规采集任务中请求体层面的绝大多数问题。真实项目中我没有见过哪次payload问题能靠一个固定脚本通吃所有接口,但只要抓住“Content-Type决定格式、动态字段要看来源、失败先看响应原文”这三条主线,处理起来就不会没头绪。

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

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

立即咨询