起因

最近在给一个多站点 MCP 工具补小红书笔记搜索。

最初的实现很直观:打开搜索页,等待页面渲染,再从 window.__INITIAL_STATE__ 里读取 search.feeds。这种方案能用,而且比解析卡片 DOM 稳定一些,但它仍然依赖前端页面:

  • 搜索页必须完整加载;
  • 筛选条件要靠点击页面控件;
  • 首屏、翻页和异常状态分散在不同前端状态里;
  • 页面结构一改,选择器和状态读取逻辑就可能失效。

既然网页自己也要请求结构化数据,能不能保留真实 Chrome 的登录环境,但跳过搜索框、筛选面板和结果卡片,直接调用内部 API?

答案是可以。

本文记录的是 2026 年 7 月实际验证过的一条路径:Chrome CDP + 小红书页面上下文 + 前端自身的请求函数

核心接口

当前网页搜索使用的笔记接口是:

1
POST https://so.xiaohongshu.com/api/sns/web/v2/search/notes

一次普通搜索的请求体大致如下:

1
2
3
4
5
6
7
8
9
10
11
{
"keyword": "露营",
"page": 1,
"page_size": 20,
"search_id": "search-mrksdol4",
"sort": "general",
"note_type": 0,
"ext_flags": [],
"geo": "",
"image_formats": ["jpg", "webp", "avif"]
}

返回值已经是可直接消费的结构化对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
response
├── hasMore
├── requestDqaInstant
└── items[]
├── id
├── modelType
├── xsecToken
└── noteCard
├── displayTitle
├── type
├── user
├── interactInfo
├── cover
└── imageList

其中 idxsecToken 很重要。后续打开笔记详情时,需要把它们一起带上:

1
https://www.xiaohongshu.com/explore/<note_id>?xsec_token=<token>&xsec_source=pc_feed

看起来只要在页面里 fetch() 一下就结束了,但真正麻烦的是请求签名。

为什么不能直接 fetch

小红书网页请求会带一组动态请求头:

1
2
3
4
5
6
X-s
X-t
X-S-Common
X-B3-TraceId
X-Xray-TraceId
X-Rap-Param

页面上确实暴露了 window._webmsxyw,调用它可以生成 X-sX-t

1
2
3
4
const signed = window._webmsxyw(
"/api/sns/web/v2/search/notes",
payload
)

但这还不够。

我做了两次验证:

  1. 只使用 _webmsxyw 返回的 X-s / X-t,接口能收到请求,但业务层拒绝;
  2. 从上一条正常请求里复制 X-S-Common 再重放,新请求仍然失败。

这说明 X-S-Common 不是一个可以长期缓存的会话常量,它和当前请求环境、签名或时间有关。继续把签名算法搬到 Node.js,不但维护成本高,也很容易随着前端升级失效。

更稳妥的办法是:不要自己生成完整签名,让小红书已经加载的前端请求模块替我们生成。

技术方案:Chrome CDP 只负责提供执行环境

整体链路如下:

1
2
3
4
5
6
7
flowchart LR
A["MCP: xhs_search_notes"] --> B["Chrome CDP 9224"]
B --> C["已登录的小红书页面"]
C --> D["动态定位 Webpack 搜索请求函数"]
D --> E["前端模块生成完整签名"]
E --> F["POST /api/sns/web/v2/search/notes"]
F --> G["规范化 items 为笔记结果"]

这里的 Chrome 不是用来点击页面,而是提供三样东西:

  1. 独立且持久的登录态;
  2. 完整的浏览器指纹和页面运行环境;
  3. 小红书已经初始化好的请求、签名和拦截器模块。

搜索数据直接来自 API 响应,不读取结果卡片 DOM,也不需要模拟输入和点击。

使用隔离的 Chrome profile

不要把日常使用的主 Chrome profile 交给自动化程序。建议为小红书单独创建一个 profile,并且只监听本机地址:

1
2
3
4
5
6
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port=9224 \
--user-data-dir="$HOME/.xhs-cdp-profile" \
--profile-directory=Default \
https://www.xiaohongshu.com

第一次启动后,在这个专用窗口里手动登录。之后登录态保存在 ~/.xhs-cdp-profile,服务重启时可以继续复用。

不需要读取 Cookie,不需要把 Cookie 复制到配置文件,也不要把 CDP 端口暴露到公网。

动态找到小红书自己的搜索函数

网页使用 Webpack 打包。模块编号和导出名经过压缩,例如某次构建里可能是数字模块号和单字母导出,但这些值下次发布就可能改变,所以不能硬编码。

可用的稳定线索有两个:

  • API 常量名:WEB_AI_SEARCH_NOTES_V2
  • 接口路径:/api/sns/web/v2/search/notes

下面这段代码在页面上下文中完成动态定位和调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
async function searchXhsNotes(page, payload) {
return await page.evaluate(async (requestBody) => {
let webpackRequire

window.webpackChunkxhs_pc_web.push([
[`xhs-api-${Date.now()}`],
{},
(nextRequire) => {
webpackRequire = nextRequire
}
])

if (!webpackRequire?.m) {
throw new Error("XHS Webpack runtime is unavailable")
}

// 先找定义接口常量的配置模块。
const configEntry = Object.entries(webpackRequire.m).find(([, factory]) => {
const source = Function.prototype.toString.call(factory)
return (
source.includes("WEB_AI_SEARCH_NOTES_V2") &&
source.includes("/api/sns/web/v2/search/notes")
)
})

if (!configEntry) {
throw new Error("XHS search API config module was not found")
}

const [configId] = configEntry

// 再找引用该配置模块和接口常量的请求包装模块。
const wrapperEntry = Object.entries(webpackRequire.m).find(
([moduleId, factory]) => {
if (moduleId === configId) return false
const source = Function.prototype.toString.call(factory)
return (
source.includes(configId) &&
source.includes("WEB_AI_SEARCH_NOTES_V2")
)
}
)

if (!wrapperEntry) {
throw new Error("XHS search API wrapper module was not found")
}

const exports = webpackRequire(wrapperEntry[0])
const searchNotes = Object.values(exports).find(
(value) =>
typeof value === "function" &&
Function.prototype.toString
.call(value)
.includes("WEB_AI_SEARCH_NOTES_V2")
)

if (!searchNotes) {
throw new Error("XHS search API function was not found")
}

// 由小红书自己的请求模块处理 X-s、X-t、X-S-Common 等头。
return await searchNotes(requestBody)
}, payload)
}

调用方式:

1
2
3
4
5
6
7
8
9
10
11
const response = await searchXhsNotes(page, {
keyword: "露营",
page: 1,
page_size: 20,
search_id: `search-${Date.now().toString(36)}`,
sort: "general",
note_type: 0,
ext_flags: [],
geo: "",
image_formats: ["jpg", "webp", "avif"]
})

这段实现依赖小红书当前网页模块的语义线索,而不是某一个固定模块号。前端重新构建后,只要接口常量和请求包装关系没有同时改变,仍然能找到正确函数。

规范化搜索结果

API 返回的是网页前端模型,字段采用 camelCase。为了让 MCP 输出稳定,最好在浏览器之外做一次白名单映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function normalizeSearchItems(items, webBaseUrl) {
return items
.filter((item) => item?.id && item?.xsecToken && item?.noteCard)
.map((item) => {
const card = item.noteCard

return {
result_id: item.id,
note_id: item.id,
xsec_token: item.xsecToken,
detail_url:
`${webBaseUrl}/explore/${encodeURIComponent(item.id)}` +
`?xsec_token=${encodeURIComponent(item.xsecToken)}` +
`&xsec_source=pc_feed`,
type: card.type,
title: card.displayTitle,
user: {
user_id: card.user?.userId,
nickname: card.user?.nickname ?? card.user?.nickName,
avatar: card.user?.avatar
},
interact_info: {
liked: card.interactInfo?.liked,
liked_count: card.interactInfo?.likedCount,
collected: card.interactInfo?.collected,
collected_count: card.interactInfo?.collectedCount,
comment_count: card.interactInfo?.commentCount,
shared_count: card.interactInfo?.sharedCount
},
cover: {
width: card.cover?.width,
height: card.cover?.height,
url_default: card.cover?.urlDefault,
url_pre: card.cover?.urlPre
}
}
})
}

不要把整个响应对象原样返回。白名单映射可以减少接口字段变化带来的影响,也避免无意间暴露调试字段或会话相关信息。

分页与筛选

分页时应在同一次搜索中复用 search_id,只增加 page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const searchId = `search-${Date.now().toString(36)}`
const allItems = []

for (let pageNumber = 1; pageNumber <= 3; pageNumber += 1) {
const response = await searchXhsNotes(page, {
keyword: "露营",
page: pageNumber,
page_size: 20,
search_id: searchId,
sort: "general",
note_type: 0,
ext_flags: [],
geo: "",
image_formats: ["jpg", "webp", "avif"]
})

allItems.push(...(response.items ?? []))
if (!response.hasMore) break

// 低频、有界,不做并发轰炸。
await new Promise((resolve) => setTimeout(resolve, 1500))
}

page_size 不等于最终笔记数量。实测响应里可能混有不同 modelType 的项目,所以最终数量应以规范化后的有效笔记为准,而不是简单相信数组长度。

筛选条件也应该继续使用网页实际使用的参数和值,不要凭空猜测枚举。当前默认请求里可以确认的字段包括 sortnote_typeext_flagsgeo

实际验证

在已登录的专用 Chrome profile 中,我做了一次不操作 DOM 的真实调用:

1
2
3
4
5
登录状态:有效
请求方法:POST
请求地址:https://so.xiaohongshu.com/api/sns/web/v2/search/notes
关键词:露营
返回 items:26

CDP 捕获到的请求头名称包括:

1
2
3
4
5
6
x-b3-traceid
x-rap-param
x-s
x-s-common
x-t
x-xray-traceid

这些头全部由小红书自身的前端请求模块生成,程序没有读取或输出任何签名值。

几个容易踩的坑

1. 登录检测不能只看页面能不能搜索

未登录时搜索页也可能渲染输入框和部分内容。如果只检查“页面里有没有搜索容器”,很容易把游客状态误判为已登录。

更可靠的做法是组合判断:

  • 是否存在用户头像或个人主页节点;
  • 是否出现登录容器或二维码;
  • 页面文字是否出现“请登录”“登录后查看”等提示。

我在未登录状态下调用接口时收到过 -104 权限错误;完成专用 profile 登录后,同一条页面内请求立即返回正常 items

2. 不要硬编码 Webpack 模块号

模块号和导出名都是构建产物,随时可能改变。应通过接口常量、路径和引用关系动态定位,并在找不到时返回清晰错误。

3. 页面导航后要重新发现函数

页面刷新或跳转可能替换 Webpack runtime。不要跨页面长期保存函数句柄;每个新页面会话重新定位,或设置短生命周期缓存。

4. 只返回可序列化快照

不要把 Axios 实例、Vue ref、函数或循环引用带出页面。搜索函数完成后,应立即转换成普通对象,再交给 Node.js/MCP 层。

5. API 不是官方公开契约

接口路径、参数和前端模块都可能变化。生产实现需要超时、结果上限、错误映射和可观察日志,并保留受控 fallback。

数据安全和使用边界

这套方案的目标是复用本人正常登录的浏览器会话,做低频、只读的数据整理。建议至少遵守以下边界:

  • 使用独立 Chrome profile,不连接日常主浏览器;
  • CDP 只监听 127.0.0.1
  • 不导出 Cookie、签名值或浏览器存储;
  • 不绕过验证码、登录限制或账号风控;
  • 不做高并发、无限翻页或批量账号抓取;
  • 尊重平台规则、内容版权和个人隐私。

如果接口返回登录、验证或账号异常,应停止自动化并由用户在专用 Chrome 窗口里处理,而不是继续尝试绕过。

总结

这次最有价值的结论不是“找到了一个接口”,而是确定了一种维护成本更低的调用方式:

Chrome CDP 保留真实登录环境,小红书前端模块负责完整签名,业务代码只负责构造参数和规范化响应。

这样既绕过了脆弱的搜索 DOM,也不需要把签名算法搬出浏览器。对于带动态签名的网页内部 API,这通常比自己复刻请求头更稳定。

相关资料: