猫捉老鼠
起因
我有个小红书链接解析器,从笔记里扒标题、作者、图片、评论、统计这些信息。某天有人反馈一个分享链接解析失败:
http://xhslink.com/o/4IVKoZeuj0O
用 aiohttp 请求,跟随重定向,最后落到 /404?errorCode=-510001。浏览器打开同一个链接完全没问题。
怎么回事?
短链重定向和 xsec_token
curl 跟一下重定向链:
xhslink.com/o/4IVKoZeuj0O
→ 302 → xiaohongshu.com/discovery/item/6955f790000000001f0042e2?xsec_token=...&type=normal
→ 302 → /404?errorCode=-510001
短链先 302 到笔记页面,然后笔记页面又 302 到 404。关键参数是 URL 里的 xsec_token,这是小红书的防盗链令牌,服务端会校验它跟当前会话是不是对得上。
对比浏览器抓包里的请求,第二次多出了一批 Cookie:
a1, webId, websectiga, sec_poison_id, gid, web_session, acw_tc, abRequestId
这些 Cookie 不是服务端通过 Set-Cookie 下发的,是页面里的 JavaScript 生成的。纯 HTTP 客户端拿不到。
身份材料的两种来源
要复现路线二和路线三,必须先建立一个关键认知——cookie 不是同一类东西。有些是本地生成的、有些是服务端签发的。
| 类型 | Cookie | 怎么得到 |
|---|---|---|
| 本地生成 | a1 / webId / abRequestId | Python 代码按固定算法算出来,完全不依赖网络 |
| 本地生成 | loadts / webBuild / xsecappid | 本地时间戳、版本号、应用 ID,直接写入 cookie jar |
| 服务端签发 | websectiga / sec_poison_id | POST /api/sec/v1/scripting,服务端下发一段 JS,本地按固定偏移解 |
| 服务端签发 | gid / acw_tc | POST /api/sec/v1/shield/webprofile,body 里带加密指纹,服务端 Set-Cookie |
| 服务端签发 | web_session | POST /api/sns/web/v1/login/activate,服务端下发,前缀 03 / 04 |
路线一不碰这两类任何一个(所以叫零 session 成本);路线二要把 9 步跑完、拿全所有 cookie,但不调签名接口;路线三除了全部 cookie,还要自己算出 5 个签名头。
路线一:移动端 UA + CDN 域名替换
这是最朴素的一条路。桌面端要 JS 生成 Cookie 才能过校验,那换个思路,伪装成手机。直接 aiohttp 跟随 302,最终页面 HTTP 200,不需要任何 Cookie 就能拿到 HTML。
MOBILE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/17.0 Mobile/15E148 Safari/604.1"
)
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
headers={
"User-Agent": MOBILE_UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
},
) as s:
async with s.get(share_url, allow_redirects=True) as resp:
final_url = str(resp.url)
html = await resp.text(errors="replace")
if "/404" in final_url or "errorCode=" in final_url:
raise RuntimeError("笔记可能已被删除或风控命中")
想想也合理。App 里的 WebView 不一定能跑完整的反爬 JS,移动端分享链接(app_platform=ios)走了一条更宽松的路径,不校验 xsec 相关 Cookie。
代价是图片 URL 带水印,形如 http://sns-webpic-qc.xhscdn.com/202604070308/<sign>/<image_id>!h5_1080jpg。末尾 !h5_1080jpg 是 CDN 层面的处理指令,CDN 在出图时合成水印;去掉后缀会 403(签名路径对不上)。水印是图片处理管线的一部分,不是客户端加的。
好在小红书还有另一个 CDN 域名 sns-img-qc.xhscdn.com(还有 ci.xiaohongshu.com),按裸 image_id 直接出原图,不签名、不带水印。换域名去水印的实现:
def cdn_strip_watermark(url: str) -> str:
clean = url.split("!")[0] if "!" in url else url # 去掉 !h5_1080jpg 这类后缀
path = urlparse(clean).path
parts = path.strip("/").split("/")
if len(parts) >= 3:
image_path = "/".join(parts[2:]) # 跳过 DATE 和 SIGN,只留 image_id
return f"https://sns-img-qc.xhscdn.com/{image_path}"
return url
从 HTML 里抠字段
移动端分享页的 HTML 里有 window.__INITIAL_STATE__ 这个变量,但里面的 note.noteDetailMap 是空对象——移动端数据注入方式不一样,笔记字段以 JSON 片段形式散落在 HTML 文本里(SSR 预渲染的 script 块、或者模板序列化产物),"nickname":"..." / "desc":"..." / "title":"..." 这种键值对就在文本里明文可读,直接 regex 扫就行。
每个字段定义一个梯队,按优先级挨个匹配,第一个非空非占位值就用:
def _first(patterns, html: str) -> str:
for p in patterns:
for m in re.finditer(p, html, re.I | re.DOTALL):
val = m.group(1).strip() if m.group(1) else ""
if val and val not in ("小红书", "小红书 - 你的生活指南"):
return val
return ""
标题的梯队必须加"小红书"占位过滤——<title> 标签经常是 "小红书" 或 "小红书 - 你的生活指南" 这种站名占位,遇到就跳到下一 pattern。不然兜底匹到站名一看拿到"小红书"就返回了,用户以为解析成功实际啥都没拿到:
title = _first([
r'<meta\s+property="og:title"\s+content="([^"]+)"',
r'"title":"([^"]+)"',
r"<title[^>]*>(.*?)</title>",
], html) or "小红书内容"
author_name = _first([r'"nickname":"([^"]+)"', r'"nickName":"([^"]+)"'], html)
author_id = _first([r'"userId":"([^"]+)"', r'"user_id":"([^"]+)"'], html)
content_raw = _first([r'"desc":"([^"]+)"', r'"content":"([^"]+)"', r'"text":"([^"]+)"'], html)
stats = {
"liked": _first([r'"likedCount":"?(\d+)"?'], html),
"comment": _first([r'"commentCount":"?(\d+)"?'], html),
"collect": _first([r'"collectedCount":"?(\d+)"?'], html),
"share": _first([r'"shareCount":"?(\d+)"?'], html),
}
pt_ms = _first([r'"time":(\d{13})'], html) # 毫秒时间戳
content 抓到的是 JS 转义字符串,得反转义:\u002F → /、\u0026 → &、\u003D → =、\u003F → ?、\u003A → :、\n、\t、" 等。转义反得不干净的话,URL 类字段(http:\u002F\u002F...)直接用不了:
def _unescape_js_string(s: str) -> str:
return (s.replace(r"\u002F", "/")
.replace(r"\u0026", "&")
.replace(r"\u003D", "=")
.replace(r"\u003F", "?")
.replace(r"\u003A", ":")
.replace(r"\n", "\n").replace(r"\t", "\t")
.replace(r"\"", '"'))
统计字段这里有个跨路线差异要记一笔:移动端 HTML 里是整数字符串("likedCount":"857"),而 API /v1/feed 返回的是可读格式("liked_count":"7.1万")。两条路线同字段语义不一样,下游要拼 UI 的话得自己对齐。
话题:必须走 tagList,不能走 desc
话题抓取有个容易踩的坑:想从 desc 字段里的 #话题名[话题]# 模式抠,看起来能匹但结果经常碎。原因是 desc 已经被 JSON 转义,话题名里的中文经过 \uXXXX 序列化,regex 的边界判断很容易切错。正确做法是走 tagList 数组,先 locate 再 findall:
topics = []
m = re.search(r'"tagList":\s*\[(.{0,5000}?)\]', html, re.DOTALL)
if m:
topics = re.findall(r'"name":"([^"]+)"', m.group(1))
if not topics: # 兜底:去 HTML 里扫 #xxx[话题]
topics = re.findall(r"#([^\s#\[]+)\[话题\]", html)
topics = list(dict.fromkeys(topics))[:20] # 去重保序,最多 20 个
tagList 是结构化来源、无转义干扰,每个话题的 name 字段原样可读,一把拿全。
图片:锚定 onix-carousel-item DOM
移动端分享页用 <div class="onix-carousel-item"><img src="..."></div> 的结构渲染图片轮播,这个 class 名很独特不会误匹,正则抓 src 就能把图片全拿出来,每个 URL 再过 cdn_strip_watermark 换域名:
carousel = re.findall(
r'class="onix-carousel-item"[^>]*>.*?<img[^>]*src=["\']([^"\'\s]+)["\']',
html, re.DOTALL,
)
images = [
{"index": i, "id": image_id_from_url(u), "url": u, "raw_url": cdn_strip_watermark(u)}
for i, u in enumerate(carousel, 1)
]
视频和实况:扫 masterUrl + 过滤水印变体
视频 URL 散点扫三种模式,每个都要过滤带水印的 _259.mp4 变体:
video_urls = []
for pat in [
r'"masterUrl":"([^"]+)"',
r'"master_url":"([^"]+)"',
r'"url":"(https://v\.xhscdn\.com[^"]+)"',
]:
for m in re.finditer(pat, html):
v = _unescape_js_string(m.group(1))
if "_259.mp4" in v: # 带水印视频变体,跳过
continue
if v not in video_urls:
video_urls.append(v)
其他后缀(_adapt_720p.mp4 / master URL 等)默认无水印。
内容类型判定
content_type 三个值:image / video / live_photo。判定顺序:
type_param = parse_qs(urlparse(final_url).query).get("type", [""])[0]
if type_param == "video" and video_urls:
content_type = "video"
videos = video_urls[:1] # 主视频一条就够
elif video_urls:
# 视频数跟图片数接近(比如都是 3 个),大概率是实况:每张静态图配一段 motion 视频
content_type = "live_photo"
live_photos = [
{"index": i, "image_url": images[i-1]["raw_url"], "video_url": video_urls[i-1]}
for i in range(1, min(len(images), len(video_urls)) + 1)
]
else:
content_type = "image"
实况笔记下游下载时每对存成 live_01_still.jpg + live_01_motion.mp4,打包后用户在手机相册就能还原成实况效果。
实测
图片笔记:http://xhslink.com/o/4IVKoZeuj0O
note_id : 6955f790000000001f0042e2
title : 𝐰𝐞𝐜𝐡𝐚𝐭|情侣头像
author : zhang / 5cd3f6730000000012033a83
content_type : image
images : 12 张(全部换 CDN 域名拿无水印原图)
stats : likes=857 comments=22 collects=290 shares=200
topics : ['今天你换头像了吗', '情侣头像', 'cp', '头像分享', '今日头像分享',
'可爱小猫', '猫猫是世界上最可爱的生物', '每日分享', '小动物头像', '头像']
publish : 2026-01-01T12:26:56
视频笔记:http://xhslink.com/o/Ap3mwS5Q0UD
note_id : 69e3114b000000002202916e
title : 仲夏可可很萌!
author : 用眼泪把你复习一遍 / 6690bced000000000f0348e9
content_type : video
videos : 1 条 master URL
stats : likes=1209 comments=154 collects=160 shares=42
topics : ['仲夏可可', '莓喵jk']
publish : 2026-04-18T13:06:19
实况笔记:http://xhslink.com/o/LRYdx90zeV
note_id : 69e1594b000000000b010eaf
title : 🇫🇷尼斯老城遇到杨超越董思成
author : 喵了个汪 / 6161f5460000000002022ced
content_type : live_photo
images : 3 张静态图 + 逐张配对的 motion 视频
stats : likes=7 comments=3419 collects=5392 shares=5024
topics : ['偶遇明星', '偶遇', '杨超越', '董思成', '法国', '尼斯', '尼斯老城区']
publish : 2026-04-17T05:48:59
三种内容类型都能出,主数据基本齐全。这条路的局限也很明确:
- 评论正文拿不到。评论不在分享页 HTML 里,要另打
/api/sns/web/v2/comment/page,那个接口又回到了需要完整签名的世界。 - 统计字段是整数而非可读格式。上面实况那条实际有 7.9 万赞,但这条路抓到的是整数
7——移动端 HTML 里点赞数就以被 CDN/SSR 截断的散落片段存在,精度不够。
路线二:PC Web 会话加 HTML 里的 __INITIAL_STATE__
小红书 PC 的分享页是服务端渲染的,数据直接嵌在 HTML 里:
<script>
window.__INITIAL_STATE__ = { "note": { "noteDetailMap": { "<note_id>": { "note": { ... } } } }, ... }
</script>
这里面是一份几乎完整的笔记 JSON——标题、正文、图片列表(带 infoList 多分辨率变体)、作者、交互数据、话题标签、视频流信息一应俱全,而且图片 URL 是无水印的原始 CDN 链接。
不需要调 /v1/feed,也就不需要 JSVMP 签名。但有代价:得先能以"像浏览器"的状态打开这个分享页。直接 aiohttp 加一个 UA 打过去会被重定向到 /login 或 404,所以要把浏览器那一整套初始化跑一遍。
Cookie 生成链:9 步 bootstrap
完整的 session 初始化跑下来是这 9 步,每一步产出的 cookie 会被下一步依赖:
1. GET / 载入首页
2. GET /api/sec/v1/ds?appId=xhs-pc-web 预拉 JSVMP 解密脚本
3. POST /api/redcaptcha/v2/getconfig 验证码配置
4. POST /api/sec/v1/scripting type=ds scripting 通道预热
5. POST /api/sec/v1/sbtsource 上报 sbt 源
6. POST /api/sec/v1/scripting callback=seccallback 下发 websectiga / sec_poison_id
7. POST /api/sec/v1/shield/webprofile 上报指纹 → 下发 gid
8. POST /api/sns/web/v1/login/activate 游客激活 → 下发 web_session
9. runtime bootstrap: user/me, system/config, zones,
homefeed/category, global/config,
racing_get, racing_report
少跑一步,后面某个接口就会挂。里面几个关键 cookie 的生成方式:
a1:这是整套身份的种子,完全本地生成。时间戳 hex + 30 位随机字符 + 平台码 + CRC32 校验,截前 52 位:
def gen_a1():
hex_data = hex(int(time.time() * 1000))[2:]
random_30 = ''.join(random.choices(
"abcdefghijklmnopqrstuvwxyz1234567890", k=30))
# GET_PLAT_FROM_CODE = 5(Windows 在前端 getPlatformCode 里走 other 分支返回 5)
text = hex_data + random_30 + "5" + "0" + "000"
crc32 = crc32_encode(text)
return (text + str(crc32))[:52] # 52 字节定长
webId:MD5(a1),跟 a1 绑定的设备标识。
websectiga 和 secpoisonid:第 6 步 POST /api/sec/v1/scripting callback=seccallback 返回一段 JS 字符串,形如 {"b":"<base64>","d":[...]})。服务端是想让你在浏览器里跑一遍 VM 解出 64 位密钥,我们静态解:
def gen_websectiga(js_text: str) -> str:
b = re.search(r'"b":"(.*?)",', js_text).group(1)
d = json.loads(re.search(r'"d":(.*?)\}\)', js_text).group(1))
# 1. base64 解码 b,按每 5 个字符一组拆列表,每个字符值取 ord(c) - 1
padding = len(b) % 4
if padding:
b += '=' * (4 - padding)
decoded = base64.b64decode(b).decode('utf-8')
decode_list = []
chunk = []
for c in decoded:
if len(chunk) == 5:
decode_list.append(chunk)
chunk = []
chunk.append(ord(c) - 1)
if chunk:
decode_list.append(chunk)
# 2. 按 d[92]:d[93]+1 切片,再按固定偏移二次查表得到 64 个整数
target = decode_list[d[92]:d[93]+1]
key = [d[target[675 + i][2]] for i in range(0, 128, 2)]
# 3. 按 for i in range(56, -1, -8) for j in range(8) 的双重循环拼 64 字符
return "".join(chr(key[i + j]) for i in range(56, -1, -8) for j in range(8))
那一串偏移量(92 / 93 / 675 / 56 / -1 / -8 / 8)都是从 JSVMP 字节码里抠出来的 magic 数字,会随版本微调。sec_poison_id 从同一次响应的另一个字段直接取。
gid 和 acw_tc:把 80+ 字段的浏览器指纹(UA、screen、WebGL、Canvas 哈希等)序列化 → base64 → DES-ECB 加密(密钥 zbp30y86,零填充到 8 字节块)→ hex。作为 profileData POST 到 webprofile,服务端在响应里 Set-Cookie 回这两个 cookie:
def encrypt_profile_data(fp: dict) -> str:
fp_json = json.dumps(fp, separators=(',', ':'), ensure_ascii=False)
fp_b64 = base64.b64encode(fp_json.encode())
cipher = DES.new(b"zbp30y86", DES.MODE_ECB)
# 零填充到 8 字节倍数
pad_len = 8 - len(fp_b64) % 8
padded = fp_b64 + b'\x00' * pad_len
return cipher.encrypt(padded).hex()
web_session:最后一步游客激活 POST /api/sns/web/v1/login/activate 空 body,服务端下发。前缀分两种:03 开头是设备级游客态,空 body POST 就能拿到;04 开头是真实登录态,只有带着已登录浏览器的 session cookie 进 activate 才能拿到。03 对 /v1/feed、分享页 HTML 这些公开数据都是够用的,真正需要 04 的是关注流、私信之类跟真实用户关系绑定的接口,跟笔记解析无关。
除此之外还会顺手写几个辅助 cookie:loadts(签名用的时间戳,每次 encrypted request 都会更新)、webBuild(等于 ARTIFACT_VERSION)、xsecappid(等于 xhs-pc-web)、abRequestId(一个 UUID)。这些缺一个都会被服务端当作异常客户端。
除了 cookie,headers 也要严格对齐。UA 里的 Chrome 版本号(比如 Chrome/147)必须和 sec-ch-ua 里的 Chromium 版本号一致,sec-ch-ua-platform、sec-ch-ua-mobile 也要给全。只要有一项对不上,签名接口就会 461。
版本同步:别把 ARTIFACT_VERSION 写死
ARTIFACT_VERSION 写死的话早晚会挂——一年多里线上从 4.83.1 一路爬到 6.7.0(LANGUAGE_VERSION 从 4.2.6 变成 4.3.5),大概一个季度一次大版本。版本落后最典型的症状是 shield/webprofile 阶段直接 471 verifyType=290:
{"msg": "当前版本过低,请刷新页面或关闭后重新打开页面", "code": 300042}
稳妥的做法是启动时去拉 https://www.xiaohongshu.com/,从返回 HTML 里找 <script src="...vendor-dynamic.xxx.js">,下载下来用正则抽版本号:
html = requests.get("https://www.xiaohongshu.com/").text
m = re.search(r'/vendor-dynamic\.([a-f0-9]+)\.js', html)
js = requests.get(f"https://static-resource.xhscdn.com/.../vendor-dynamic.{m.group(1)}.js").text
artifact_version = re.search(r'artifactVersion.*?(\d+\.\d+\.\d+)', js).group(1)
language_version = re.search(r'languageVersion.*?(\d+\.\d+\.\d+)', js).group(1)
同样的办法抽 sdkVersion、appId。抽不到就 fallback 到本地配置,但本地配置要定期跟,别把两年前的值留在那里。
打开分享页
Session 建好之后,按顺序试两个 URL:
candidates = [
f"https://www.xiaohongshu.com/discovery/item/{note_id}"
f"?app_platform=ios&app_version=9.22.1&share_from_user_hidden=true"
f"&xsec_source=app_share&type=normal&xsec_token={quote(xsec_token, safe='')}",
f"https://www.xiaohongshu.com/explore/{note_id}"
f"?xsec_token={quote(xsec_token, safe='')}&xsec_source=pc_feed",
]
第一个模拟 App 分享起源,第二个模拟从 PC feed 点进去。服务端会把 noteDetailMap[<note_id>].note 直接填到 HTML 里返回。请求头要模仿 document 级跳转,服务端才会把它当真实浏览器:
doc_headers = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8",
"referer": "https://www.xiaohongshu.com/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"upgrade-insecure-requests": "1",
}
xsec_token 必须 quote(token, safe='') 整串转义后再嵌 URL,否则末尾的 = 会被当成 URL query 分隔符。
从 HTML 里抠 __INITIAL_STATE__
小红书 PC 前端的 build 在某次更新后把赋值变成了紧贴写法 window.__INITIAL_STATE__={...}(原来是 window.__INITIAL_STATE__ = {...},两边各一个空格)。老代码 html.find("window.__INITIAL_STATE__ = ") 直接返回 -1,整条 HTML 解析就死在第一步,整个系统会静默退化成 <meta og:*> 兜底——表面上没报错,实际只剩标题和封面。
稳妥的写法是用正则匹配赋值符号、再从第一个 { 开始做大括号平衡扫描精确截取整个 JSON 对象,同时避开字符串字面量里的括号:
def extract_assigned_json_object(html: str, var_name: str) -> str:
m = re.search(rf"{re.escape(var_name)}\s*=\s*", html, flags=re.IGNORECASE)
if not m:
return ""
# 跳过等号后的空白,定位第一个 '{'
i = m.end()
while i < len(html) and html[i].isspace():
i += 1
if i >= len(html) or html[i] != "{":
return ""
depth, in_string, quote_char, escaped = 0, False, "", False
start = i
for cursor in range(i, len(html)):
ch = html[cursor]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote_char:
in_string = False
continue
if ch in ('"', "'"):
in_string, quote_char = True, ch
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return html[start : cursor + 1]
return ""
抠出来的 JSON 不是合法 JSON——小红书前端会塞 undefined 字面量:
"someField": undefined,
"someArray": [undefined]
json.loads 直接爆炸,先做两轮替换:
def sanitize_initial_state(raw: str) -> str:
s = re.sub(r":\s*undefined(?=[,}])", ": null", raw)
s = re.sub(r"\[\s*undefined\s*\]", "[null]", s)
return s
state = json.loads(sanitize_initial_state(extract_assigned_json_object(html, "window.__INITIAL_STATE__")))
笔记主体在 state["note"]["noteDetailMap"][<note_id>]["note"]。两个候选 URL 里第一个能命中就直接返回;如果都没命中,noteDetailMap 可能只剩一个 "undefined" 占位键(笔记被风控或已删),代码里专门留了"找第一个非 undefined key"的兜底:
def choose_note_payload(state: dict, note_id: str) -> dict:
detail_map = (state.get("note") or {}).get("noteDetailMap") or {}
if note_id and note_id in detail_map:
return (detail_map[note_id] or {}).get("note") or {}
for key, value in detail_map.items():
if key != "undefined" and isinstance(value, dict):
return value.get("note") or {}
return {}
拿到 note 之后字段都是结构化的:note["title"]、note["desc"]、note["user"]["nickname"]、note["user"]["userId"]、note["interactInfo"]["likedCount"](可读格式 "7.9万")、note["tagList"] 是 [{"id", "name", "type"}, ...]、note["imageList"] 每项带 urlDefault / urlPre / url / infoList(多分辨率变体)和 livePhoto 标志、note["video"]["media"]["stream"] 里有 h264 / h265 / av1 的 master URL 和备用 URL。
补评论和附属数据
评论不在 HTML 里,得单独请求 /api/sns/web/v2/comment/page。这个接口和 /api/sns/web/share/code(分享码)、/api/sns/web/v2/widgets(组件信息)一样,都是走完整签名流程的——和 /v1/feed 用的是同一套 session 和同一套加密头。也就是说,一旦 PC session 养干净了,拿评论、分享码、组件信息这些附属数据基本就是顺手的事。
实测
路线二在同一条 session 基础上跑三个测试链接,拿到的主字段跟路线一完全一致:
| 字段 | 图片笔记 | 视频笔记 | 实况笔记 |
|---|---|---|---|
| note_id | 6955f790... | 69e3114b... | 69e1594b... |
| title | 𝐰𝐞𝐜𝐡𝐚𝐭|情侣头像 | 仲夏可可很萌! | 🇫🇷尼斯老城遇到杨超越董思成 |
| content_type | image | video | live_photo |
| images | 12 | — | 3 |
| videos | — | 1 master URL | 3 motion(每张静态图配一段) |
| stats | 857 / 22 / 290 / 200 | 1209 / 154 / 160 / 42 | 79000 / 3419 / 5392 / 5024 |
跟路线一比,路线二的两点优势:
- 图片 URL 默认就是无水印的
!nd_dft_wlteh_jpg_3变体,不用再换域名也能拿干净图 - 统计数是结构化字段(上面实况那条在 HTML 层面是
79000,不是路线一那个被切碎的7)
代价是每次要跑完整 9 步 bootstrap,冷启动 10–30 秒;想拿评论正文还得再过一次签名接口。
路线三:走 /api/sns/web/v1/feed
HTML 能拿到绝大多数字段,但有几个场景还是只有 API 才干净。比如完整的 infoList 多分辨率变体、视频的多条备用 CDN URL、精确可读格式的统计数、实况的 stream.h264 结构、某些话题的完整元数据。所以最里面还留了一条直接调 feed 接口的路。
走通这条路的核心在于两件事:一是路线二里那套 session 和 cookie 生成链要先建好(/v1/feed 复用同一条 session,a1 / web_session / gid 一个都不能少);二是每个请求要带 5 个自己算出来的签名头。下面逐个说。
请求签名总览
| Header | 构造方式 |
|---|---|
x-s | 11 段字节流拼成的 124 字节数组 → 逐位 XOR 固定 124 字节 key → Base58 编码 → mns0101_ 前缀 → 外层包一层自定义 Base64 → XYS_ 前缀 |
x-s-common | 指纹 18 字段子集 ARC4 加密得到 b1 → 外层包含 plat_from_code / language_version / artifact_version / cookie_a1 / b1 / MRC 校验的字典 → 自定义 Base64 |
x-b3-traceid | 16 位随机 hex |
x-xray-traceid | (timestamp_ms << 23) | seq 补 16 hex + 64 位随机数补 16 hex,拼成 32 位 |
x-t | 当前毫秒时间戳 |
这五个头里后三个是纯本地运算,一看就能实现。真正重头戏是 x-s 和 x-s-common——下面把它们拆开讲。
x-s:11 段字节拼装后 XOR + Base58
浏览器里这个头由 window.mnsv2() 算出来,实际逻辑被编译成自定义 VM 字节码跑在 JSVMP 虚拟机里。静态还原方式是对着字节码逐条指令解释,还原出下面这段 Python:
def _encrypt_headers_x3(a1, loadts, uri, params=None, data=None):
# 把 query/body 拼到 uri 后面一起算签名
if params:
uri = f"{uri}?{urlencode(params).replace('%2C', ',')}"
if data is not None:
uri = uri + json.dumps(data, separators=(',', ':'))
md5_url = hashlib.md5(uri.encode()).hexdigest()
random_num = int(random.random() * 4294967295)
timestamp = int(time.time() * 1000)
# 11 段字节流 = 4+4+8+8+4+4+4+8+53+11+16 = 124 字节
part1 = [119, 104, 96, 41] # 固定 magic
part2 = list(random_num.to_bytes(4, 'little'))
# timestamp 8 字节 LE,第 0 字节改成 sum(b[1:5])+sum(b[5:8]) 低 8 位,全字节 XOR 41
b = list(timestamp.to_bytes(8, 'little'))
b[0] = (sum(b[1:5]) & 255) + sum(b[5:8]) & 0xFF
part3 = [x ^ 41 for x in b]
part4 = list(loadts.to_bytes(8, 'little'))
part5 = list((int(random.random() * 99) + 1).to_bytes(4, 'little'))
part6 = list((1293).to_bytes(4, 'little')) # window 属性数
part7 = list(len(uri.encode()).to_bytes(4, 'little'))
part8 = [b ^ (random_num & 255) for b in bytes.fromhex(md5_url)][:8]
part9 = [len(a1)] + list(a1.encode()) # 53
part10 = [len('xhs-pc-web')] + list(b'xhs-pc-web') # 11
part11 = [1, (random_num & 255) ^ 115,
249, 83, 103, 103, 201, 181, 131, 99, 94, 7, 68, 250, 132, 21]
raw = part1 + part2 + part3 + part4 + part5 + part6 + part7 \
+ part8 + part9 + part10 + part11
encrypted = [i ^ j for i, j in zip(raw, XOR_KEY_124)] # 固定 key
return "mns0101_" + base58_encode(bytes(encrypted), CUSTOM_BASE58_TABLE)
几个容易搞错的细节:
part3里先对 timestamp 的字节自校验一把再全体 XOR 41,校验没过(比如自己随手给的 timestamp 不对)服务端会直接 461。loadts不是笔记接口的时间戳,而是"本次签名"的时间戳——签名前先loadts = str(int(time.time() * 1000))写回 cookie jar,让服务端 echo 回来的 cookie 里永远带一个最新值;然后把这个毫秒数作为part4拼进数组。换句话说每次签名都重算,和x-t只差一两毫秒(但不是同一个值,不能混用)。1293是"Chrome 里Object.getOwnPropertyNames(window).length"的硬编码值。Chromium 版本迭代会微调这个数,偶尔得对着线上浏览器实测更新一把。XOR_KEY_124是 124 字节的常量表([175, 87, 43, 149, ...]),从 JS 里一把拖出来用。part9长度是动态的——len(a1)+ a1 bytes,但 a1 始终截到 52 字节,所以part9一直 53 字节,整个数组长度稳定在 124。
外层再包一层:
p = {
'x0': LANGUAGE_VERSION, 'x1': 'xhs-pc-web', 'x2': 'Windows',
'x3': _encrypt_headers_x3(...),
'x4': '' if data is None else 'object',
}
payload = url_quote(json.dumps(p, separators=(',', ':')))
return "XYS_" + custom_base64(utf8_to_bytes(payload), CUSTOM_BASE64_TABLE)
CUSTOM_BASE64_TABLE 是小红书自己的码表 ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5——标准 base64 是解不出来的,得实现一版按码表查表的 encoder。
x-s-common:b1 指纹 + MRC 校验
x-s-common 的主体是个字典,真正难的是其中的 b1 字段——指纹的子集经过 ARC4 加密再做一次自定义 Base64:
def _encrypt_b1(fp):
# 从 80+ 字段指纹里挑 18 个(x33~x39 + x42~x46 + x48~x52 + x82)
subset = {k: fp[k] for k in (
'x33','x34','x35','x36','x37','x38','x39',
'x42','x43','x44','x45','x46','x48','x49','x50','x51','x52','x82',
)}
raw = json.dumps(subset, separators=(',', ':'), ensure_ascii=False).encode()
cipher = ARC4.new(b'xhswebmplfbt')
ct = cipher.encrypt(raw).decode('latin1')
# URL 编码后再手动拆百分号序列,目的是把非 ASCII 拆成单字节
encoded = url_quote(ct, safe="!*'()~_-")
b = []
for c in encoded.split('%')[1:]:
chars = list(c)
b.append(int(''.join(chars[:2]), 16))
[b.append(ord(j)) for j in chars[2:]]
return custom_base64(bytes(b), CUSTOM_BASE64_TABLE)
外层字典:
source = {
's0': 5, # GET_PLAT_FROM_CODE(Windows 走 other 分支 = 5)
's1': '',
'x0': '1', # localStorage.getItem("b1b1"),写死
'x1': LANGUAGE_VERSION, # 4.3.5
'x2': 'Windows',
'x3': 'xhs-pc-web',
'x4': ARTIFACT_VERSION, # 6.7.0
'x5': cookie_a1,
'x6': '', 'x7': '', # 旧版是 XS / XT,现在写死
'x8': b1,
'x9': diy_mrc('' + '' + b1), # 自实现 CRC32,校验 b1
'x10': fp['x39'], # 调用计数器,写死也能过
'x11': 'normal',
}
return custom_base64(utf8_bytes(url_quote(json.dumps(source, separators=(',', ':'), ensure_ascii=False))),
CUSTOM_BASE64_TABLE)
diy_mrc 是改版 CRC32:表按标准多项式 0xedb88320 生成,但最后一步会对累加值做 JS 风格的 int32 wraparound(num >= 2**31 ? num % 2**32 : num - 2**32)。Python 里不做这个 wraparound 就对不上 JS 的有符号整型行为,服务端会把 x9 判成非法。
其他三个头
剩下三个头相对朴素:
x_b3_traceid = ''.join(random.choices('abcdef0123456789', k=16))
x_t = str(int(time.time() * 1000))
# x-xray 里有个 seq,每次调用自增——怀疑跟 x-s-common 里的 x10 是一类行为计数
seq = initial_seq + call_count
part1 = hex(int(time.time() * 1000) << 23 | seq)[2:].zfill(16)
part2 = hex((random_u32() << 32) | random_u32())[2:].zfill(16)
x_xray_traceid = part1 + part2
一个常见误区是"签名反正会换字节码,不如复用别人维护的实现"。实测下来只对一半:VM 指令集不换,换的只是字节码里若干操作的常量(124 字节 XOR key、Base58/Base64 码表、part11 尾巴、part1 magic、websectiga 里那几个 d[92]/d[93]/675 偏移)。一旦把静态实现写通了,下次服务端换 key,diff 一下新旧 vendor-dynamic.xxx.js 十几分钟就能定位到新常量替换掉;反倒是靠第三方实现时,它不更新你就得等。自己逆一遍最大的收获是知道"哪些常量会变、哪些结构不变"——结构这一层几乎从来不变。
指纹 context 是动态的
容易被忽视的细节是指纹不是一次性的。x-s-common 里的 b1 每次都拿当前 fp 重算,而 fp 会根据 storage_state(页面状态)和 page_context(当前 URL 和 referer)更新——比如调笔记详情要设成 explore/{note_id},调首页推荐设成首页。服务端看到的"你在哪个页面"和请求本身对不上,风控直接拉闸。调 feed 之前要先把 page_context 切到 explore/{note_id} 再算签名,这一步忘了就 461 verifyType=301。
page_context 根据 URL 里的路径映射成 page_type:
def normalize_page_context(ctx: dict | None) -> dict:
out = {
"location": "https://www.xiaohongshu.com/explore",
"referer": "https://www.xiaohongshu.com/",
"page_type": "explore",
}
if ctx:
out.update({k: v for k, v in ctx.items() if v is not None})
path = urlparse(out["location"]).path or "/"
if "/search_result" in path:
out["page_type"] = "search"
elif "/user/profile" in path:
out["page_type"] = "user_profile"
elif "/explore/" in path or "noteId=" in out["location"]:
out["page_type"] = "note_detail"
else:
out["page_type"] = "explore"
return out
指纹本身是 80+ 字段的字典 fp = {"x1": ua, "x2": "false", "x3": "zh-CN", "x4": 24, ...}——UA、语言、色深、设备内存、CPU、屏幕分辨率("1920;1080")、可用区域("1920;1040")、时区(-480,"Asia/Shanghai")、GPU vendor/renderer、plugins、canvas 指纹(x22)、voice 哈希(x53)、WebGL 扩展哈希(x56)、cookie 拷贝(x57)、DOM 相关计数(x58 div / x59 resource / x61 window.* 属性数 / x73 DOM 节点数)、x66 里的 {referer, location, frame}、x69 的 {prefix}|{window_props}|{script_count} 字符串、以及一些写死的 magic 字段(x30 "swf object not loaded"、x45 "__SEC_CAV__1-1-1-1-1|__SEC_WSA__|" 等)。整个 dict 的作用就是描述一次"浏览器在某个 URL 上某一刻的状态"。
每次调 encrypted 接口前会重算 fp 里受当前请求影响的字段:
# 关键:受请求变化的字段
fp["x39"] = str(storage["sc"]) # session counter,每请求 +1
fp["x44"] = str(int(time.time()*1000)) # 当前毫秒
fp["x57"] = "; ".join(f"{k}={v}" for k, v in cookies.items()) # 当前 cookie 快照
fp["x58"] = str(div_count) # 当前 page_type 下的 DOM div 数
fp["x59"] = str(resource_count)
fp["x61"] = str(window_props)
fp["x66"] = {"referer": ctx["referer"], "location": ctx["location"], "frame": 0}
fp["x69"] = f"{prefix}|{window_props}|{script_count}"
fp["x73"] = str(dom_count)
x58/x59/x73 在不同 page_type 上有不同的 baseline:explore 是 (204, 14, 1240),note_detail 加 (+36, +12, +420),search 加 (+18, +8, +180),user_profile 加 (+22, +10, +260)。这些 baseline 是对着真实浏览器在各页面抓出来的值,微调一两个数不影响风控,但整个量级和 page_type 必须对得上。
Feed 请求
/api/sns/web/v1/feed 的 POST body 很简单:
data = {
"source_note_id": note_id,
"image_formats": ["jpg", "webp", "avif"],
"extra": {"need_body_topic": "1"},
"xsec_source": "pc_feed", # 从分享 URL 的 query 取
"xsec_token": xsec_token,
}
签名输入是 (a1, loadts, "/api/sns/web/v1/feed", None, data)——把 data JSON 序列化(separators=(',', ':'))直接拼到 URI 后面一起 MD5。返回体形如:
{
"code": 0, "success": true, "msg": "成功",
"data": {
"items": [{
"id": "...", "model_type": "note",
"note_card": {
"note_id": "...", "type": "video",
"title": "...", "desc": "...",
"user": {"user_id": "...", "nickname": "..."},
"interact_info": {"liked_count": "7.9万", "comment_count": "3419", ...},
"image_list": [{"url_default": "...", "info_list": [...], "live_photo": true}],
"video": {"media": {"stream": {"h264": [{"master_url": "...", "backup_urls": [...]}]}}},
"tag_list": [{"id": "...", "name": "...", "type": "topic"}]
}
}]
}
}
拿 items[0].note_card 下游处理。
首发 session 质量与自动重试
即便签名、cookie、bootstrap 都对,新建 session 首次打 feed 仍有概率返回外壳成功但 data 为空的响应(success=True 但 items 空)。这不是代码问题,是服务端对"全新身份首次访问"的概率性降权。同一套代码同一个 note,这次返回空、重建一次 session 再打就有数据。
识别标志:success=True 且 items 为空。处理方式是自动重试循环——删掉持久化的设备状态(device_state.json)、重新跑一遍 9 步 bootstrap 换一套身份、再打 feed。单次调用里最多试 3 轮。冷启动单次命中大约 70-90%,加 retry 能拉到 95%+:
MAX_ATTEMPTS = 3
note_card = {}
for attempt in range(1, MAX_ATTEMPTS + 1):
if attempt > 1:
DEVICE_STATE_FILE.unlink(missing_ok=True) # 删持久化设备状态
session = await create_xhs_session() # 重跑 9 步 bootstrap 换一套身份
try:
resp = await session.apis.note.note_detail(note_id, xsec_token)
payload = await resp.json(content_type=None)
items = (payload.get("data") or {}).get("items") or []
if items and (items[0].get("note_card") or {}):
note_card = items[0]["note_card"]
break # 成功
except NeedCaptchaVerify as e:
# 216 语义验证码 / 102 滑块验证;捕获后让下一轮重置身份重试
last_err = f"attempt={attempt} verify={e.details.get('verifyType')}"
finally:
await session.close_session()
返回结构里还带两个可观测字段 feed_attempts(试了几次)和 feed_recovered_on_attempt(成功那次是第几轮),一眼能看出是不是靠重试救回来的——不走 feed 的路线恒为 1/1,只有 API 路线会动。
实测
图片笔记:
note_id : 6955f790000000001f0042e2
title : 𝐰𝐞𝐜𝐡𝐚𝐭|情侣头像
author : zhang / 5cd3f6730000000012033a83
content_type : image
images : 12 张(每张带 infoList 多分辨率、url_pre、url_default、url)
stats : liked=857 comment=22 collect=290 share=200
publish : 2026-01-01T12:26:56
feed_attempts=2 feed_recovered_on_attempt=2 ← 本次首发抖动,第二轮捞回
视频笔记:
note_id : 69e3114b000000002202916e
title : 仲夏可可很萌!
author : 用眼泪把你复习一遍 / 6690bced000000000f0348e9
content_type : video
videos : 6 条(master URL + 多条 CDN 备用)
stats : liked=1209 comment=154 collect=160 share=42
publish : 2026-04-18T13:06:19
实况笔记:
note_id : 69e1594b000000000b010eaf
title : 🇫🇷尼斯老城遇到杨超越董思成
author : 喵了个汪 / 6161f5460000000002022ced
content_type : live_photo
images : 3 张静态图
videos : 6 条(每张静态图配一段 motion,stream.h264 结构规整)
stats : liked=7.9万 comment=3419 collect=5392 share=5024 ← 可读格式
publish : 2026-04-17T05:48:59
路线三的字段粒度最细:视频多条备用 URL、实况 motion 流结构规整、统计数是可读的 7.9万。代价是每次要跑完整 9 步 bootstrap 再在本地算一次 JS 签名、签名里的常量随服务端版本可能要跟更新、偶发需要重试。
三条路线合到一起
实际跑起来,三条路线是按数据质量排序叠一起的:
step 1 /v1/feed + 自算 x-s / x-s-common → source=feed_api 最干净
step 2 PC 会话打开分享页,读 __INITIAL_STATE__ → source=initial_state 完整笔记
step 3 读 <meta og:*> 标签 → source=meta_fallback 只剩标题和封面
每次输出带一个 extract_source 字段,一眼就能看出这次数据是哪一档。
路线一是另一个入口——只要图、顺手拿点元数据的场景走它,不建 PC session,整体开销是完整路径的十分之一。把三条测试链接在三条路线上的结果拉到一起看,三条路线在"能拿什么数据"上其实几乎等价,真正的区别是建立连接的成本:
| 路线 | session 成本 | 签名成本 | 数据鲜度 | 评论内容 | 图片水印 |
|---|---|---|---|---|---|
| 移动端 UA + CDN 替换 | 无 | 无 | SSR 快照 | 拿不到 | !h5_1080jpg 带(换域名解) |
PC 会话 + __INITIAL_STATE__ | 9 步 bootstrap | 无 | 实时 | 需另打接口 | 默认不带 |
PC 会话 + /v1/feed API | 9 步 bootstrap + 指纹 | 自算 x-s / x-s-common | 实时 | 一并返回 | 默认不带 |
统一输出结构(三条路线都归到这一个 shape 方便下游):
{
"source": "mobile | html | api",
"extract_source": "feed_api | initial_state | meta_fallback",
"original_url": "...",
"final_url": "...",
"note_id": "...",
"xsec_token": "...",
"title": "...", "content": "...",
"content_type": "image | video | live_photo",
"author": {"name": "...", "user_id": "..."},
"stats": {"liked": "...", "comment": "...", "collect": "...", "share": "..."},
"topics": ["..."],
"publish_time_ms": 0, "publish_time_iso": "...",
"images": [{"index", "id", "url", "raw_url", "live_photo"}],
"videos": ["..."],
"live_photos": [{"index", "image_url", "video_url"}],
"feed_attempts": 1,
"feed_recovered_on_attempt": 1,
}
图片变体谱系
三条路线的实测下载结果放一起看,虽然 image_id 完全一致,CDN 给的"默认变体"差别不小:
| 来源 | URL 后缀 | 大小 | 水印 |
|---|---|---|---|
移动端 HTML(!h5_1080jpg) | 缩放到 1080,jpg | ~77 KB | 带 |
桌面 HTML __INITIAL_STATE__(!nd_dft_wlteh_jpg_3) | 默认处理,jpg | ~147 KB | 不带 |
API /v1/feed(!nd_dft_wlteh_webp_3) | 默认处理,webp | ~42 KB | 不带 |
换 sns-img-qc.xhscdn.com/ | 无后缀、无签名 | ~147 KB | 不带 |
几条值得记下来的规律:
- 水印是特定后缀变体
!h5_1080jpg的特性,不是sns-webpic-qc这个域名的特性。同一域名下的!nd_dft_wlteh_*变体默认不加水印。 - 桌面 HTML 给的 jpg 变体跟裸原图几乎同大小(147KB vs 147KB),说明那条变体是近无损的;换域名在这条路线上不是必须。
- API 给的 webp 变体才是真的小(42KB),体积优化占了主导。要原图得换域名。
- 三条路线的
image_id完全一致。所以只要知道任意一条路线给的 URL,都能通过"跳过 date 和 sign、保留image_id、换域名"拿到任意其他变体(包括无水印原图)。
排障顺序:链路坏了从哪一层查起
链路挂了的时候,最怕的是从"最后一步"往回猜。正确的方向是从最前面的、最便宜的检查往后推,每一层都有具体的信号:
第 1 层:入口参数
先确认 note_id 和 xsec_token 解析对了——特别是 xsec_token 尾部的 = 有没有被吃掉。排查方法:打印 resolve 完的 note_id 和 xsec_token,用它们直接 quote 之后手工拼一个 explore/{note_id}?xsec_token=... URL 在浏览器里打开。能看到笔记就说明入口参数没问题。
第 2 层:版本号
打到这一层的典型症状是 shield/webprofile 返回 471 verifyType=290("当前版本过低")。排查:看当前 ARTIFACT_VERSION / LANGUAGE_VERSION 跟从线上 vendor-dynamic.xxx.js 抽出来的是不是一样;不一样就手动同步一次再重跑。
第 3 层:Bootstrap
9 步任一步挂了,后面全是连锁反应。排查:每一步都 print 出状态码和关键 cookie 是否写入(websectiga / sec_poison_id / gid / acw_tc / web_session)。典型病灶:
- 6、7 两步
scripting的 type 参数错了 →websectiga解不出来 profileData里的 fingerprint 被 JSON encoder 转义了 non-ASCII → webprofile 401 / 461- activate 之前 cookie jar 不干净 → 拿不到 web_session,或拿到的是错误前缀
第 4 层:签名(路线三专属)
feed 返回 461 时看 header 里的 Verifytype:
| 现象 | 阶段 | 含义 | 对策 |
|---|---|---|---|
461 verifyType=216 | /v1/feed | 签名无效或 JSVMP 字节码换了 | 对齐新常量 / 重新解字节码 |
461 verifyType=301 | /v1/feed | session 质量不够或 page_context 不匹配 | 重置设备状态,切对 page_type |
461 verifyType=102 | /v1/feed | 触发滑块验证 | 人工过或挂验证码服务 |
461 verifyType=216 + HTML | 分享页 HTML | 语义验证码(选图点字) | 解析 theme/grid,调专门分支 |
code=0 success=true data={} | /v1/feed | 软风控:外壳成功但 data 被抽空 | 先查 xsec_token 末尾 = 是否被截断;否则重置设备状态重试 |
SSLError / UNEXPECTED_EOF | 任意 | TLS 指纹被识别或网络抖动 | 重试,换代理 |
被重定向到 /login | HTML 分享页 | session 过期或游客态被收回 | 清设备状态,重新激活 |
特别要警惕 code=0 data={}。它长得一切正常,实际上什么都没返回,很容易骗过粗糙的成功判断。
第 5 层:HTML 强提取(路线二)
如果 feed 持续挂、但 HTML 分享页能打开,退到路线二检查 __INITIAL_STATE__ 是否能抠出来。典型病灶是前面讲的"等号两边空格消失"的 marker regression——症状是 extract_source 一直落到 meta_fallback,标题正文还在但作者/图片/时间全空。这不是站点砍能力,是本地解析器回归。
第 6 层:移动端兜底(路线一)
PC 两条都波动时用移动端路线当最后的验证:如果这条也出不来数据,大概率是笔记本身被删或者风控直接拉黑了这个出口 IP;如果这条能出基础字段,说明内容还活着,只是 PC 这边状态不干净,重置 session 重来。
已证实、推断、不要再神化
做下来攒了不少经验,区分一下这三类免得后人走回头路。
已证实:
xsec_token尾部=被截断会导致/v1/feed出现code=0 success=true data={}window.__INITIAL_STATE__marker 写死会导致 HTML 完整提取整条退化到meta_fallback/v1/feed当前存在首发失败、二次重建 session 后恢复的真实波动web_session是服务端签发的,本地无论怎么拼都换不到"有效但服务端没见过"的值
高可信推断:
- session 质量(cookie 完整度 + 指纹一致性 + page_context 匹配)会显著影响 Route C 首发成功率
- 持久化的"稳定设备态"比完全随机新设备态更像真实浏览器,通过率更高
- JSVMP VM 指令集长期稳定,换的主要是字节码里的常量——意味着自己逆一遍后的维护成本其实可接受
不要再神化:
- "
web_session是 03 前缀就一定不行、04 前缀就一定可以" —— 不成立。03 对公开数据足够用,04 只在关注流 / 私信这些接口上才必要。 - "只要补上某个 header 就一定能过 feed" —— 不成立。feed 的成败是 session 质量、cookie 组合、指纹 context、时机波动综合结果。
- "路线一只能拿图" —— 不成立。移动端 HTML 里实际散落着大部分字段,散点 regex 就能扫出来;评论正文确实拿不到,但别的都能要。
- "水印是
sns-webpic-qc域名的特性" —— 不成立。水印是!h5_1080jpg这个处理后缀的特性,同域名的!nd_dft_wlteh_*变体默认不带水印。
小红书的反爬长什么样
把三条路线叠起来看,大致能画出小红书的防护层级:
| 层级 | 机制 | 说明 |
|---|---|---|
| 1 | xsec_token | URL 级别的防盗链令牌,绑定会话,别被末尾的 = 坑到 |
| 2 | JS Cookie 生成 | a1/webId/websectiga/gid 等由 JS 运行时生成 |
| 3 | 浏览器指纹 | 80+ 字段的设备指纹,DES 加密上报 |
| 4 | 请求签名 x-s | JSVMP 虚拟机执行,定期更换字节码常量 |
| 5 | TLS 指纹 + UA/client hints | UA 和 sec-ch-ua 不一致直接识别 |
| 6 | 版本校验 | ARTIFACT_VERSION 过期直接 471/290 |
| 7 | page_context 一致性 | 请求 URL 和 referer/location 对不上触发 301 |
| 8 | 语义验证码 | 216 型,要识别图片里的主题和网格 |
| 9 | 行为分析 | 频率、轨迹、时序等综合风控 |
第 4 层是硬骨头,但前面 8 层加起来其实已经卡掉了 99% 的脚本,大部分人连到第 9 层的门都没摸到。JSVMP 把签名算法编译成自定义字节码,VM 指令集基本稳定、换的是字节码里的常量(XOR key、码表、magic 字节等)——所以一次逆通之后维护只是 diff 新旧 vendor-dynamic.js 抓常量的事。真正每天要跟服务端博弈的反而是 session 质量:cookie 下发、版本号同步、指纹一致性,这些才是每次请求都在变的。
免责声明
本文仅记录个人在技术学习过程中的探索和思考,用于安全研究和教育目的。文中涉及的所有技术分析均针对公开可访问的页面和网络请求,未涉及任何非授权访问、数据批量抓取或商业用途。请遵守相关平台的服务条款和当地法律法规,不要将文中内容用于侵犯他人合法权益的场景。
后言
三条路线从"裸 HTTP"到"完整 Web 会话加官方 API"跨度挺大,但真正花时间的从来不是签名算法本身,而是 session 的质量——UA 和 client hints 要不要对齐、bootstrap 能不能跑完、web_session 拿到的是 03 还是 04、page_context 对不对应当前请求。签名算法一次写完就能用很久,session 质量是每次请求都要跟服务端博弈的日常工作。
另一个观察是,最贵的那条路(路线三)和最便宜的那条路(路线一)在"能拿什么数据"上差别并不大——两者返回的字段差异主要在评论正文、视频多备用 URL、可读格式统计数这三块。分层 fallback 把它们叠在一起,把昂贵路径的代价摊薄,同时用 extract_source 字段显式标记每次走到了哪一档,出问题不用手动猜。
sns-webpic-qc 和 sns-img-qc 这对 CDN 域名大概是整个系统里最适合作为收尾的例子。前者走签名路径,按 URL 后缀决定出图规格——加不加水印、分辨率、编码格式都藏在后缀里;后者按裸 image_id 直接给原图,不签名,也没有后缀概念。两个域名共用同一套 image_id 命名空间——知道了一张图在 webpic-qc 上的 id,在 img-qc 拿原图是免费的。正是这点让移动端分享页里那个 !h5_1080jpg 的水印版本可以被轻松绕过:不需要知道它怎么算签名,只要保留 image_id 然后换域名。这扇门未来可能会关上。但至少今天,它还开着。
附录:最小复现清单
按这个顺序把能力补齐就能把三条路线全跑通,对着打勾排查:
入口层
- 跟随
xhslink.com/o/...302,拿到最终 URL - 从最终 URL 抽
note_id/xsec_token/xsec_source,用parse_qs保留=padding
路线一(零 session)
- iPhone UA 请求分享页,跟随重定向
- 散点 regex 抽标题/作者/正文/stats/topics/时间,带"小红书"占位过滤
-
onix-carousel-item抓图片,_259.mp4过滤水印视频 -
cdn_strip_watermark换sns-img-qc拿原图 - contenttype 三分支判定(video / `livephoto` / image)
Cookie 生成(路线二、三共用)
-
a1=hex(ts) + rand30 + "50000" + crc32[:52],webId= MD5(a1) - 版本同步:从线上
vendor-dynamic.xxx.js抽artifactVersion/languageVersion - 9 步 bootstrap 按顺序跑通,每步检查对应 cookie 写入
-
websectiga的 base64 + 5 字符分组 + 二次查表解码 -
gid的 JSON → base64 → DES-ECB(zbp30y86) → hex -
web_session从 activate 响应拿,区分 03 / 04
路线二(HTML)
- 两个候选 URL(discovery/item + explore),带 document 级 headers
-
extract_assigned_json_object:正则 marker + brace-balance + 字符串转义 -
sanitize_initial_state:两轮undefined→null -
choose_note_payload:按 note_id 查,兜底找第一个非undefinedkey
路线三(API)
-
x-s:11 段字节 → XORKEY124 → Base58(custom) →mns0101_+ 外层 Base64(custom) +XYS_ -
x-s-common:ARC4(xhswebmplfbt) 加密 18 字段指纹 →b1,外层 dict + diy_mrc + Base64 -
x-b3-traceid/x-xray-traceid/x-t - 每次签名前更新
loadts和fp的动态字段(x39/x44/x57/x58/x59/x61/x66/x69/x73) -
page_context根据 URL 切到note_detail - feed POST body 固定 shape(
source_note_id/image_formats/extra/xsec_source/xsec_token) - 3 轮 auto-retry,每轮失败删
device_state.json+ 清 init cookie 缓存 + 重建 session
统一输出
- 三条路线归到同一套 dict shape,带
source/extract_source/feed_attempts/feed_recovered_on_attempt - 实测三种内容类型(image / video / live_photo)都能出完整字段