1. 从一次列表页请求说起:xhs 列表页数据链路到底长什么样
xhs 列表页全流程解析这件事,很多人第一次接触时都会卡在同一个地方:浏览器里明明能看到内容,代码一跑就返回空数组或者直接 461。我最初也以为只是换个 User-Agent 的事,后来才发现整条链路里真正决定成败的是几个看起来不起眼的参数。
先把场景说清楚。这里讨论的列表页,指的是首页推荐流(homefeed)和探索页(explore)这两类以卡片形式向下滚动加载的页面。它们的特点是:首屏返回一批笔记,滚动到底部时前端带着上一次响应里的游标继续请求下一页。你要做的是把「请求 → 参数构造 → 解析 → 落地」这条链路跑通,并且能稳定复现。
适合谁看:需要做内容聚合、选题分析、竞品监控的开发者;正在写爬虫但被签名参数卡住的同学;以及想把列表数据接进自己数据库做二次分析的人。不适合想绕过平台规则做高频抓取的人,本文只讲工程链路和排障思路。
整条链路可以拆成四段。第一段是首屏请求,拿到第一批卡片和一个关键的prefetch_id;第二段是用prefetch_id去请求预取接口,拿到更完整的结构化数据;第三段是分页,靠cursor_score和prefetch_id两个游标往下翻;第四段是字段映射和落库。很多人只做了第一段就以为完事了,结果发现拿到的数据字段残缺,问题就出在漏了第二段。
我实测下来,最容易踩的坑有三个:一是x-s签名头缺失或过期,直接 461;二是cursor_score用了固定值,第二页开始就重复;三是 cookie 里的a1和签名里的x3对不上,服务端校验失败。这三个坑后面会逐个给排查方法。
在动手之前,建议你先明确一件事:列表页返回的是 JSON,但字段层级比较深,items数组里每一项还有note_card嵌套。如果你打算落库,最好先设计好扁平化的表结构,否则解析代码会写得很乱。下面从环境准备开始,一步步把这条链路搭起来。
2. 前置准备:用 TaoToken 统一管理请求侧配置与密钥
在写请求代码之前,先把「配置」这件事处理好。列表页请求涉及的东西不少:Base URL、鉴权 Key、模型 ID(如果你后续要用模型做字段清洗或内容理解)、以及各种环境变量。散落在代码里很快就会失控。
我现在的做法是把所有外部依赖收敛到一个统一入口。TaoToken 在这里的角色是提供统一的 API 接入层,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数,配置时直接用干净的那个。
为什么列表页项目会用到它?因为完整链路里往往不只是抓取,还要对抓下来的标题、正文做去重、分类、摘要。这些步骤用模型处理比写规则省事得多。把模型调用和抓取请求的配置放在一起管理,切换环境时只改一处。
具体操作上,先去控制台创建 Key。控制台入口:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完在 API Keys 页面复制:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。这个 Key 后面会写进环境变量,不要硬编码进代码。
模型 ID 怎么选?如果你只是做字段清洗和简单分类,选一个响应快的通用模型就够;如果要做长文本摘要,选上下文窗口大的。具体可用模型列表在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。文档里会列出当前支持的模型 ID,照着填就行。
如果你用的是 Claude Code 这类编码工具做开发,接入配置可以参考:https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=ClaudeCodeAnthropic&utm_campaign=rewrite 。长期跑编码任务或 Agent 的话,Coding Plan 更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
环境变量建议这样组织,放在项目根目录的.env里:
# 抓取侧 XHS_COOKIE="你的完整cookie字符串" XHS_USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" # 模型侧(TaoToken) TAOTOKEN_BASE_URL="https://taotoken.net/api" TAOTOKEN_API_KEY="sk-你的key" TAOTOKEN_MODEL_ID="你的模型ID"这里有个细节:cookie 字符串很长,直接贴进.env容易因为换行或引号出问题。建议用单行、不加多余空格的方式存。读取时用os.getenv拿,不要手动拼接。
另外提醒一句,cookie 里的a1值是有时效的,签名参数x-s里的x3就是它的 MD5。如果a1过期,签名校验必然失败。所以每次跑之前先确认 cookie 是新鲜的,别拿几天前的来用。
配置管理这块做扎实了,后面调试能省一半时间。接下来进入可复制的请求配置。
3. 可复制配置:请求参数、签名头与字段映射表
这一节是全文的核心,直接给能跑的配置。先看首屏请求的完整结构。
请求地址是https://www.xiaohongshu.com/api/sns/web/v1/homefeed,方法 POST,Content-Type 是text/plain(注意不是application/json,这个细节很多人搞错)。请求体是一个 JSON 字符串,关键字段如下:
{ "cursor_score": "1.701248256973002E9", "num": 27, "refresh_type": 3, "note_index": 62, "unread_begin_note_id": "", "unread_end_note_id": "", "unread_note_count": 0, "category": "homefeed_recommend", "search_key": "", "need_num": 7, "image_scenes": ["FD_PRV_WEBP", "FD_WM_WEBP"], "prefetch_id": "1eb569d1-eb74-4010-a01d-cce7c9e60583" }逐个说清楚。cursor_score是分页游标,首屏可以给一个初始值,后续必须用上一页响应里返回的新值。num是请求数量,need_num是实际需要的数量,两者配合控制返回条数。refresh_type为 3 表示推荐流刷新。category固定为homefeed_recommend。prefetch_id是预取标识,首屏可以留空或给一个 UUID,响应里会返回新的。
请求头里最关键的是三个:cookie、user-agent、x-s。x-s是签名头,格式是XYW_加上一段 base64。它的生成逻辑是:把x1(某个固定值的 MD5)、x2(写死的版本号)、x3(cookie 里 a1 的 MD5)、x4(时间戳)拼成 payload,再做 base64 编码,最后套上XYW_前缀。
如果你不想自己实现签名,最省事的办法是从浏览器里复制一份新鲜的x-s,配合对应的 cookie 使用。但要注意,x-s和 cookie 是绑定的,换 cookie 就得换签名。
字段映射表如下,方便你落库时对照:
| 响应字段路径 | 含义 | 建议类型 |
|---|---|---|
data.items[].id | 笔记 ID | string |
data.items[].note_card.display_title | 标题 | string |
data.items[].note_card.user.nickname | 作者昵称 | string |
data.items[].note_card.interact_info.liked_count | 点赞数 | string |
data.items[].note_card.cover.url_default | 封面图 | string |
data.cursor_score | 下一页游标 | string |
data.prefetch_id | 预取标识 | string |
预取接口是另一个关键。首屏响应里会返回prefetch_id,拿它去请求https://www.xiaohongshu.com/explore/prefetch?prefetch_id=xxx,方法 GET,带上同样的 cookie。这个接口返回的是更完整的结构化数据,字段比首屏丰富。注意这个请求的x-s头可以省略,实测不带也能通。
分页逻辑:每次请求后,从响应里取cursor_score和prefetch_id,替换请求体里的对应字段,再发下一次。不要复用旧的cursor_score,否则会拿到重复数据。
如果你用 Cline 或类似工具做开发,MCP 配置里需要写全三件套:Base URL、Key、Model ID。配置片段如下:
{ "mcpServers": { "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的key", "modelId": "你的模型ID" } } }Codex 用户如果走auth.json,结构类似,把 base URL 和 key 填进去即可。核心是这三项必须齐全,缺一个就连不上。
配置给完了,下一步是验证它到底能不能跑通。
4. 三步验证:抓一页样本、校验字段、对比分页
配置写完不要直接上量,先做三步验证。这三步能帮你快速定位问题出在哪一环。
第一步,抓一页样本。用下面的最小代码跑一次首屏请求:
import os import json import requests from dotenv import load_dotenv load_dotenv() url = "https://www.xiaohongshu.com/api/sns/web/v1/homefeed" payload = json.dumps({ "cursor_score": "1.701248256973002E9", "num": 27, "refresh_type": 3, "note_index": 62, "unread_begin_note_id": "", "unread_end_note_id": "", "unread_note_count": 0, "category": "homefeed_recommend", "search_key": "", "need_num": 7, "image_scenes": ["FD_PRV_WEBP", "FD_WM_WEBP"], "prefetch_id": "1eb569d1-eb74-4010-a01d-cce7c9e60583" }) headers = { "cookie": os.getenv("XHS_COOKIE"), "user-agent": os.getenv("XHS_USER_AGENT"), "x-s": "XYW_你的签名", "Content-Type": "text/plain" } resp = requests.post(url, headers=headers, data=payload, timeout=15) print(resp.status_code) print(resp.text[:500])跑通的话,status_code是 200,resp.text里能看到items数组。如果返回 461,说明签名或 cookie 有问题;返回空items,说明参数不对。
第二步,校验字段完整性。把响应解析成 JSON,检查data.items的长度,以及每一项里note_card是否存在。用这段代码:
data = resp.json() items = data.get("data", {}).get("items", []) print(f"拿到 {len(items)} 条") for it in items[:3]: card = it.get("note_card", {}) print(card.get("display_title"), "|", card.get("user", {}).get("nickname"))如果note_card大量缺失,说明你拿到的可能是预取前的简版数据,需要再走一次预取接口。
第三步,对比分页结果。用第一页返回的cursor_score发第二次请求,检查两页的笔记 ID 有没有重复:
cursor = data["data"]["cursor_score"] prefetch = data["data"]["prefetch_id"] # 用新游标发第二页,对比 id 集合如果两页 ID 完全一样,说明cursor_score没生效,检查是不是用了固定值。
这三步跑完,链路基本就通了。接下来是排障。
5. 常见报错排查:461、空 items、签名失效与 OAuth 类问题
排障这块我按报错类型整理,对照着看。
461 状态码。这是最常见的。原因通常是x-s签名头缺失、格式错误,或者签名里的x3和 cookie 里的a1对不上。排查方法:先确认x-s以XYW_开头,然后检查 cookie 里有没有a1字段。如果都对还是 461,说明签名过期了,重新从浏览器复制一份。
返回 200 但 items 为空。这种情况多半是请求体参数问题。检查category是不是homefeed_recommend,refresh_type是不是 3。另外cursor_score如果给了一个服务端不认识的值,也可能返回空。建议首屏用一个已知有效的初始值。
local proxy failed 类报错。如果你在代码里配了代理,而代理不可用,会报这个。检查你的网络配置,确保请求能直连。这类报错和签名无关,纯粹是网络层问题。
reading choices 报错。这通常出现在解析响应时,说明你假设的字段路径和实际返回结构不一致。比如你以为items在顶层,实际在data.items。打印完整响应,逐层确认路径。
OAuth 相关报错。如果你在接入模型侧时遇到 OAuth 报错,检查 Key 是否有效、Base URL 是否写对。TaoToken 的 API 地址是https://taotoken.net/api,不要多加路径。Key 从 API Keys 页面复制,注意不要带多余空格。
签名长度和浏览器不一致。有同学自己实现签名,发现生成的x-s长度和浏览器的不一样。这通常是 payload 拼接顺序或编码方式的问题。x1是某个固定值的 MD5,x2写死,x3是 a1 的 MD5,x4是时间戳。四段拼好后做 base64,再套XYW_前缀。顺序错了长度就会变。
分页重复。第二页和第一页数据一样,检查是不是把cursor_score写死了。每次请求后必须用响应里的新值替换。
字段缺失。如果note_card里缺少interact_info,可能是该笔记本身没有互动数据,不一定是解析错误。做字段映射时给默认值。
排障的核心思路是:先确认状态码,再看响应结构,最后定位到具体字段。不要一上来就改代码,先打印原始响应。
6. 把链路接进你的工程:从样本到稳定落地的实践建议
链路跑通只是开始,真正要用起来还得考虑稳定性。分享几个我踩过坑之后总结的做法。
第一,cookie 和签名要定期刷新。a1有时效,签名跟着它走。建议写一个检查逻辑,请求返回 461 时自动提示刷新,而不是硬跑。手动刷新虽然麻烦,但比被封强。
第二,分页要有终止条件。列表页理论上可以一直翻,但实际数据会重复或耗尽。设置一个最大页数,并且在连续两页 ID 重复率超过阈值时停止。
第三,落库前先做字段清洗。标题里可能有换行、特殊字符,点赞数可能是"1.2万"这种格式。用模型做一轮标准化比写正则省事。这时候 TaoToken 的模型调用就派上用场了,把原始字段丢进去,让它输出结构化结果。
第四,请求频率要克制。列表页不是设计给高频抓取的,间隔太短容易触发风控。建议每次请求之间加随机延迟,单次任务控制在合理页数内。
第五,把配置和代码分离。cookie、签名、模型 Key 都放环境变量,代码里只读不写。这样换环境时不用改代码,也避免密钥泄露。
如果你后续要做更复杂的 Agent 任务,比如自动选题、内容生成,可以考虑用 Coding Plan 把模型调用和工程流程串起来:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。需要快速验证模型输出效果的话,模型对话页面可以直接试:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。
最后说一个实际经验:列表页的数据结构会变,今天能用的字段路径,过段时间可能就调整了。所以解析代码要写得有容错,用.get()逐层取,不要用[]硬索引。这样即使某个字段没了,程序也不会直接崩,而是返回 None,你还能继续处理其他数据。
整条链路的核心就三件事:参数对、签名对、游标对。把这三样管好,剩下的都是工程细节。