爬虫这行干久了你会发现大部分请求被拒不是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就是一长串usernamexxxpasswordyyyremember1这种键值对。另一个常见接口呢Content-Type是application/json那payload就必须是合法的JSON文本。这两种写法在抓包工具里看起来差不多但在代码里混用的时候服务器就会翻脸。1.2 三类核心格式对照一眼识别该用哪种写法从爬虫实战的角度绝大多数payload只有下面三种。我建议你把这张对照表存下来进抓包面板第一件事就是看Content-Type和Payload的形态然后决定requests里该传什么参数。Content-TypePayload 形态requests 推荐写法application/x-www-form-urlencodedkeyvaluekey2value2data{key: value}字典自动编码application/json{key: value}json{key: value}自动序列化并改Headermultipart/form-data一段带boundary分隔符的混合内容文件与字段混合data{...}, files{...}requests自动生成boundary除了这三种偶尔也会遇到text/plain、application/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, datalogin_data, headers{User-Agent: Mozilla/5.0 ...}, )当data传的是字典时requests内部会按application/x-www-form-urlencoded的规则把字典编码成usernametest_userpassword123456remember1同时自动设置Content-Type。这是最稳的做法因为键值对的排序和转义都由库处理。但也有人喜欢自己先拼好字符串再丢进去payload_str usernametest_userpassword123456remember1 resp requests.post(url, datapayload_str, headers...)不是不行但一旦字段值里有中文、空格、特殊符号比如和你就必须手动做URL编码不然服务端解析会乱套。例如密码是abc123这种直接拼字符串会变成两个字段而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类型有几种反直觉的情况。第一某些搜索接口允许同一个关键字传多次例如tagpythontag爬虫这时不能直接用字典因为字典键重复会覆盖。需要用元组列表payload [(tag, python), (tag, 爬虫), (page, 1)]requests遇到这种结构会自动编码成两个tag字段。如果你用dict最终只会剩下最后一个tag值。第二有些接口的payload里带空字符串或者null很多新手不知道空字符串该不该带。后端如果对字段做了必填判断但允许空值那remark: 和直接不传这个字段是两回事。是否传空取决于你抓包时浏览器实际发送的body长什么样。抓包里出现了就原样传抓包里没出现就不要脑补。第三有些表单里会用数组结构比如多选下拉框提交成hobby1hobby2hobby3。跟重复字段一样用字典传就会丢数据。所以在处理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, datapayload_str, headers{Content-Type: application/json} )第一种写法更省事requests会自动把Python字典变成JSON字符串并且帮你设置Content-Type为application/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没问题。但如果你用datastr(payload)这种野路子就会把Python语法原封不动发出去服务端看到True和None直接解析失败。如果接口本身对布尔值和空值要求特别严格比如签名接口参与摘要计算的字段必须原样无变化你可能就需要手动构造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, datadata, filesfiles, )requests会自动根据data字段和files字段生成multipart格式并附带boundary。你需要做的就是让代码里的字段名跟抓包里保持一致。文件上传接口里有些后端会对文件头部做嗅探检查真实格式是否和声明的Content-Type一致如果强行把文本文件后缀改成png会报错。另一类更值得一提的场景是明明你在浏览器里看到的是JSON格式但后端要求Payload签名动态支付。类似京东爬虫场景里常见的风控对抗表面上Payload看起来就是JSON但里面每个固定字段都可能过了一层签名算法后端会对请求体的完整性做校验body里被篡改任何一个值都会直接拒绝。这种就不能靠调参数解决了。4.2 动态payload的标准解决思路定位与执行碰到payload里带一个看似随机或加密的参数比如常见的sign、_signature、token、nonce常规思路是三步走。第一步确定加密字段从哪里来。浏览器里把Payload和Headers连起来看如果这个值在第一次页面返回的HTML或某个初始化接口里那通常是服务端下发的。比如登录接口需要的csrf_token一般藏在页面meta标签里或者藏在Cookie里爬虫可以先GET一次拿到再拼到POST payload里。这种情况最简单本质上是动态参数但不是加密参数。第二步如果值是通过JS计算出来的那就需要找到计算入口。在Sources面板里搜索这个参数名定位到赋值语句然后顺藤摸瓜找到生成函数。运气好的话加密算法是MD5、SHA256、AES的某一种简单组合运气不好会遇到Webpack打包的混淆代码。第三步在执行层面有三条路可走。第一把算法用Python复刻适合逻辑简单且不含环境检测的加密第二用Python调用JS执行环境比如PyExecJS、js2py把从页面上扒下来的加密函数原封不动跑一遍第三用无头浏览器或本地调试工具直接执行JS函数适合强环境验证的场景。以PyExecJS为例常规使用方式是把需要执行的JS函数提取出来放进一个独立的js文件然后在Python中加载并调用import execjs with open(encrypt.js, r, encodingutf-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里只要传keyword和page但第一次访问必须先访问首页拿到一个session_id放在Cookie里。如果直接用requests.post而跳过前置请求接口返回403body怎么调都无效。这就是典型的“隐形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, jsonpayload, headersheaders) print(resp.status_code) print(resp.text) # 先看原文如果resp.text里是一个带message字段的JSON比如{code: -1, msg: invalid request}那问题基本确认在请求构造上。第二步检查你发出的请求体格式是否和浏览器里完全一致。打印出最终编码后的bodyreq requests.Request(POST, url, jsonpayload, headersheaders) prepped req.prepare() print(prepped.body)这一步能直接看到requests实际编码完成后的body长什么样。和浏览器F12里Payload标签页的内容做对比逐字段检查。第三步检查headers里是否少了关键项。很多服务端要求Content-Type、Origin、Referer、X-Requested-With同时到位。尤其当接口返回的是403的HTML而不是JSON时八成是Referer或Origin没过。第四步如果body看起来没问题检查是不是登录态失效。有些payload本身是固定的但接口会校验Cookie或Authorization头一旦过期就返回200加空数据。你要做的就是重新登录刷新Cookie。第五步在代码里对异常分支做打印。不要只写try...except: pass至少把异常信息打出来try: resp requests.post(url, jsonpayload, headersheaders, timeout10) 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, jsonpayload, headersheaders, allow_redirectsFalse) 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 固定字段: appidandroid_touch, scenesearch 动态字段: 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排序拼成 kvk2v2再拼上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, methodPOST, payloadNone, headersNone, cookiesNone, need_signFalse, secret, timeout10): headers headers or {} if not payload: payload {} # 记录请求详情便于复盘 print(f[请求] {method} {url}) print(f[Payload] {json.dumps(payload, ensure_asciiFalse)}) 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, jsonpayload, headersheaders, cookiescookies, timeouttimeout) else: resp SESSION.get(url, paramspayload, headersheaders, cookiescookies, timeouttimeout) # 打印响应状态和前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决定格式、动态字段要看来源、失败先看响应原文”这三条主线处理起来就不会没头绪。