小红书笔记调研 API 教程 | SandBase
用 SandBase 调研一个品类在小红书上什么笔记跑得好:按关键词搜笔记、翻页、打开头部笔记、抽样评论。无需小红书登录,只需一个 SandBase API Key。

品牌要在小红书上找达人、写 brief 之前,通常会有人花一下午刷品类关键词的搜索结果:哪些笔记被收藏得多,而不只是点赞多?跑出来的是图文还是视频?哪些话题标签反复出现?评论区的人到底在问什么?
这篇教程把这一下午的活儿写成脚本:按关键词搜笔记、翻页、打开头部笔记拿正文、标签和互动数据,再抽样评论。完整的端点地图在小红书公开数据 API 总览里,建议先看一眼。
本文只讲笔记。如果你要研究的是商品、SKU 和商品评价,看另一篇小红书商品调研工作流。
这里读的都是公开、只读数据,不需要小红书账号,也不需要 SDK,只要一个 SandBase API Key。笔记相关的这几个端点目前在 SandBase 目录里标的是 Free。
参数和响应信封以端点 API 参考为准,参考也只保证信封结构。下文的业务字段名都来自我自己跑的调用(测试于 2026-10-01,UTC),属于实测观察,不是文档保证。正式依赖之前,请先用真实响应核对。
先说结论
xiaohongshu/app-v2/search-notes传keyword,返回笔记卡片:id、类型、标题、点赞、收藏、评论数。翻页用page,再带上它返回的search_id和search_session_id。- 图文笔记用
app-v2/image-note-detail,视频笔记用app-v2/video-note-detail,都按note_id查。视频接口返回的是一个列表,必须按 id 匹配。app-v2/note-comments按note_id抽评论,排序选like_count,翻页时把返回的cursor字符串原样传回去。- 看收藏和点赞的比例。在我的测试里,这个比例比单看点赞更能区分“值得收藏的干货”和“娱乐、抽奖帖”。
用 SandBase 还是走官方
| 你的需求 | 用什么 |
|---|---|
| 做品类调研用的公开笔记、互动和评论数据 | SandBase 小红书公开数据 API |
| 发笔记、管理品牌号、投放广告、授权数据合作 | 小红书官方渠道 |
| 私密、仅粉丝可见或需要登录的内容 | 两条路都不适用 |
流程一览
- 扩展种子关键词(可选):
xiaohongshu/web-v3/search-suggest。 - 搜笔记:
xiaohongshu/app-v2/search-notes(keyword、page),用返回的 id 翻页。 - 排序:按点赞加收藏排,取前几条。
- 打开头部笔记:根据卡片的
type选image-note-detail或video-note-detail。 - 抽样评论:
xiaohongshu/app-v2/note-comments。 - 汇总:标签频次、视频占比、点赞中位数、收藏点赞比。
SandBase 上的小红书目录页:34 个端点以 GET /apis/v1/xiaohongshu/... 的形式列出,选中的 Search notes 状态为 Available、Free。本教程调用的是 Model API 的 POST /v1/api/xiaohongshu/... 路由。
写代码前先把接口面说清楚。目录页展示的是 GET /apis/v1/xiaohongshu/<path>;本文用的是端点参考里的 Model API,也就是 POST /v1/api/xiaohongshu/<path>,JSON 请求体里只放这个端点自己的参数。参考页生成的示例里有时会带一个 model 字段,路径本身已经确定了是哪个接口,所以我没加。复制示例时请保留 POST 方法和 /v1/api/ 前缀。
第 0 步:一个通用调用函数
import os
import time
import requests
API = "https://api.sandbase.ai/v1/api"
HEADERS = {
"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}",
"Content-Type": "application/json",
}
def call(path: str, payload: dict, retries: int = 2) -> dict:
for attempt in range(retries + 1):
try:
resp = requests.post(f"{API}/{path}", headers=HEADERS, json=payload, timeout=90)
resp.raise_for_status()
break
except requests.RequestException:
if attempt == retries:
raise
time.sleep(2 * (attempt + 1))
body = resp.json()
if body.get("status") != "completed":
raise RuntimeError(body.get("error", {}).get("message", f"{path} did not complete"))
# Reference documents outputs[0].data; also accept a top-level `output`.
output = body.get("output")
if output is None and body.get("outputs"):
output = body["outputs"][0].get("data", {})
output = output or {}
# app-v2 routes used code 0, web-v3 used code 1000; both carried success: true.
if output.get("success") is False:
raise RuntimeError(f"{path}: upstream code {output.get('code')} {output.get('msg')}")
return output
参考文档写的完成态结构是 outputs[0].data,这次测试里小红书的每次调用也都是这个结构。函数里顺带兼容了顶层 output,因为我在 SandBase 其他平台端点上见过这种返回。payload 里面还包着一层上游结构:code、msg、success,真正的内容在 data 里。
先说个坑:我第一版代码把所有非 0 的 code 都当失败,结果第一个调用就翻车了。web-v3/search-suggest 返回的是 code: 1000、msg: "成功",而 app-v2 的接口用的是 code: 0。两边都带着 success: true,所以函数改成检查 success。另外加了网络异常重试,这次测试里我确实碰到过一次连接被重置。
第 1 步(可选):扩展种子关键词
def suggest_keywords(seed: str) -> list[str]:
data = call("xiaohongshu/web-v3/search-suggest", {"keyword": seed}).get("data", {})
return [s.get("text") for s in data.get("sug_items", []) if s.get("text")]
种子词用“防晒霜”,我记录的这次调用(74a3e360-568a-4688-b9c4-f347d630a1a0)给出的联想是:防晒霜推荐、防晒霜推荐清爽不油腻、防晒霜的正确涂法、防晒霜推荐男、防晒霜推荐军训。
这本身就是调研结论:大家搜的是“推荐”“不油腻”“怎么涂”“男生用”“军训用”这几个角度。想扩大样本,就把每个联想词都跑一遍下一步。
第 2 步:搜笔记并翻页
def search_notes(keyword: str, pages: int = 2) -> list[dict]:
notes, params = [], {"keyword": keyword, "page": 1}
for _ in range(pages):
out = call("xiaohongshu/app-v2/search-notes", params)
for item in out.get("data", {}).get("items", []):
if item.get("model_type") != "note":
continue # skip ads and other cards
n = item.get("note", {})
notes.append({
"note_id": n.get("id"),
"type": n.get("type"), # "normal" (image) or "video"
"title": n.get("title"),
"likes": n.get("liked_count", 0),
"collects": n.get("collected_count", 0),
"comments": n.get("comments_count", 0),
})
if not out.get("next_page"):
break
params = {
"keyword": keyword,
"page": out["next_page"],
"search_id": out.get("search_id", ""),
"search_session_id": out.get("search_session_id", ""),
}
return notes
参考里说,翻页时要把第一次搜索返回的 search_id 和 search_session_id 传回去,实测也是这样:
- 第 1 页(run
829c7829-b1d4-4588-abcb-bdabd4f2a16a):返回 20 条,data旁边并列着page: 1、next_page: 2、search_id和search_session_id。 - 第 2 页(run
c578ddab-1bfe-42f9-ac33-509e5bb39206):带上这两个 id 和page: 2,又拿到 20 条,两个 id 不变,next_page变成 3。
第 2 页还冒出两个情况。一是有一条的 model_type 是 "ads",不是 "note",所以循环里要过滤。二是结果每次跑都会变:同样翻两页,一次去重后是 40 条,下一次只有 30 条。按 note_id 去重,也别把一次结果当成稳定排名。
每张笔记卡片里观察到的字段有 id、type、title、desc、liked_count、collected_count、comments_count、shared_count、user 对象和 xsec_token。user 里有作者昵称和 id,做品类调研基本用不上,所以函数没存。
xiaohongshu/app-v2/search-notes 参考页:POST /v1/api/xiaohongshu/app-v2/search-notes,必填 keyword,page 从 1 开始,翻页用可选的 search_id 和 search_session_id。响应示例里 outputs[0].data 是空对象。
第 3 步:打开头部笔记
def fetch_note(note_id: str, note_type: str) -> dict | None:
if note_type == "video":
items = call("xiaohongshu/app-v2/video-note-detail", {"note_id": note_id}).get("data", [])
note = next((x for x in items if x.get("id") == note_id), None)
else:
items = call("xiaohongshu/app-v2/image-note-detail", {"note_id": note_id}).get("data", [])
notes = items[0].get("note_list", []) if items else []
note = next((x for x in notes if x.get("id") == note_id), None)
if not note:
return None # deleted, private, or the id did not come back
return {
"note_id": note_id,
"type": note.get("type"),
"title": note.get("title"),
"text": (note.get("desc") or "")[:1500],
"tags": [t.get("name") for t in note.get("hash_tag", []) if t.get("name")],
"likes": note.get("liked_count"),
"collects": note.get("collected_count"),
"comments": note.get("comments_count"),
"shares": note.get("shared_count"),
"posted_at": note.get("time"),
"video_seconds": (note.get("video_info_v2") or {}).get("capa", {}).get("duration"),
}
两个详情接口的返回结构不一样,这一步我试得最久:
image-note-detail:data是只有一个元素的列表,笔记在这个元素的note_list里。video-note-detail:data直接是视频笔记的平铺列表,请求的那条排第一,后面跟着别的视频。
第二种结构有个坑。我把一条图文笔记的 id 传给 video-note-detail,它照样返回 code: 0,但给的是两条无关的视频,根本不是我要的那条(run fd0b3c04-618a-4a9b-8098-153badd96660)。代码里用 next(...) 按 id 匹配,这种情况就会干净地返回 None,而不是悄悄拿错笔记。所以要按卡片的 type 分流,并且始终核对 id。
下面是理肤泉官方品牌号“理肤泉larocheposay”一条公开笔记的节选,内容是代言人周边的晒单抽奖活动(run f90a6662-0eb4-49b5-a76b-88ffe488d085;在 640fbaba-3390-4935-a269-08ba2162937c 里再次读取,数字完全一致):
{
"code": 0,
"success": true,
"data": [
{
"model_type": "note",
"note_list": [
{
"id": "6a749cd70000000005021fd8",
"type": "normal",
"title": "莎莎周边已就位!晒单抽亲签💙",
"desc": "夏日养肤,谁还没用这套!\n维稳抗应激、深层修护、清洁净肤……",
"liked_count": 4297,
"collected_count": 136,
"comments_count": 193,
"shared_count": 86,
"time": 1786071657,
"ip_location": "Shanghai",
"view_count": 0,
"hash_tag": [
{"name": "理肤泉B5面膜PRO", "type": "topic"},
{"name": "理肤泉超级B5精华", "type": "topic"}
],
"user": {"nickname": "理肤泉larocheposay", "red_official_verified": false}
}
]
}
]
}
从这条和其他几条笔记里,我注意到几件事:
- 话题标签出现两次,一次以
#名称[话题]#的形式写在desc正文里,一次是结构化的hash_tag列表。用后者。 view_count在这里是0,别把它当浏览量。time是秒级 Unix 时间戳。- 这个品牌号在搜索和详情里的
red_official_verified都是false,靠它分不出哪些是品牌笔记。要做品牌和素人的区分,就自己维护一份品牌账号 id 清单。
另外,image-note-detail 传视频笔记的 id 也能用:返回的是正确的那条,type 为 "video",互动数据齐全,只是没有 video_info_v2。如果你只需要正文和互动数据,它可以当统一的兜底接口。
xiaohongshu/app-v2/image-note-detail 参考页:可选参数 note_id 和 share_text(分享链接)。页面生成的 cURL 示例只发了一个 model 字段,note_id 需要你自己加上。
第 4 步:抽样评论
def sample_comments(note_id: str, max_pages: int = 2, sort: str = "like_count") -> list[dict]:
params, out = {"note_id": note_id, "sort_strategy": sort}, []
for _ in range(max_pages):
data = call("xiaohongshu/app-v2/note-comments", params).get("data", {})
for c in data.get("comments", []):
if c.get("content"):
out.append({"text": c["content"], "likes": c.get("like_count"),
"replies": c.get("sub_comment_count")})
if not data.get("has_more") or not data.get("cursor"):
break
params = {"note_id": note_id, "sort_strategy": sort, "cursor": data["cursor"]}
return out
sort_strategy 在 schema 里默认是 latest_v2。响应的 all_sort_strategies 列出了三种:default、latest_v2 和 like_count。做调研选 like_count,排在前面的是其他读者也认同的评论。
翻页我做了一次对照实验。参考里写的是把上一页的 cursor、index、pageArea 分别传回去;实际上响应里的 cursor 本身就是一段 JSON 字符串,里面装着 cursor、index 和 pageArea 三个值。我在品牌笔记上两种都试了:
- 整段字符串原样塞回
cursor(rundbfada10-3360-4a16-a0fd-e0cd1b2db866) - 解析成三个字段分开传(run
a01020cc-24ea-4d94-aea9-64bfa31b921e)
两次拿到的都是同样的后 10 条评论,所以函数用了更省事的第一种。
每条评论里观察到 content、like_count、sub_comment_count、time、ip_location,user 下还有评论者身份。函数只留文本、点赞数和回复数,做主题分析够用,也能少存个人信息。
还有个细节:品牌笔记按 like_count 排序时,第一条评论 0 赞、18 条回复,后面才是 97、41、30 赞。所以顺序并不严格按点赞,真在意的话,拿到后自己再排一次。
xiaohongshu/app-v2/note-comments 参考页:可选参数 cursor、index、note_id、pageArea、share_text 和 sort_strategy(默认 latest_v2)。
串起来:一份品类报告
from collections import Counter
from statistics import median
def research_topic(keyword: str, top_n: int = 5) -> dict:
notes = search_notes(keyword, pages=2)
unique = list({n["note_id"]: n for n in notes if n["note_id"]}.values())
ranked = sorted(unique, key=lambda n: n["likes"] + n["collects"], reverse=True)
top = []
for n in ranked[:top_n]:
detail = fetch_note(n["note_id"], n["type"])
if detail is None:
continue
detail["comment_sample"] = sample_comments(n["note_id"], max_pages=1)
top.append(detail)
return {
"keyword": keyword,
"searched": len(ranked),
"video_share": round(sum(n["type"] == "video" for n in ranked) / max(len(ranked), 1), 2),
"median_likes": median([n["likes"] for n in ranked]) if ranked else 0,
"top_notes": top,
"top_tags": Counter(t for d in top for t in d["tags"]).most_common(10),
}
我在 2026-10-01 UTC 跑了 research_topic("防晒霜")。两页搜索(be1bcdab-01f0-41c8-ba6c-7d64f74ab394、1ad8a233-75c2-4054-afa0-14e8396eb072)去重后 30 条笔记,其中视频占 23%,点赞中位数 84,前五名里有三条是视频。
最有意思的是互动结构:
- 第一名是一条没写标题、打着“搞笑”标签的视频,约 15.6 万赞,收藏只有约 6,700。
- 第二名是一条防晒盘点视频,约 2.2 万赞,收藏约 2.55 万,比点赞还多。
- 上面那条品牌抽奖笔记是 4,297 赞、136 收藏。
说白了,点赞奖励的是娱乐和抽奖,收藏更接近“买之前我还要回来看”。如果目标是给一个品类写内容 brief,按收藏数或收藏点赞比排序,比按点赞排更靠谱。当然这只是一个关键词、一天的数据,把它当成假设,到你自己的品类上验证,别当规律。
标签计数是另一个快速信号。这次排在前面的是“防晒霜”“敏感肌防晒”“通勤防晒”。把整份报告交给模型,让它总结头部笔记和评论里的形式、钩子和顾虑,就是一份 brief 的初稿。
调用量方面,每次是两次搜索,加上每条头部笔记两次调用。取前五条就是 12 次,算上联想词是 13 次。
文档保证 vs. 实测观察
| 项目 | 状态 |
|---|---|
POST /v1/api/xiaohongshu/app-v2/search-notes,参数 keyword、page、search_id、search_session_id | 参考文档已列明 |
POST /v1/api/xiaohongshu/app-v2/image-note-detail 与 video-note-detail,参数 note_id 或 share_text | 参考文档已列明 |
POST /v1/api/xiaohongshu/app-v2/note-comments,参数 note_id、cursor、sort_strategy | 参考文档已列明 |
信封 id / status / model / outputs[0].data | 参考文档已列明 |
上游包装 code / msg / success / data | 仅实测观察 |
搜索 items[].model_type、note.liked_count、collected_count、next_page | 仅实测观察 |
图文详情 data[0].note_list[];视频详情平铺 data[] 列表 | 仅实测观察 |
hash_tag[].name、desc、time、shared_count、video_info_v2.capa.duration | 仅实测观察 |
评论 comments[]、has_more、JSON 字符串形式的 cursor、all_sort_strategies | 仅实测观察 |
常见用法
品类内容 brief
搜品类词和它的联想词,按收藏排序,把头部笔记的标题、标签和正文交给模型总结规律。
- 输入:种子关键词
- 输出:包含形式、钩子、标签的 brief
- 端点:
search-suggest、search-notes、image-note-detail
品牌与品类对标
把自家品牌笔记的点赞、收藏、评论和品类中位数放在一起比。认证字段帮不上忙,品牌账号 id 清单要自己维护。
- 输入:笔记 id
- 输出:对标表
- 端点:
image-note-detail、video-note-detail
挖掘顾虑和问题
抽头部笔记里点赞最多的评论,按主题聚类:肤感、泛白、价格、去哪买。
- 输入:头部笔记 id
- 输出:按热度排序的买家问题清单
- 端点:
note-comments
追踪内容形式变化
每周用同一个关键词跑一次,存下视频占比、点赞中位数和头部标签,看哪种形式开始起量。
- 输入:关键词
- 输出:时间序列
- 端点:
search-notes
实用提示
- 详情按
type分流。normal走image-note-detail,video走video-note-detail,两边都要核对id。 - 按
model_type过滤。 搜索结果里可能夹着ads卡片。 - 响应给什么就传回什么。 搜索翻页用
next_page、search_id、search_session_id,评论翻页用cursor字符串。 - 判断成功看
success,别看code。 我测到 app-v2 和 web-v3 的成功码不一样。 view_count和red_official_verified别拿来做判断。 前者读出来是 0,后者对品牌号也是 false。- 结果会漂移。 同样的搜索每次跑都不一样,记得去重并保存快照。
- 有一个接口我没跑通。
xiaohongshu/web-v3/note-detail要note_id加xsec_token。我用 app-v2 搜索结果里的 token 试了三次,全部返回 HTTP 503,里面包着上游 400。详情还是用 app-v2 这两个接口。 - 只读公开数据。 不发笔记,也不碰私密或仅粉丝可见的内容。
常见问题
需要小红书账号或登录吗?
不需要。用 SANDBASE_API_KEY 向 SandBase 鉴权就行,这几个只读端点不需要你这边有小红书账号,也不用走 OAuth。
收费吗? 笔记相关的端点目前在 SandBase 目录里标的是 Free,以目录页当前状态为准。
怎么拿到更多搜索结果?
把上一页返回的 next_page 填进 page,同时带上它返回的 search_id 和 search_session_id。
能直接用分享链接打开笔记吗?
详情和评论接口的参考里都有 share_text 参数,可以传小红书分享链接。不过本文只测了 note_id 这种方式。
和商品调研那篇有什么区别?
那篇围绕商品:search-products、product-detail 和按 sku_id 查的商品评价。这篇围绕笔记,也就是大家写的种草帖。
小结
想知道一个品类在小红书上什么内容跑得好,一个关键词就够了:搜笔记、用返回的 id 翻页,按类型选对详情接口打开头部笔记,再抽样点赞最多的评论。最后先对比一下收藏和点赞,再决定“跑得好”到底指什么。
其他小红书端点的用法,可以看小红书公开数据 API 总览。准备好了就可以开始: