ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

爬虫必知:表单、JSON、查询参数三类Payload全解析与逆向调试

爬虫必知:表单、JSON、查询参数三类Payload全解析与逆向调试 我刚开始写爬虫那会儿最头疼的不是反爬不是IP封禁而是请求数据里那几种花花绿绿的payload。翻了无数文档看了不少帖子结果大家都在讲headers、讲cookie对payload的处理反而一笔带过。等我自己上手调试了十几个目标站点之后才明白payload其实就是爬虫和服务器对话时的“内容底牌”你传得对不对直接决定了服务器是给你数据还是给你一纸封禁通知。这篇文章我想把爬虫里最常见的3种payload——表单型、JSON型、查询参数型从识别、构造到调试完整地拆开揉碎讲一遍。内容包括我实际踩过的坑、用过的排查思路以及一些网上不太容易找全的参数逆向技巧。不管你是刚开始学Python爬虫还是已经在写scrapy、写分布式采集只要和requests、和接口参数打交道这篇内容应该都能帮上忙。1. 先从payload的本质说起1.1 为什么爬虫要死磕payloadHTTP请求本质上就是客户端给服务器递话。递话的方式有很多种你可以在URL后面的问号里带参数这是查询参数你可以在请求体里塞一串字符串这是数据体还可以把这些内容包装成特定格式比如JSON、表单URL编码、multipart等等。这里的“话”就是payload也就是实际传给服务器的核心数据。很多刚开始写爬虫的朋友容易走入一个误区觉得只要把URL弄对了带上几个headers就能轻松拿到数据。但其实对于大量真实站点来说URL和headers只是敲门砖payload才是决定你能不能被放进数据仓库的钥匙。尤其是那些带搜索、翻页、筛选条件的接口服务器完全依靠payload里的字段去查询、去校验、去返回结果。如果你payload里的参数少了、错了一个字母或者类型对不上接口就可能返回空数据、错误码甚至让你的IP直接被风控盯上。我自己在采集某电商平台的商品列表时就遇到过这种情况。URL看着挺正常headers也模仿了浏览器但结果翻页翻到第三页就返回一个奇奇怪怪的code码。后来排查出来是payload里的一个页码字段从字符串类型变成了数字类型服务器解析出错拿我当异常用户处理了。所以要写好爬虫得先学会管理payload理解它的结构懂得服务器对这些数据的解读方式。1.2 payload在HTTP请求里的三种存在形态从我的实际经验和网上各种热门讨论来看爬虫里最常见的payload无非三种形态。第一种是表单型form data。它的特点是请求头里的Content-Type通常是application/x-www-form-urlencoded或者multipart/form-data数据像query string那样由keyvalue用符号连接。这种格式多出现在传统的网页表单提交、登录操作、老一些的PHP站点后台交互里。第二种是JSON型。请求头的Content-Type是application/jsonbody是一段JSON字符串。现在前后端分离的站点越来越多很多内部接口都采用JSON格式来传复杂嵌套的数据尤其适合包含列表、对象、数组混合结构的场景。第三种是查询参数型query string。严格说它也属于URL的一部分就是问号后面那串keyvalue对。很多GET请求的筛选条件、翻页页码、关键词搜索都会通过查询参数传给服务器而请求体则是空的。这三种payload在实际项目里可能单独出现也可能混合出现。比如一个POST请求既在URL里带了一些追踪参数又在body里传了JSON数据。理解它们各自的特性和组合方式是后续所有处理技巧的基础。2. 表单型payload的构造与处理2.1 表单数据的常见结构与server解析逻辑表单型payload最常见的形态就是a1b2chello这种URL编码字符串。浏览器在提交普通HTML表单的时候默认就是按这个格式把用户输入的数据塞进请求体里。服务端拿到请求体后会按照Content-Type里指定的字符编码去解码这些键值对然后填充到后台语言对应的请求对象里。处理这种payload时有一个很容易被忽略的细节字段的顺序。虽然理论上URL编码的表单本质是一组无序键值对但有些后端框架会按固定顺序解析或者某些网关会依据参数顺序做签名校验。我在处理一个老系统的登录接口时就发现调整了参数的排序之后原本能通过的请求直接报签名错误。后来老老实实按照浏览器原始请求里参数的顺序去构造才恢复正常。还有一个常见问题是空值字段。有些表单里会存在 标签但没有填内容的情况浏览器依然会把该字段以key空值的形式提交上去。如果你在构造payload时漏掉了这个空字段部分严谨的后端会直接判断为非法请求。所以遇到表单型payload我建议先把浏览器请求里的完整字段列表抓下来哪怕某些字段看似没意义也要原样保留。2.2 用requests库处理表单型payload的实操写法在Python爬虫里requests库对表单型payload的支持非常友好。你只需要把字段放在data参数里传一个字典requests会自动完成URL编码并设置合适的Content-Type。import requests url https://example.com/api/login payload { username: test_user, password: your_password, remember: 1, csrf_token: a1b2c3d4e5 } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/login } resp requests.post(url, datapayload, headersheaders) print(resp.status_code) print(resp.text)这段代码看似简单但有几个隐藏细节值得说明。第一requests的data参数接收字典时会默认按照字典键的插入顺序编码。Python 3.7之后dict保证有序所以如果你需要严格控制字段顺序只要按顺序定义字典就行。第二如果你的字段里某个value本身包含、、中文等特殊字符不需要手动去转义requests会帮你处理。但你如果自己拼字符串去传data那就得小心这些字符带来的坑。比如密码字段里刚好包含了符号自己拼字符串的话服务器解析出来的字段就会多出一个甚至多个键值对直接导致登录失败。第三对于文件上传如果Content-Type是multipart/form-data那就不能简单用data传字典了得用files参数配合元组来构造。下面是常见的写法files { file: (report.xlsx, open(report.xlsx, rb), application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) } resp requests.post(url, filesfiles, data{note: upload test})这里data和files同时使用requests会把data字段和文件字段组合成multipart格式。很多新手在这里容易忽略一个点上传接口往往会同时包含一些业务字段比如上传者ID、备注、分类ID等这些字段要放在data参数里而不是混进files里。2.3 动态token、验证码等特殊字段的处理心得表单型payload里的字段并不总是静态的。像CSRF token、用户session相关的随机字符串、时间戳等往往需要从之前的页面或接口中提取出来动态放入payload里。我一般会分两种情况来处理。第一种token出现在登录页的HTML源码中。可以先请求登录页然后用正则或者lxml把隐藏的input标签里的value提取出来。比如input typehidden namecsrf_token valueabc123def456对应的解析代码可以这么写from lxml import html page_resp requests.get(https://example.com/login, headersheaders) doc html.fromstring(page_resp.text) token doc.xpath(//input[namecsrf_token]/value)[0]提取之后再把token塞进登录的payload里。这里要注意有些站点的token是绑定cookie的同一个token和同一个cookie配套使用才有效。所以requests.Session()在这里就非常关键要保持同一个session实例来维持cookie的连续性。第二种token不是显式写在HTML里的而是由页面上的一段JavaScript动态生成然后塞进某个全局变量里。这种情况下我得打开浏览器开发者工具的Sources面板找到生成token的JS函数看看它的加密方式。有些是用固定的拼接规则有些是做一个简单的混淆并不一定非得逆向出全部JS逻辑只要找出规律在Python里复现生成函数就行。比如有一个站点它的token生成逻辑是base64编码当前时间戳加上固定盐值。那我只需要在Python里按同样的方式生成token不需要去执行JS。import base64 import time salt fixed_salt_string ts str(int(time.time() * 1000)) token base64.b64encode(f{ts}{salt}.encode()).decode()这种处理方式比引入重型无头浏览器方案要轻量得多也不容易被目标站点通过检测webdriver特征而拦截。但前提是你得先把JS逻辑给啃明白。3. JSON型payload的构建与调试3.1 JSON payload的嵌套结构处理JSON型payload现在几乎是现代Web应用的主流。前端把用户填写的各种信息转换成一个结构清晰的对象然后通过JSON.stringify变成字符串放到请求体里传给后端。后端再用JSON解析库还原成内存对象。JSON的好处是表达能力强可以轻松嵌套多层结构。比如一个搜索接口的payload可能是这样的{ query: 笔记本电脑, filters: { price: {min: 2000, max: 8000}, brands: [联想, 惠普], in_stock: true }, page: 1, page_size: 20, sort: sales_desc }在Python里构造这种payload直接写嵌套字典和列表就行然后传给requests的json参数库会自动序列化并设置Content-Type为application/jsonpayload { query: 笔记本电脑, filters: { price: {min: 2000, max: 8000}, brands: [联想, 惠普], in_stock: True }, page: 1, page_size: 20, sort: sales_desc } resp requests.post(url, jsonpayload, headersheaders)这里有一个很值得注意的点requests的json参数和data参数是有区别的。data传字符串时不帮你做任何处理而json参数会自动将字典序列化成JSON字符串同时自动设置Content-Type为application/json。如果目标接口对Content-Type有严格要求用json参数就省心很多。但在实际逆向接口的过程中我发现很多前端代码并不会简单地post一个字典而是会做二次处理。比如把某个子结构单独JSON.stringify再作为父级字段的字符串值。这种“字符串套字符串”的结构在Python里构造起来比较绕你得先把子结构转成字符串再塞进外层字典。sub_config { sort_type: 1, show_unavailable: False } payload { keyword: iphone, config: json.dumps(sub_config, ensure_asciiFalse), page: 1 }如果你漏掉了这层序列化直接把sub_config字典传进去发出的JSON结构就变成了嵌套对象而后端期望的可能是字符串就会解析出错或者忽略这个字段。这种问题调试起来比较隐蔽因为从浏览器的开发者工具里看payload显示的是被转义过的字符串你需要仔细对着结构逐层比对。3.2 JSON payload中的动态参数补全和表单型一样JSON payload里也会有大量的动态字段。最常见的包括时间戳、请求ID、签名值。特别是签名值几乎成了很多站点的标配。举个例子我之前采集一个资讯类App的接口发现它的payload里有一个sign字段。我对比了多个请求之后发现这个sign是32位十六进制字符串而且同一个参数在不同时间请求就不一样。为了找到sign的生成逻辑我的做法是先用浏览器的“搜索”功能在JS文件里搜索“sign”这个关键词定位到生成函数。然后看代码逻辑。那次的逻辑比较简单把所有参数按key的字母顺序排序拼接成字符串再加上一个固定的appSecret最后做MD5。在Python里复现这一步很容易import hashlib import time def generate_sign(params: dict, app_secret: str) - str: sorted_keys sorted(params.keys()) raw_string .join(f{k}{params[k]} for k in sorted_keys) raw_string app_secret md5 hashlib.md5() md5.update(raw_string.encode(utf-8)) return md5.hexdigest() payload { keyword: 数码产品, page: 1, timestamp: str(int(time.time())) } payload[sign] generate_sign(payload, your_app_secret)但并不是所有站点的签名都这么简单。有些会用HMAC-SHA256有些还会加入随机nonce甚至会把某些值经过多次编码再参与签名。遇到这些情况我没有别的捷径就是一条条理清楚每个参与签名的字段确认它们的排序方式、分隔符、是否包含外层字段名然后写独立的函数去复现。测试时用一个固定的timestamp去验证生成的签名是否和浏览器里某个请求保持一致如果一致说明算法复现成功。3.3 异步接口和预请求中的JSON payload除了正常页面请求现在很多站点会在真正拉取数据之前先发送一个“预请求”或者“埋点请求”。这些请求的payload往往是JSON结构承载着客户端环境信息、用户行为数据等。有时候服务器会基于预请求的响应结果生成后续请求要用的关键token。我在处理一些单页应用时就遇到过一个情况页面数据接口的payload里必须带上一个trace_id而这个trace_id是之前某个埋点接口的响应里返回的。如果跳过了预请求直接请求数据接口服务器就会认为请求非法返回403。所以处理这类站点时别急着直奔目标接口。先用浏览器开发者工具里的Network面板按时间顺序把整个页面加载过程中的所有XHR请求过一遍找出哪个请求先发送、哪个响应里的字段被用在了后续payload里面。这个过程比较费时间但一旦理清了链路后面就好办了。我的习惯是先把完整的请求链用笔记记下来标注每个接口的入参来源、出参去向然后再动手写代码。这样写出来的爬虫更稳健也更容易排查问题。4. 查询参数型payload与签名校验的破解4.1 查询参数型payload的解析与重组查询参数型payload是我们最熟悉的一种就是URL问号后面的内容。比如https://example.com/api/list?keyword手机page2sortprice_asc对于这种payloadrequests库的处理很简单用params参数传字典即可params { keyword: 手机, page: 2, sort: price_asc } resp requests.get(https://example.com/api/list, paramsparams, headersheaders)requests会自动把字典拼接到URL后面并正确处理特殊字符编码。如果遇到参数里有中文requests会自动进行URL编码不用自己手动去quote。但有时候从浏览器里复制的URL本身已经包含了编码过的字符。比如https://example.com/api/list?keyword%E6%89%8B%E6%9C%BA这种时候如果你把URL直接传给requests再去params传一个keyword可能会造成参数重复。所以我不建议在同一个请求里既用含参数的URL又传params。正确做法是提取出干净的URL再把参数单独放到params里。这样后续修改翻页页码、关键词时只需要改动字典代码更清晰。查询参数型payload最常见的坑有两个。第一个是参数重复。有些接口允许同一个key出现多次比如https://example.com/api/search?tag手机tag电脑tag平板requests的params传字典时重复key会被后面的覆盖掉只保留一个。要传递重复参数需要传一个列表的元组params [ (tag, 手机), (tag, 电脑), (tag, 平板) ]这种写法在处理多选筛选条件的站点时特别有用。第二个坑是参数顺序影响缓存或签名。某些CDN或者网关会对查询参数排序后生成缓存键如果你每次请求的参数顺序都不同可能会导致缓存命中率降低服务器压力增大从而触发反爬策略。所以在构造查询参数型payload时尽量按浏览器原始请求的参数顺序来组织。4.2 签名参数的生成逻辑与逆向思路查询参数型payload里同样可能出现签名参数。和JSON payload里的签名一样它也是为了防止参数被篡改。常见的签名逻辑是从所有参数中剔除sign本身把剩余参数按某种规则排序拼接再加上一个密钥然后做哈希。我在逆向一个二手交易平台的搜索接口时发现它的签名算法比较典型。它把所有非空参数按key的ASCII码升序排列用连接但value要做两次URL编码。然后把拼接后的字符串末尾加上固定密钥做MD5再转成大写。整个过程有几个容易出错的地方空值参数不参与签名、大小写转换要在MD5之后、URL编码的字符集必须保持一致。我把这个逆向过程记录下来给大家参考import hashlib from urllib.parse import quote def sign_request(params: dict, secret: str) - str: filtered {k: v for k, v in params.items() if k ! sign and v not in (None, )} sorted_items sorted(filtered.items(), keylambda item: item[0]) raw .join(f{k}{quote(str(v), safe)} for k, v in sorted_items) raw secret return hashlib.md5(raw.encode(utf-8)).hexdigest().upper()这里有一个非常关键的细节quote的safe参数设置为空字符串意味着所有保留字符也会被编码。如果safe使用默认值“/”那URL里的斜杠就不会被编码导致拼接出来的字符串和站点实际签名用的字符串不一致签名校验就会失败。这个坑特别隐蔽因为浏览器开发者工具里看到的请求参数都是编码过的不容易直接比对。遇到这种带签名的查询参数型payload我的建议是先把所有参数固定住在Python里复现一次签名然后和目标请求里实际的sign值对比。如果一致说明算法复现成功如果不一致就从参与签名的字段范围、排序规则、编码方式、密钥来源这几个方向逐一排查。4.3 针对分页和筛选条件的动态组合查询参数型payload还有一个显著特点它经常和用户的操作行为绑定。你点了第2页URL里多了page2你勾了一个筛选条件URL里多了filterxxx。爬虫要覆盖大量数据就得分页、切换关键词、改变筛选条件动态组合这些参数。我的做法是先把基础的固定参数写成一个字典然后根据循环逻辑去更新其中的动态字段。比如base_params { sort: default, city: 上海市, page_size: 20 } for page in range(1, 11): params base_params.copy() params[page] page resp requests.get(url, paramsparams, headersheaders) # 解析响应提取数据这里要注意不要直接修改base_params字典而是用copy()生成新字典避免循环中参数污染。另外有些接口对分页最大值有限制比如最多只能查到第100页超过之后返回空列表。这时候不要盲目增大页码而应该尝试切换筛选条件或者关键词去细分数据范围。我还遇到过一种情况分页参数不是简单的页码而是游标cursor形式。服务器返回的响应里包含一个next_cursor字段下一次请求的payload必须带上这个游标值。这种游标分页在短视频、社交类站点上非常常见。处理这类接口时要把游标作为动态状态管理起来cursor None while True: params { count: 20, } if cursor: params[cursor] cursor resp requests.get(url, paramsparams, headersheaders) data resp.json() items data.get(items, []) if not items: break # 处理items cursor data.get(next_cursor) if not cursor: break游标分页的循环退出条件非常重要。除了items为空、next_cursor为None之外还要考虑数据重复和数据总量上限。比如游标可能陷入循环导致同一页数据反复获取。所以我在代码里会额外加一个去重集合记录已经出现过的item_id如果连续多次拿到重复数据就强制结束循环。这个策略虽然不能百分百防止反爬但至少能避免无限循环浪费资源。5. 常见问题与排查技巧实录5.1 反爬拦截时的payload调整思路爬虫做到一定阶段总会碰到反爬拦截。拦截的表现形式多种多样返回一个验证码页面、返回一段JS挑战代码、直接封IP或者返回一堆伪造数据。很多人第一反应是频繁换IP、调整请求频率但忽略了payload本身可能已经暴露了爬虫身份。举个例子有些站点会在payload里检查一个由前端生成的设备指纹字段。如果你每次请求都用同一个固定指纹而同一个IP的访问量又特别大就容易被关联起来判断为脚本。解决思路是把指纹改成随机生成并对每个任务会话绑定不同的指纹。还有一些站点会对参数值的取值范围做合理性校验。比如一个正常的用户不可能在1秒内请求100次搜索接口也不可能让搜索关键词极其重复。这种时候即使payload结构完全正确服务器也会通过统计特征把你识别出来。破解思路是把请求频率降低、把参数值打散、模拟真实用户的操作间隔。这不是技术上的破解而是从行为特征上去贴近真人。如果碰到比较严格的签名校验比如payload里的签名值和服务器计算的不一致就会直接返回错误码。这种情况通常是签名算法没复现完全或者参与签名的字段范围不对需要在浏览器里重新抓包核对所有参数。别急着怀疑目标站点有风控先检查自己的代码。5.2 编码与字符集的坑payload处理里编码问题永远是个大坑。最常见的就是中文参数的URL编码问题。requests虽然会自动编码但不同的站点可能使用不同的编码方式有的用UTF-8有的用GBK还有的在URL编码前先做了一次Unicode转义。我在处理一个老牌论坛的搜索接口时就发现它的keyword参数必须按照GBK编码后再URL编码否则搜索不到任何结果。requests默认使用UTF-8直接传中文就会失败。解决办法是先手动编码from urllib.parse import quote keyword 数码产品 encoded_keyword quote(keyword.encode(gbk)) url fhttps://example.com/search?keyword{encoded_keyword}这里还有一个小技巧如果你不确定目标站点用的什么编码可以用浏览器的开发者工具直接复制一个请求出来看URL里中文是被如何编码的。然后根据编码结果反推编码格式。比如%E6%89%8B%E6%9C%BA是UTF-8编码的“手机”而%CA%D6%BB%FA是GBK编码的“手机”。JSON payload里的编码问题也很常见。如果你用requests的json参数它默认会用UTF-8编码。但某些老接口后端可能是用GBK去解析请求体的这时候就会产生乱码。解决办法是手动把数据转成JSON字符串再编码成目标字符集放到data参数里传import json payload_str json.dumps(payload, ensure_asciiFalse) resp requests.post(url, datapayload_str.encode(gbk), headers{ Content-Type: application/json; charsetgbk })ensure_asciiFalse这个参数很重要它确保中文字符不被转成\uXXXX的形式而是保留可读的原始字符。5.3 日志与调试技巧最后聊一下排查payload问题时我自己非常受用的调试方法。写爬虫的时候千万别只在报错时才去看请求长什么样平时就要养成打印请求详情的习惯。requests库提供了一个非常方便的方法Request对象的prepare()方法可以生成完整的prepared request然后用prepared.headers、prepared.url、prepared.body查看将要发送的内容。req requests.Request(POST, url, jsonpayload, headersheaders) prepared req.prepare() print(URL:, prepared.url) print(Headers:, prepared.headers) print(Body:, prepared.body)这种方法对比浏览器里的实际请求非常高效。你可以把prepared出来的Body和开发者工具里的Request Payload并排放在一起逐字符核对差异。有时候一个不显眼的空格、一个不同的引号就可能导致服务器返回异常。还有一个小技巧给requests加一个事件钩子在每次请求完成后自动记录请求状态和响应片段方便事后复盘。import requests def log_request(response, *args, **kwargs): print(fRequest URL: {response.request.url}) print(fRequest Body: {response.request.body}) print(fStatus Code: {response.status_code}) if len(response.text) 500: print(fResponse: {response.text}) session requests.Session() session.hooks[response] [log_request]日志里我会记录每次请求的时间戳、目标URL、payload摘要、响应状态码和响应耗时。这套日志体系在排查被封IP、数据不完整、字段缺失等问题时能帮你快速定位是哪个环节出了问题。排查payload问题还有个笨办法但确实有效直接用浏览器手动操作一次完整流程同时打开开发者工具的Network记录。然后把每个请求的payload完整复制出来和程序里发出的请求做对比。很多时候肉眼对比就能发现差异。不要一上来就怀疑反爬、怀疑网络先从最简单的payload差异开始排查。写在最后这几年代码写下来我最大的感受是爬虫里真正麻烦的不是那些高大上的分布式框架也不是复杂的JS逆向而是这些看似不起眼的payload细节。一个字段的顺序、一个参数的编码方式、一个签名的拼接规则都可能让整个采集任务功亏一篑。处理payload没有一个万能模板因为每个站点的开发者都有自己习惯的组织方式。但万变不离其宗你只要抓到请求的原始结构理解服务器的解析方式再用Python去精确复现问题基本都能解决。遇到不确定的地方多抓包、多对比、多打印请求详情这三板斧比什么高级技巧都管用。如果你现在正在为某个payload抓狂我的建议是先把请求原样抓下来用requests原样复现一遍确认能用之后再逐步把固定参数改成动态参数。别一上来就想把所有功能一步到位那样出了问题反而不容易定位。先把基础跑通了后面加任何功能都会顺很多。
返回列表