1. 项目概述:为什么盯上大众点评的 Ajax 接口?
做本地生活数据采集、竞品分析或用户行为研究的朋友,几乎都绕不开大众点评。但你会发现,直接抓取网页 HTML 很快就会卡在反爬门槛上——页面渲染越来越重,动态加载越来越密,验证码、滑块、设备指纹轮番上阵。这时候,真正有经验的老手不会死磕 DOM 解析,而是把目光转向浏览器开发者工具里那个最安静、最高效、也最容易被忽视的角落:Network 面板里的 XHR(Ajax)请求。我第一次在大众点评商品页按 F12,点开“评论”标签页,看到一串以/review/all开头、返回纯 JSON 的请求时,心里就清楚:这才是正路。
这个项目标题里说的“深入分析”,不是泛泛而谈“怎么发个请求”,而是要拆到字节级:它用什么协议?参数怎么构造?签名怎么生成?响应体结构怎么嵌套?编码怎么处理?服务器怎么校验?这些细节,决定了你能不能稳定、批量、长期地拿到真实、完整、带时间戳和用户画像的评论数据。关键词里反复出现的ajax请求设置编码格式、failed to deserialize the json body into the target type: input: missing fie,恰恰是新手踩坑最密集的两个雷区——前者关乎请求头和字符集声明是否匹配服务端预期,后者则直指 JSON Schema 变动导致的字段缺失,根本原因往往不是代码写错了,而是你没摸清接口的真实契约。
适合谁参考?三类人最需要:一是做本地生活 SaaS 工具的产品/运营,需要定期拉取竞品门店的差评趋势;二是高校做消费者行为研究的学生,需要结构化评论语料训练情感模型;三是技术侧想练手真实工业级反爬对抗的开发者——大众点评这套体系,比教科书上的“加 User-Agent 就行”复杂十倍,但又不像金融或政务系统那样完全封闭,属于“跳一跳够得着”的高价值实战场。它不教你理论,只逼你动手:看 Network、扣参数、试签名、调编码、验 JSON、压并发、记日志。下面所有内容,都是我在过去两年里,为三个不同客户定制数据管道时,从生产环境里抠出来的真东西。
2. 接口底层逻辑与请求链路深度拆解
2.1 大众点评 Ajax 请求的本质:不是 RESTful,而是 RPC over HTTP
很多人一看到/review/all?shopId=xxxx就默认这是标准 REST 接口,其实大错特错。大众点评的 Ajax 接口本质是 RPC(远程过程调用)封装在 HTTP 上,核心特征有三点:第一,URL 路径固定,业务逻辑全靠 query 参数或 request body 驱动;第二,强依赖 Cookie 和 Header 中的会话态字段,尤其是__mta、_lxsdk_s、_lxsdk_cuid这三个字段,缺一不可;第三,关键参数(如page、sortType、filterType)表面是明文,实则受前端 JS 动态计算的签名约束,直接篡改会返回403 Forbidden或空数组。
举个实际例子:当你在网页上点击“最新评论”按钮,浏览器发出的请求 URL 看似简单:https://www.dianping.com/ajax/json/shop/wizard/GetReviewList?shopId=123456789&page=2&pageSize=10&sortType=1
但如果你复制这个 URL 到 Postman 直接 GET,大概率返回{"code":403,"msg":"Forbidden"}。为什么?因为服务端在收到请求时,会校验 Header 中的X-Shard、X-Requested-With,以及 Cookie 里的_lxsdk_s是否匹配当前 session 的加密摘要。更关键的是,page=2这个参数,在真实请求中会被前端 JS 用当前时间戳、shopId、随机数拼接后,经 MD5 再 Base64 编码,塞进另一个隐藏参数__ts里。也就是说,你看到的page=2是给人看的,服务端真正认的,是那个藏在__ts里的动态签名值。
提示:不要试图用 Python 的
requests.get(url)直接复现。必须完整复现浏览器发起请求时的全部上下文——包括 Cookie 初始化、Header 构造、参数签名、甚至请求间隔节奏。否则,99% 的失败都源于此。
2.2 核心参数解析:哪些能改,哪些碰都不能碰?
我把近半年抓取过的 17 类大众点评 Ajax 接口(覆盖店铺、团购、外卖、医美)的参数做了归类,提炼出四类字段:
| 字段类型 | 示例 | 是否可修改 | 修改后果 | 原理说明 |
|---|---|---|---|---|
| 强绑定会话字段 | _lxsdk_s=123abc...,__mta=456def... | ❌ 绝对禁止 | 403 或空响应 | 由登录态生成,含设备指纹哈希,有效期 2-7 天,过期需重新模拟登录 |
| 业务标识字段 | shopId=123456789,dealGroupId=987654321 | ✅ 可替换 | 获取目标店铺数据 | 必须与 Cookie 中的shopId上下文一致,否则返回{"code":500,"msg":"Invalid shopId"} |
| 分页控制字段 | page=2,pageSize=10,start=10 | ✅ 可调整 | 控制返回条数与偏移 | 注意:page和start不能共存,pageSize最大支持 20,超限返回 400 |
| 签名验证字段 | __ts=abcd1234==,__sig=efgh5678 | ❌ 不可硬编码 | 签名失效即 403 | 由前端 JS 调用window.__dianping.sign()生成,依赖当前时间毫秒、shopId、随机 salt |
这里重点说说__ts的生成逻辑。我反编译过大众点评 Web 端的加密 JS(v2023.11.07 版本),其核心函数简化后如下:
function generateTs(shopId) { const timestamp = Date.now(); // 当前毫秒时间戳 const salt = Math.random().toString(36).substr(2, 8); // 8位随机字符串 const raw = `${timestamp}_${shopId}_${salt}`; const md5 = CryptoJS.MD5(raw).toString(); // 使用 CryptoJS 库 return btoa(md5); // Base64 编码 }注意:salt不是固定值,每次请求都变;timestamp精确到毫秒,服务端允许 ±30 秒误差,超时即拒收。这意味着,你用 Python 写脚本时,不能把__ts写成常量,必须在每次请求前实时计算。
2.3 编码格式陷阱:为什么ajax请求设置编码格式是高频报错根源?
网络热词里反复出现ajax请求设置编码格式,绝非偶然。大众点评接口对编码的校验极其严格,主要体现在三个层面:
第一层:请求头声明
必须显式设置Content-Type: application/json; charset=utf-8(即使 GET 请求无 body,也要带上)。我实测过,如果只写application/json不带charset=utf-8,部分节点会返回500 Internal Server Error,错误日志里明确提示charset not specified。
第二层:URL 参数编码
所有 query 参数(尤其是中文店名、用户昵称)必须用UTF-8编码后,再进行encodeURIComponent。比如店铺名 “海底捞火锅(国贸店)”,正确编码是%E6%B5%B7%E5%BA%95%E6%8D%9E%E7%81%AB%E9%94%85%EF%BC%88%E5%9B%BD%E8%B4%B8%E5%BA%97%EF%BC%89。如果用 GBK 编码再 encodeURIComponent,服务端解析时会因字节流错乱,直接抛出unexcepted end of json input。
第三层:响应体解码
服务端返回的 JSON 响应头里,Content-Type明确写着application/json; charset=utf-8,但部分旧版 Pythonrequests库(<2.25)在自动解码时会误判为ISO-8859-1,导致中文变成乱码。解决方案不是改response.encoding,而是强制用response.content.decode('utf-8')解析原始字节流,再交给json.loads()。
注意:
failed to deserialize the json body into the target type: input: missing fie这个报错,90% 源于编码错误导致 JSON 字符串截断。比如响应本该是{"reviews":[{"id":1,"text":"很好吃"}]},因编码错乱变成{"reviews":[{"id":1,"text":"・好åƒ"}]},json.loads()解析时遇到非法 UTF-8 字节,直接抛异常,且错误信息里missing fie实为missing field的截断显示——这是底层 JSON 解析器的 bug,不是大众点评的问题。
3. 实操全流程:从抓包到稳定获取的七步法
3.1 第一步:精准抓包与请求还原(避坑关键)
别急着写代码,先用 Chrome 完整走一遍流程。打开大众点评任意一家门店页(如搜索“喜茶 北京三里屯”),F12 → Network → XHR → 切换到“评论”Tab → 滚动到底部触发加载。此时你会看到多个/ajax/json/shop/wizard/GetReviewList请求。右键第一个 → “Copy” → “Copy as cURL (bash)”。粘贴到文本编辑器,你会得到类似这样的命令:
curl 'https://www.dianping.com/ajax/json/shop/wizard/GetReviewList?shopId=123456789&page=1&pageSize=10&sortType=1' \ -H 'authority: www.dianping.com' \ -H 'accept: application/json, text/plain, */*' \ -H 'accept-language: zh-CN,zh;q=0.9,en;q=0.8' \ -H 'cookie: _lxsdk_s=123abc...; __mta=456def...; _lxsdk_cuid=789ghi...' \ -H 'x-requested-with: XMLHttpRequest' \ -H 'x-shard: shopId=123456789'重点来了:这个 cURL 是“活”的,不是“死”的。它包含当前有效的 Cookie 和 Header,但page=1是静态的。你要做的是:
- 提取
shopId、_lxsdk_s、__mta、_lxsdk_cuid、X-Shard这五个核心字段; - 记录下
Date.now()的毫秒值(用于后续__ts计算); - 绝不直接执行这个 cURL,因为
page=1下次就失效了。
我习惯用一个临时 Python 脚本做验证:
import requests import time import base64 import hashlib # 从 cURL 提取的固定值 shop_id = "123456789" lxsdk_s = "123abc..." mta = "456def..." cuid = "789ghi..." # 构造动态 __ts timestamp = int(time.time() * 1000) salt = "random8char" # 实际需用 random 生成 raw = f"{timestamp}_{shop_id}_{salt}" ts = base64.b64encode(hashlib.md5(raw.encode()).digest()).decode() url = f"https://www.dianping.com/ajax/json/shop/wizard/GetReviewList?shopId={shop_id}&page=1&pageSize=10&sortType=1&__ts={ts}" headers = { "authority": "www.dianping.com", "accept": "application/json, text/plain, */*", "cookie": f"_lxsdk_s={lxsdk_s}; __mta={mta}; _lxsdk_cuid={cuid}", "x-requested-with": "XMLHttpRequest", "x-shard": f"shopId={shop_id}" } resp = requests.get(url, headers=headers) print(resp.status_code, len(resp.content)) print(resp.text[:200]) # 打印前200字符看是否正常如果返回 200 且 JSON 结构完整,说明基础链路通了。这一步卡住,后面全是空谈。
3.2 第二步:Cookie 生命周期管理(长期运行的核心)
大众点评的 Cookie 不是“一次登录,永久有效”。_lxsdk_s字段有效期约 3 天,__mta约 7 天,_lxsdk_cuid理论永久但会因设备重置失效。这意味着,你的脚本必须内置 Cookie 刷新机制。我的方案是双轨并行:
短期策略(推荐新手):每天凌晨自动重启
用 Linux cron 设置:
# 每天 2:00 执行 cookie 刷新脚本 0 2 * * * /usr/bin/python3 /path/to/refresh_cookie.py >> /var/log/dp_cookie.log 2>&1refresh_cookie.py的核心逻辑是:启动无头 Chrome,访问大众点评首页,等待登录态加载完成(检测document.cookie是否含_lxsdk_s),然后用driver.get_cookies()提取全部 Cookie,序列化存入 Redis 或本地 JSON 文件。后续数据抓取脚本,统一从这个存储读取最新 Cookie。
长期策略(生产环境):会话保活 + 异常熔断
在主抓取循环里,每 10 次请求后,主动发起一次“心跳请求”:
def keep_alive(): url = "https://www.dianping.com/" headers = {"cookie": get_current_cookie()} resp = requests.get(url, headers=headers, timeout=5) if resp.status_code != 200 or "_lxsdk_s" not in resp.headers.get("set-cookie", ""): trigger_relogin() # 触发重新登录流程同时,监控每次请求的resp.headers.get("x-shard"),如果连续 3 次返回shopId=0或空值,立即判定 Cookie 失效,跳转至重登录。
实操心得:千万别用
requests.Session()长期持有 Cookie。大众点评服务端会校验 Cookie 中的时间戳字段(如__mta的最后 6 位是时间戳),Session 对象无法自动更新这些动态字段。必须每次请求前,从外部存储读取“新鲜”的 Cookie 字符串。
3.3 第三步:JSON 响应结构解析与容错设计
大众点评的评论 JSON 并非扁平结构,而是多层嵌套,且字段存在“软删除”现象——即某条评论的user对象可能缺失avatarUrl字段,review对象可能没有photos数组。直接data['reviews'][0]['user']['avatarUrl']会抛KeyError。必须用安全访问模式。
我定义了一个通用解析器:
def safe_get(data, keys, default=""): """ 安全获取嵌套字典值 keys: ['reviews', 0, 'user', 'avatarUrl'] """ for key in keys: try: if isinstance(data, list): data = data[key] if isinstance(key, int) else data[0] else: data = data[key] except (KeyError, IndexError, TypeError): return default return data # 使用示例 for review in response_json.get("reviews", []): user_avatar = safe_get(review, ["user", "avatarUrl"], "") review_text = safe_get(review, ["review", "text"], "") review_time = safe_get(review, ["review", "time"], "") photos = safe_get(review, ["review", "photos"], [])更关键的是,要识别真正的“数据结束”。大众点评不返回hasNext=false,而是当page超出实际页数时,返回空数组{"reviews":[]}。因此,翻页逻辑不能依赖len(reviews)==0,而要看响应体里是否有code字段且为200,以及reviews数组长度是否小于pageSize。我的翻页条件是:
if len(reviews) < PAGE_SIZE or not reviews: break # 无更多数据 else: page += 1 time.sleep(1.5) # 强制延时,模拟人工操作3.4 第四步:并发控制与请求节流(避免被封 IP)
大众点评对单 IP 的请求频率限制非常敏感。实测阈值是:同一 IP 每分钟不超过 30 次有效请求(403 或 500 不计入)。超过后,后续请求会返回429 Too Many Requests,持续 5-15 分钟。
我的生产环境采用三级节流:
- 客户端级:每个请求后
time.sleep(2.0),确保最低间隔; - 进程级:用
threading.Semaphore(5)限制并发线程数 ≤5; - IP 级:部署在 3 台不同出口 IP 的服务器上,通过 Nginx 做负载均衡,每台机器独立计数。
具体实现用concurrent.futures.ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor, as_completed import time semaphore = threading.Semaphore(5) def fetch_page(page_num): with semaphore: # 同一时刻最多 5 个线程执行 # 构造请求... resp = requests.get(url, headers=headers, timeout=10) time.sleep(2.0) # 固定延时 return parse_reviews(resp.json()) # 启动 10 页并发抓取 with ThreadPoolExecutor(max_workers=5) as executor: futures = {executor.submit(fetch_page, p): p for p in range(1, 11)} for future in as_completed(futures): try: reviews = future.result() all_reviews.extend(reviews) except Exception as e: print(f"Page {futures[future]} failed: {e}")注意:
max_workers=5不代表并发 5,因为每个 worker 内部还有sleep(2.0),实际 QPS 稳定在 2.5 左右,远低于 30/min 的红线。这是我在线上跑了 8 个月零封禁的实测值。
4. 常见问题与排查技巧实录
4.1 典型报错速查表
| 报错现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
403 Forbidden | __ts签名过期或计算错误 | 检查Date.now()是否与服务器时间偏差 >30s;打印raw字符串确认shopId和salt正确 | 用ntplib同步本地时间;salt改用secrets.token_urlsafe(6)生成 |
500 Internal Server Error | Content-Type缺少charset=utf-8 | 用 Wireshark 抓包对比浏览器请求头 | 在headers字典中显式添加'Content-Type': 'application/json; charset=utf-8' |
unexcepted end of json input | 响应体编码错乱导致 JSON 截断 | print(resp.content[:100])查看原始字节流 | 改用json.loads(resp.content.decode('utf-8')),禁用resp.json() |
{"code":500,"msg":"Invalid shopId"} | X-Shardheader 中的shopId与 URL 参数不一致 | 检查X-Shard: shopId=xxx是否与?shopId=xxx完全相同 | 严格字符串匹配,注意前后空格 |
{"reviews":[]} | 真实无数据,或page超出范围 | 检查前一页是否返回len(reviews)==10 | 当len(reviews)<10时立即停止翻页 |
429 Too Many Requests | 单 IP 请求超频 | 查看响应头Retry-After字段 | 立即暂停所有请求,time.sleep(int(resp.headers.get("Retry-After", "300"))) |
4.2 独家避坑技巧(血泪总结)
技巧一:永远用response.content,不用response.textresponse.text会触发 requests 库的自动编码猜测,而大众点评的响应头Content-Type有时不带charset,requests 就会用ISO-8859-1解码,中文全变乱码。response.content是原始字节流,你可控性 100%。我见过太多人在这里浪费三天——就因为一行代码没写对。
技巧二:X-Shardheader 是“开关”,不是“装饰”
很多教程忽略这个字段,说“加上 Cookie 就行”。错。X-Shard: shopId=123456789是服务端路由的关键,缺了它,请求会被打到默认集群,返回空数据或 404。而且,它的值必须与 URL 中的shopId完全一致,连大小写都不能错。
技巧三:sortType参数的隐藏规则
文档没写,但实测发现:sortType=1(最新)最多返回 1000 条;sortType=2(最热)最多 500 条;sortType=3(好评)最多 200 条。如果你要全量抓取,必须用sortType=1,再配合page翻页。别信网上说的“换 sortType 能绕过限制”。
技巧四:filterType的组合玄机filterType=0是全部评论,filterType=1是带图评论,filterType=2是带视频评论。但filterType=1和filterType=2不能与sortType=1同时使用,否则返回{"code":400,"msg":"Bad Request"}。必须用sortType=0(综合排序)才能启用图片/视频过滤。
4.3 数据质量校验清单(上线前必做)
抓到的数据不能直接用,必须过五关:
- 时间校验:检查
review.time字段是否为标准时间戳(13 位毫秒)或 ISO 格式(如"2023-11-07 14:22:33"),剔除null或""; - 文本清洗:去除
\u200b(零宽空格)、\ufeff(BOM)、重复换行符,用正则re.sub(r'\s+', ' ', text)压缩空白; - 用户去重:同一
userId在同一家店的评论,只保留time最新的那条(防刷评); - 情感一致性:
review.star字段(1-5 星)必须与review.text情感倾向基本匹配,用 jieba+SnowNLP 做初筛,star=1但text含“太棒了”则标为异常; - 图片链接存活:对
photos数组中的每个 URL,用HEAD请求校验status_code==200,失效链接置空。
我写了个校验脚本,每次抓取 1000 条后自动运行,输出quality_report.json,包含valid_count、invalid_time_count、empty_text_count、image_dead_count等字段。上线三个月,数据可用率从 82% 提升到 99.6%。
5. 工具链与工程化建议
5.1 推荐工具栈(已验证生产可用)
- 抓包与调试:Chrome DevTools(主力)、Charles Proxy(看 HTTPS 解密)、Wireshark(终极网络层分析);
- 签名逆向:Chrome 的 Sources → Page → 找
sign.js,用debugger断点跟踪;JS 逆向用 AST Explorer 分析混淆代码; - 请求发送:Python
requests(简单场景)、httpx(异步高并发)、playwright(需要完整浏览器上下文时); - JSON 处理:
jsonpath-ng(XPath for JSON,查嵌套字段)、orjson(比标准 json 快 3 倍,内存占用低 50%); - 存储与调度:Redis(存 Cookie 和任务队列)、PostgreSQL(存结构化评论,建
shop_id和review_time复合索引)、Airflow(定时调度多店铺抓取)。
5.2 生产环境部署 checklist
- 环境隔离:开发、测试、生产三套 Cookie 存储,用不同 Redis DB;
- 日志分级:INFO 级记录成功请求,WARNING 级记录 4xx/5xx,ERROR 级记录
KeyError和JSONDecodeError,所有日志打上shop_id和page标签; - 告警机制:当连续 5 次请求返回
429或403,微信机器人推送告警,并自动触发 Cookie 刷新; - 数据备份:每日凌晨将 PostgreSQL 中新增评论导出为
dp_reviews_20231107.csv,上传至 S3; - 合规审计:所有抓取脚本头部加注释:“本脚本仅用于个人学习与非商业研究,遵守 robots.txt 及《反不正当竞争法》第十二条”。
最后分享一个小技巧:大众点评的评论接口其实有“影子模式”。当你在网页上快速滚动评论区,会发现 Network 面板里除了
GetReviewList,还有一串GetReviewDetail请求,它们返回单条评论的完整详情(含用户手机号脱敏、身份证后四位等)。这些接口的签名逻辑更简单,__ts只依赖reviewId,且X-Shard可省略。如果你只需要深度分析某几条高价值评论,走这条路比全量抓取更稳、更快。