做爬虫的人,十个里有八个被payload坑过。尤其是刚入门的阶段,明明URL、headers、cookie全都核对过了,请求就是返回400或者参数错误,最后排查半天才发现,问题出在请求体(body)里的payload上。今天这篇就把我在实际抓取中反复处理的三种payload信息统一捋一遍:表单类payload、JSON类payload、还有混合复杂类payload。每一类我会讲清楚它长什么样、怎么构造、怎么排查,以及哪些地方最容易翻车。
“payload”这个词在爬虫语境里指的就是HTTP请求中真正传给服务器的数据体。你可以把它理解成你去食堂打饭时递过去的菜单——URL是你要去的窗口,headers是你的饭卡,payload才是你具体点了什么菜、要几分辣。服务器拿到请求后,真正解析执行的就是这部分内容。所以payload一旦搞错,整个请求基本就废了。
这篇文章适合正在学爬虫Python入门的人、被某个接口卡住一两天的开发者、以及想系统理解requests库中data、json、files参数到底有什么区别的读者。我会尽量用实际案例来讲,不做教科书式的名词堆砌。
1. 先搞清楚payload到底是个什么角色
1.1 从HTTP请求的角度理解payload
很多人刚学爬虫时,习惯把注意力放在URL和headers上,因为这两个东西在浏览器开发者工具里一眼就能看到,而payload藏在请求体里,点开“Payload”或“Request Body”标签才能看到。但恰恰是这个不起眼的区域,决定了你是在模拟一个真实请求,还是在发一个被服务器一眼识破的空壳请求。
HTTP请求按携带数据的位置可以分成三块:URL参数(query string)、请求头(headers)、请求体(body)。其中URL参数和请求体里的内容,合在一起就是广义上的payload。不过在日常爬虫开发中,大家说的payload大多数时候指请求体内容,也就是POST请求里的body部分。GET请求因为自身特点,一般不携带请求体,参数全拼在URL后面,这部分处理起来相对简单,只要注意urlencode编码就行。
POST请求的payload才是重头戏。服务器如何解析你的请求体,完全取决于Content-Type请求头的值。如果Content-Type是application/x-www-form-urlencoded,服务器就按key=value&key2=value2的方式解析;如果是application/json,服务器就按JSON格式解析;如果是multipart/form-data,服务器则会按照带boundary分隔线的格式去处理。你payload里的数据格式必须和Content-Type匹配,否则服务器解析出来就是一堆乱码,或者直接返回错误。
我举个例子你就明白了。假设你要登录一个网站,在浏览器里填写用户名和密码表单,提交时浏览器自动把数据编码成username=admin&password=123456这种格式,然后在请求头里加上Content-Type: application/x-www-form-urlencoded。如果你用Python的requests库发请求时只写了data={"username": "admin", "password": "123456"},requests会自动帮你编码并设置Content-Type,这没问题。但如果你手动把data换成json.dumps({"username": "admin"}),同时没有改Content-Type,那服务器按表单格式解析这串JSON文本,拿到的key就变成了{"username": 这种奇怪的字符串,登录必然失败。
1.2 三种典型payload格式的常见场景
根据我这些年抓取不同网站的经验,爬虫中真正高频出现的payload其实就三大类,每一类都有其典型的应用场景:
第一类是表单格式(application/x-www-form-urlencoded),常见于各类传统网站的登录接口、搜索接口、分页加载接口。这类接口后端通常用Java的request.getParameter、PHP的$_POST、Python Flask的request.form来取值,格式简单直接,参数都是扁平的键值对。
第二类是JSON格式(application/json),常见于前后端分离的现代Web应用、移动端App接口、小程序接口。现在前端框架流行,很多后端接口直接接收JSON对象,参数可以嵌套、可以传数组、可以传对象,结构比表单复杂,但可读性好。
第三类是混合复杂类型,包括multipart/form-data(文件上传)、GraphQL的查询体、JSON-RPC的调用结构、以及一些加密后的自定义格式。这类payload处理起来最麻烦,因为格式不标准,或者参数经过了额外处理,需要特殊对待。
搞清楚了这三类,再遇到任何接口,你就知道该用什么姿势去处理了。下面我分别细说。
2. 表单类payload的解析与构造
2.1 表单payload的特征和抓取方法
表单类payload是最古老、也最容易处理的一类,但容易处理不代表不会踩坑。这类payload的特征非常明显:请求头里Content-Type是application/x-www-form-urlencoded,正文是一串用&连接的键值对,比如:
username=testuser&password=abc123&remember=1值里面如果有特殊字符,会被URL编码,比如中文会被编码成%E4%BD%A0%E5%A5%BD这种形式,空格会被编码成+号。这一点很关键,很多人手工复制浏览器里的payload时容易忽略编码问题,导致请求发出去后服务器收到的是乱码。
抓取这类payload,最简单的办法是打开浏览器开发者工具,切到Network面板,刷新页面并触发目标请求,找到对应的接口后在Headers里往下拉,能看到Form Data区域,里面就是浏览器实际发送的表单内容。有些浏览器(比如Chrome)还会直接给你展示“view source”选项,点开能看到原始的编码后字符串。
如果你用抓包工具(Fiddler、Charles或Burp Suite),步骤也类似:拦截请求后在Inspectors的WebForms标签页里能看到解析后的表单参数,在Raw标签页里能看到原始报文。我个人习惯先用浏览器开发者工具看一遍结构,再用抓包工具确认原始字节内容,因为有时候浏览器会隐藏一些自动添加的字段(比如某些前端框架自动加的csrf token),这部分在原始报文里才看得到。
2.2 实际构造演示与常见坑
用Python的requests库构造表单payload非常简单,直接传data参数即可:
import requests url = "https://example.com/api/login" payload = { "username": "testuser", "password": "abc123", "remember": "1" } response = requests.post(url, data=payload) print(response.text)这段代码里,requests会做三件事:把payload字典urlencode成username=testuser&password=abc123&remember=1,自动设置Content-Type为application/x-www-form-urlencoded,然后发送请求。整个过程看起来没问题,但实际使用中有几个坑我踩过不止一次:
第一,某些服务器会严格校验Content-Type,如果requests自动设置的Content-Type里带了charset参数(比如application/x-www-form-urlencoded; charset=utf-8),部分老旧的服务器可能不认识。解决办法是手动指定headers中的Content-Type,去掉charset部分。
第二,如果表单中某个参数的值含有中文字符,要特别注意编码方式。requests默认会用utf-8进行urlencode,但某些老系统要求gbk编码。这时候需要手动编码:
payload = { "keyword": "爬虫".encode("gbk") }这样requests在urlencode时就会按gbk处理。
第三,表单参数的顺序在某些极端情况下是有意义的。正常来说服务器解析键值对不关心顺序,但有些后端框架会把所有参数按字典序排序后再做签名校验。如果你发现请求参数一样但总是签名失败,可以对比一下浏览器实际发送的参数顺序,必要时用有序字典(collections.OrderedDict)或自己拼接原始字符串来构造payload。
第四,注意复选框和多值参数。HTML表单里同名复选框可以勾选多个,提交后变成name=a&name=b这样的格式。在requests里对应的方法是传入列表:
payload = { "hobby": ["reading", "coding", "gaming"] }requests会自动展开成hobby=reading&hobby=coding&hobby=gaming。这块如果你不小心传成字符串"['reading', 'coding']",服务器收到的就是一段Python列表的文本表示,取值时自然对不上。
3. JSON payload的解析与处理
3.1 JSON payload的特征和动态参数
随着前后端分离架构的普及,JSON payload已经成为现在爬虫遇到最多的格式。它的特征很直观:Content-Type为application/json,请求体是一段符合JSON格式的文本,比如:
{ "page": 1, "pageSize": 20, "filters": { "category": "electronics", "priceRange": [100, 500] }, "sort": "sales_desc" }相比表单格式,JSON结构可以表达复杂的嵌套关系——列表、字典、多层嵌套都支持。这使得它在处理搜索条件、筛选条件、批量操作等复杂业务时非常有优势。但同时也意味着,如果你构造JSON时某个字段的层级错了、类型错了(比如整数写成了字符串),接口就会返回参数校验失败。
用requests构造JSON payload有两种方式:
import requests url = "https://example.com/api/search" payload = { "page": 1, "pageSize": 20, "filters": { "category": "electronics" } } # 方式一:直接用json参数 response = requests.post(url, json=payload) # 方式二:手动序列化并指定Content-Type import json headers = {"Content-Type": "application/json"} response = requests.post(url, data=json.dumps(payload), headers=headers)两种方式本质上一样,第一种更简洁。但第二种方式在一些需要自定义JSON序列化逻辑的场景下更有用,比如遇到datetime对象时需要自定义default处理。
JSON payload处理起来,真正的难点不在格式本身,而在于动态参数。很多网站为了防止自动化采集,会在JSON body里附加一些随时间或会话变化的字段,最常见的是timestamp和sign。timestamp就是当前时间戳,sign则是对请求参数进行某种算法运算后得到的签名值。服务器收到请求后会用同样的算法重新计算,判断两个值是否一致,不一致就拒绝请求。
3.2 应对动态签名与埋点参数
处理带签名的JSON payload,核心思路就一句话:找到签名生成逻辑,在本地复现它。我拿一个实际例子来说。之前我采集过一个电商平台的商品列表接口,它的JSON payload长这样:
{ "page": 1, "pageSize": 20, "keyword": "手机", "timestamp": 1712345678901, "sign": "a1b2c3d4e5f6..." }初看以为sign是随便生成的,但经过对比多个请求发现,sign的算法是:把所有业务参数(page、pageSize、keyword、timestamp)按照key的字母序排序,拼成字符串,再加上一个固定的salt值,最后做MD5。比如:
import hashlib import time def generate_sign(params, salt="s3cr3t"): sorted_keys = sorted(params.keys()) raw_string = "" for key in sorted_keys: raw_string += f"{key}={params[key]}&" raw_string += f"salt={salt}" return hashlib.md5(raw_string.encode("utf-8")).hexdigest() payload = { "page": 1, "pageSize": 20, "keyword": "手机", "timestamp": int(time.time() * 1000), } payload["sign"] = generate_sign(payload)找到规律后,本地复现就很简单。难的是那些把签名逻辑放在JavaScript里、经过Webpack打包混淆的站点。这种情况下,你有几条路可以走:
第一,硬啃JavaScript代码。把浏览器里加载的JS文件下载下来,搜索sign、md5、sha256、encrypt、token等关键词,找到加密函数,用Python重写一遍。这条路最稳定,但费时费力,适合JS逻辑不复杂的情况。
第二,用PyExecJS或Js2Py直接执行JS代码。把提取出来的加密函数原封不动地搬过来,用Python调用。这种方式的好处是不需要重写算法,坏处是如果JS依赖了浏览器环境(比如window对象、document对象),在Python里跑不起来。我之前用PyExecJS执行某个网站的登录加密逻辑,折腾半天发现它依赖了浏览器的localStorage,最后只能改用第三种方式。
第三,用Selenium或Playwright等浏览器自动化工具,在真实浏览器环境里执行JS获取加密结果。这种方式最稳,但速度慢、资源消耗大,一般只在其他方法搞不定时使用。
除了签名之外,JSON payload里还常见一些“埋点参数”,比如deviceId、fingerprint、uuid等。这些参数通常是在页面加载时生成的,存在cookie或localStorage里,后续请求会带上。处理思路就是首次访问页面时把这些参数抓下来,后续请求时填入。要注意的是,有些站点会校验参数的合法性和关联性(比如deviceId必须和cookie里的某个值对应),这时候就需要保持会话一致,用requests.Session来维持cookie和headers。
4. 复杂混合型payload与特殊边界情况
4.1 multipart和GraphQL等特殊格式
前面两类是主流,但爬虫真正让人头疼的,往往是那些非常规的payload。我挑了三种比较有代表性的情况来说:multipart文件上传、GraphQL查询、JSON-RPC调用。
multipart/form-data格式常见于上传图片、上传文件的接口,比如用户头像上传、商品图片上传、批量导入Excel等。它的请求体不是简单的键值对,而是用boundary分隔线把多个字段块分开,每个字段块内部包含了Content-Disposition、Content-Type和具体内容。长这样:
------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="file"; filename="photo.jpg" Content-Type: image/jpeg [二进制内容] ------WebKitFormBoundary7MA4YWxkTrZu0gW--用requests构造multipart请求非常简单,直接传files参数:
url = "https://example.com/api/upload" files = { "file": ("photo.jpg", open("photo.jpg", "rb"), "image/jpeg") } response = requests.post(url, files=files)requests会自动生成boundary并设置Content-Type。这里容易踩坑的点是:除了文件字段,有时接口还需要同时传业务参数,比如上传图片时附带说明文字。这时候data参数和files参数可以同时传,requests会把data里的普通字段和files里的文件字段合并到同一个multipart body里。
GraphQL接口现在也越来越多。它的特点是单端点(通常只有一个URL),所有查询都通过POST请求发送,请求体里是GraphQL查询语句和变量。payload结构固定为:
{ "query": "query($id: ID!) { user(id: $id) { name email } }", "variables": { "id": "123" } }处理GraphQL接口时,最需要关注的是query字段的内容。有的站点把查询语句写得很复杂,嵌套很深,你必须完全复现它的查询结构,少一个字段都可能返回报错。最好的办法是在开发者工具里直接复制浏览器发出的GraphQL查询语句,然后在Python里原样填入,只修改variables部分。
JSON-RPC常见于一些内部系统或者去中心化应用的接口。它的请求体结构固定为:
{ "jsonrpc": "2.0", "method": "getBlockByNumber", "params": ["0x10", true], "id": 1 }处理这类接口时注意两点:method必须和文档一致;params可以是数组也可以是对象;id字段一般用自增数字就行,但某些严格实现会校验id的唯一性,所以建议每个请求生成不同的id。
4.2 payload中的编码、混淆与防御识别
比格式更麻烦的是编码和混淆。有些接口的payload肉眼看着是JSON,但内容完全不可读,比如:
{ "data": "8f8f8f8f8f8f8f8f8f8f" }这种十六进制字符串一看就是加密后的数据。这种情况下,你需要先弄清楚加密发生在哪个环节。常见的情况有几种:一是前端先对业务数据做AES或RSA加密,再放到JSON里传输;二是整个payload外层套了一层加密,本质上只有解密后才能看到真正的业务字段。
面对这种情况,我的处理思路是分步走。先用浏览器调试工具在JS代码里打断点,找到加密函数和密钥;然后在Python端用对应的算法复现加密过程;最后把加密后的结果填入payload。比如某个接口用AES-128-CBC加密,密钥和IV都硬编码在JS文件里,那你用pycryptodome库就能轻松实现。
另外要留意的是,有些站点会在payload里塞一些“蜜罐”字段——这些字段对业务没有任何影响,但爬虫程序如果漏掉了,就会被判定为异常请求。比如一个列表接口,payload里有个字段叫fakeField,值为固定的"please_ignore_me",看起来无用但必须带上。这种字段在浏览器里不会显示,只有在完整报文里才能发现。所以我的习惯是:每次抓包时都看Raw报文,不要只看浏览器格式化后的参数视图。
还有一类比较恶心的处理是参数编码嵌套。举个例子,某个接口的payload从外表看是表单格式,但其中一个参数的值本身是URL编码后的JSON字符串:
data=%7B%22page%22%3A1%2C%22keyword%22%3A%22test%22%7D服务器收到后先URL解码,再按JSON解析这个参数值。处理这种嵌套时,需要先在内层用json.dumps构造JSON字符串,再做URL编码,最后传入外层表单数据:
import json from urllib.parse import quote inner_data = json.dumps({"page": 1, "keyword": "test"}) payload = { "data": quote(inner_data) # 注意这里的编码 }很多时候你发现接口总是报参数错误,排查到最后才发现是这种嵌套编码的问题。
5. 常见问题排查与调试技巧
5.1 payload出错时该怎么定位
我见过太多人在payload上翻车,而且翻车的姿势五花八门。为了帮你快速定位问题,我把这些年的排查经验总结成一张速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 服务器返回400 Bad Request | Content-Type与payload格式不匹配 | 核对请求头Content-Type,确认是form还是json |
| 返回403 Forbidden | 缺少动态签名或签名计算错误 | 检查sign、token等字段的生成逻辑 |
| 返回200但数据为空 | payload字段名与服务器期望不一致 | 对比浏览器实际发送的字段名和值 |
| 中文乱码 | urlencode编码不是服务器期望的字符集 | 尝试gbk或其他字符集编码 |
| 某些参数总是被忽略 | 参数嵌套层级错误 | 检查JSON结构,确认字段层级和类型 |
| 偶发失败 | payload里有时间戳或随机数参数 | 确认这些参数是否过期,动态更新 |
| 同一个payload用Postman可以但爬虫不行 | headers中cookie或user-agent不一致 | 复制浏览器完整headers到requests中 |
| 服务器返回参数校验失败 | 类型不对,如整数传成字符串 | 对照抓包内容检查字段类型 |
排查payload问题,我的建议是遵循“对比法”:先用浏览器正常操作,抓包记录一个真实请求;然后用Python原样复现这个请求,确保返回结果一致;最后再逐步修改参数,改成动态化。如果一开始就自己凭空构造payload,等于把“环境差异”和“参数错误”混在一起,排查起来非常费劲。
5.2 个人积累的实用工具与调试方法
我在处理payload时,有几个工具和习惯帮了大忙,分享给你。
第一个是Fiddler或Charles之类的抓包工具。虽然浏览器开发者工具足够应付大部分场景,但抓包工具能让你直接看到原始字节流、修改请求后重放、对比不同请求的差异。尤其是修改重放这个功能,我在调试签名参数时经常用:先发一个浏览器正常请求,保存在抓包工具里,然后复制到Python中,如果Python请求的响应和浏览器不一致,我再回到抓包工具里逐步修改参数来定位是哪个字段导致的。
第二个是Postman或Apifox。当Python代码调不通一个接口时,我会先用Postman手动构造一遍,确认接口本身是否正常,排除是不是自己的代码写错了。Postman里还能生成Python代码片段,哪怕不全依赖它,参考生成的代码也能帮你发现requests库的一些细节用法。
第三个是我自己写的一个小工具脚本:用Flask在本机开一个HTTP服务器,打印所有收到的请求细节。有时候某个接口回调我们自己的服务器(比如支付回调、webhook),想查看回调payload长什么样,就把它指向本地服务器,payload的所有细节一目了然。
from flask import Flask, request app = Flask(__name__) @app.route("/callback", methods=["POST", "GET"]) def callback(): print("Headers:", dict(request.headers)) print("Body:", request.get_data(as_text=True)) return "ok" if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)当你的爬虫程序同时服务于多个目标站点时,这种本地调试服务器几乎成了标配。
还有一个经验是:在爬虫代码里打印完整的请求信息,用于后续排查。requests库本身没有直接打印请求报文的功能,但你可以用一个简单的钩子函数来实现:
import requests from requests.structures import CaseInsensitiveDict def log_request(request): print(f"URL: {request.url}") print(f"Method: {request.method}") print(f"Headers: {dict(request.headers)}") print(f"Body: {request.body}") # 通过事件钩子查看请求 def on_request(response, *args, **kwargs): log_request(response.request) return response session = requests.Session() session.hooks["response"] = [on_request]这样每次请求发送前(实际是收到响应后回调),都能看到完整的请求信息,不会再出现“代码跑了一百遍也不知道发出去的是什么”的情况。
说到排查问题,这里多提醒一句:遇到payload相关的问题,先确认是不是编码问题,再确认是不是签名问题,最后再怀疑是不是反爬策略。因为编码问题最隐蔽但最好解决,签名问题复杂度居中,反爬策略排查成本最高。按这个优先级排查,效率会高很多。
还有个小技巧:用curl命令快速验证。把浏览器里的请求复制成curl命令(在开发者工具Network面板里右键Copy as cURL),在终端里执行一次,如果curl能正常返回,说明请求本身没问题,问题出在Python代码上;如果curl也返回异常,那就是请求构造本身不对。这个“浏览器-curl-Python”三步法,能帮你快速缩小问题范围。
6. 写在最后的几点实操体会
做爬虫这几年,payload这块我吃了不少亏,也积累了一些判断经验。这里分享几个我个人的习惯,不一定适合所有人,但至少能帮你少走弯路。
第一个习惯是:拿到一个新接口,我从不跳过抓包这一步直接写代码。哪怕接口看起来再简单,我都会先完整看一遍浏览器实际发送的报文——包括URL、所有headers、完整的body。因为浏览器会帮你处理很多隐含的内容,比如自动添加的cookie、token、Content-Type等,只看表面的Form Data很容易漏掉关键信息。
第二个习惯是:能用session就用session。requests.Session会自动管理cookie、维持连接池,还能方便地设置默认headers。很多payload里的动态参数(比如csrf token)其实是服务器通过cookie下发的,用session能保证cookie的一致性,减少很多麻烦。
第三个习惯是:注意payload里的空值。有些字段在特定场景下不传或传空字符串,在另一些场景下传null或0。这个细微差异往往决定接口是正常返回还是报错。我碰到过一个个例:列表接口的分页参数pageSize,正常请求传20,但如果传0,服务器会返回一个特殊的错误提示;如果完全不传这个字段,服务器则默认返回全量数据。
最后,我要特别强调一下合规问题。爬虫技术本身是中性的,但使用它时要遵守目标网站的robots协议、服务条款和相关法律法规。本文中所有示例都基于虚构或授权场景,实际开发中请务必确保你的爬虫行为合法合规,不要对目标服务器造成过大压力。技术能力越强,越需要自律。