<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[顾の博客]]></title><description><![CDATA[哈喽~欢迎光临]]></description><link>https://blog.ovoii.io</link><image><url>https://blog.ovoii.io/innei.svg</url><title>顾の博客</title><link>https://blog.ovoii.io</link></image><generator>Yohaku (https://github.com/Innei/Yohaku)</generator><lastBuildDate>Tue, 05 May 2026 18:09:08 GMT</lastBuildDate><atom:link href="https://blog.ovoii.io/feed" rel="self" type="application/rss+xml"/><pubDate>Tue, 05 May 2026 18:09:08 GMT</pubDate><language><![CDATA[zh-CN]]></language><item><title><![CDATA[活]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/82">https://blog.ovoii.io/notes/82</a></blockquote><div><p>我总感觉我命不久矣。</p><p>我觉得活到八十岁是一件很恐怖的事情——这意味着我还要再度过差不多三倍我现在已经度过的时间，很难想象我还要继续面对这个可怕的世界将近六十年。</p></div><p style="text-align:right"><a href="https://blog.ovoii.io/notes/82#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/82</link><guid isPermaLink="true">https://blog.ovoii.io/notes/82</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sat, 11 Apr 2026 21:55:33 GMT</pubDate></item><item><title><![CDATA[猫捉老鼠]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/cat-catches-mouse">https://blog.ovoii.io/posts/notes/cat-catches-mouse</a></blockquote><div><h2 id="">起因</h2><p>我有个小红书链接解析器，从笔记里扒标题、作者、图片、评论、统计这些信息。某天有人反馈一个分享链接解析失败：</p><pre class=""><code class="">http://xhslink.com/o/4IVKoZeuj0O
</code></pre>
<p>用 <code>aiohttp</code> 请求，跟随重定向，最后落到 <code>/404?errorCode=-510001</code>。浏览器打开同一个链接完全没问题。</p><p>怎么回事？</p><h2 id="-xsectoken">短链重定向和 xsec_token</h2><p>curl 跟一下重定向链：</p><pre class=""><code class="">xhslink.com/o/4IVKoZeuj0O
  → 302 → xiaohongshu.com/discovery/item/6955f790000000001f0042e2?xsec_token=...&amp;type=normal
    → 302 → /404?errorCode=-510001
</code></pre>
<p>短链先 302 到笔记页面，然后笔记页面又 302 到 404。关键参数是 URL 里的 <code>xsec_token</code>，这是小红书的防盗链令牌，服务端会校验它跟当前会话是不是对得上。</p><p>对比浏览器抓包里的请求，第二次多出了一批 Cookie：</p><pre class=""><code class="">a1, webId, websectiga, sec_poison_id, gid, web_session, acw_tc, abRequestId
</code></pre>
<p>这些 Cookie 不是服务端通过 <code>Set-Cookie</code> 下发的，是页面里的 JavaScript 生成的。纯 HTTP 客户端拿不到。</p>
<h2 id="">身份材料的两种来源</h2><p>要复现路线二和路线三，必须先建立一个关键认知——<strong>cookie 不是同一类东西</strong>。有些是本地生成的、有些是服务端签发的。</p><table><thead><tr><th> 类型 </th><th> Cookie </th><th> 怎么得到 </th></tr></thead><tbody><tr><td> <strong>本地生成</strong> </td><td> <code>a1</code> / <code>webId</code> / <code>abRequestId</code> </td><td> Python 代码按固定算法算出来，完全不依赖网络 </td></tr><tr><td> <strong>本地生成</strong> </td><td> <code>loadts</code> / <code>webBuild</code> / <code>xsecappid</code> </td><td> 本地时间戳、版本号、应用 ID，直接写入 cookie jar </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>websectiga</code> / <code>sec_poison_id</code> </td><td> POST <code>/api/sec/v1/scripting</code>，服务端下发一段 JS，本地按固定偏移解 </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>gid</code> / <code>acw_tc</code> </td><td> POST <code>/api/sec/v1/shield/webprofile</code>，body 里带加密指纹，服务端 <code>Set-Cookie</code> </td></tr><tr><td> <strong>服务端签发</strong> </td><td> <code>web_session</code> </td><td> POST <code>/api/sns/web/v1/login/activate</code>，服务端下发，前缀 <code>03</code> / <code>04</code> </td></tr></tbody></table>
<p>路线一不碰这两类任何一个（所以叫零 session 成本）；路线二要把 9 步跑完、拿全所有 cookie，但不调签名接口；路线三除了全部 cookie，还要自己算出 5 个签名头。</p><h2 id="-ua--cdn-">路线一：移动端 UA + CDN 域名替换</h2><p>这是最朴素的一条路。桌面端要 JS 生成 Cookie 才能过校验，那换个思路，伪装成手机。直接 <code>aiohttp</code> 跟随 302，最终页面 HTTP 200，不需要任何 Cookie 就能拿到 HTML。</p><pre class="language-python lang-python"><code class="language-python lang-python">MOBILE_UA = (
    &quot;Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) &quot;
    &quot;AppleWebKit/605.1.15 (KHTML, like Gecko) &quot;
    &quot;Version/17.0 Mobile/15E148 Safari/604.1&quot;
)

async with aiohttp.ClientSession(
    timeout=aiohttp.ClientTimeout(total=30),
    headers={
        &quot;User-Agent&quot;: MOBILE_UA,
        &quot;Accept&quot;: &quot;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&quot;,
        &quot;Accept-Language&quot;: &quot;zh-CN,zh;q=0.9&quot;,
    },
) as s:
    async with s.get(share_url, allow_redirects=True) as resp:
        final_url = str(resp.url)
        html = await resp.text(errors=&quot;replace&quot;)

if &quot;/404&quot; in final_url or &quot;errorCode=&quot; in final_url:
    raise RuntimeError(&quot;笔记可能已被删除或风控命中&quot;)
</code></pre>
<p>想想也合理。App 里的 WebView 不一定能跑完整的反爬 JS，移动端分享链接（<code>app_platform=ios</code>）走了一条更宽松的路径，不校验 xsec 相关 Cookie。</p><p>代价是图片 URL 带水印，形如 <code>http://sns-webpic-qc.xhscdn.com/202604070308/&lt;sign&gt;/&lt;image_id&gt;!h5_1080jpg</code>。末尾 <code>!h5_1080jpg</code> 是 CDN 层面的处理指令，CDN 在出图时合成水印；去掉后缀会 403（签名路径对不上）。水印是图片处理管线的一部分，不是客户端加的。</p><p>好在小红书还有另一个 CDN 域名 <code>sns-img-qc.xhscdn.com</code>（还有 <code>ci.xiaohongshu.com</code>），按裸 image_id 直接出原图，不签名、不带水印。换域名去水印的实现：</p><pre class="language-python lang-python"><code class="language-python lang-python">def cdn_strip_watermark(url: str) -&gt; str:
    clean = url.split(&quot;!&quot;)[0] if &quot;!&quot; in url else url     # 去掉 !h5_1080jpg 这类后缀
    path = urlparse(clean).path
    parts = path.strip(&quot;/&quot;).split(&quot;/&quot;)
    if len(parts) &gt;= 3:
        image_path = &quot;/&quot;.join(parts[2:])                 # 跳过 DATE 和 SIGN，只留 image_id
        return f&quot;https://sns-img-qc.xhscdn.com/{image_path}&quot;
    return url
</code></pre>
<h3 id="-html-">从 HTML 里抠字段</h3><p>移动端分享页的 HTML 里<strong>有</strong> <code>window.__INITIAL_STATE__</code> 这个变量，但里面的 <code>note.noteDetailMap</code> 是空对象——移动端数据注入方式不一样，笔记字段以 JSON 片段形式散落在 HTML 文本里（SSR 预渲染的 script 块、或者模板序列化产物），<code>&quot;nickname&quot;:&quot;...&quot;</code> / <code>&quot;desc&quot;:&quot;...&quot;</code> / <code>&quot;title&quot;:&quot;...&quot;</code> 这种键值对就在文本里明文可读，直接 regex 扫就行。</p><p>每个字段定义一个梯队，按优先级挨个匹配，第一个非空非占位值就用：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _first(patterns, html: str) -&gt; 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 &quot;&quot;
            if val and val not in (&quot;小红书&quot;, &quot;小红书 - 你的生活指南&quot;):
                return val
    return &quot;&quot;
</code></pre>
<p>标题的梯队必须加&quot;小红书&quot;占位过滤——<code>&lt;title&gt;</code> 标签经常是 <code>&quot;小红书&quot;</code> 或 <code>&quot;小红书 - 你的生活指南&quot;</code> 这种站名占位，遇到就跳到下一 pattern。不然兜底匹到站名一看拿到&quot;小红书&quot;就返回了，用户以为解析成功实际啥都没拿到：</p><pre class="language-python lang-python"><code class="language-python lang-python">title = _first([
    r&#x27;&lt;meta\s+property=&quot;og:title&quot;\s+content=&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;title&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&quot;&lt;title[^&gt;]*&gt;(.*?)&lt;/title&gt;&quot;,
], html) or &quot;小红书内容&quot;

author_name = _first([r&#x27;&quot;nickname&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;nickName&quot;:&quot;([^&quot;]+)&quot;&#x27;], html)
author_id   = _first([r&#x27;&quot;userId&quot;:&quot;([^&quot;]+)&quot;&#x27;,   r&#x27;&quot;user_id&quot;:&quot;([^&quot;]+)&quot;&#x27;],  html)

content_raw = _first([r&#x27;&quot;desc&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;content&quot;:&quot;([^&quot;]+)&quot;&#x27;, r&#x27;&quot;text&quot;:&quot;([^&quot;]+)&quot;&#x27;], html)

stats = {
    &quot;liked&quot;:   _first([r&#x27;&quot;likedCount&quot;:&quot;?(\d+)&quot;?&#x27;],     html),
    &quot;comment&quot;: _first([r&#x27;&quot;commentCount&quot;:&quot;?(\d+)&quot;?&#x27;],   html),
    &quot;collect&quot;: _first([r&#x27;&quot;collectedCount&quot;:&quot;?(\d+)&quot;?&#x27;], html),
    &quot;share&quot;:   _first([r&#x27;&quot;shareCount&quot;:&quot;?(\d+)&quot;?&#x27;],     html),
}

pt_ms = _first([r&#x27;&quot;time&quot;:(\d{13})&#x27;], html)                  # 毫秒时间戳
</code></pre>
<p><code>content</code> 抓到的是 JS 转义字符串，得反转义：<code>\u002F</code> → <code>/</code>、<code>\u0026</code> → <code>&amp;</code>、<code>\u003D</code> → <code>=</code>、<code>\u003F</code> → <code>?</code>、<code>\u003A</code> → <code>:</code>、<code>\n</code>、<code>\t</code>、<code>&quot;</code> 等。转义反得不干净的话，URL 类字段（<code>http:\u002F\u002F...</code>）直接用不了：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _unescape_js_string(s: str) -&gt; str:
    return (s.replace(r&quot;\u002F&quot;, &quot;/&quot;)
             .replace(r&quot;\u0026&quot;, &quot;&amp;&quot;)
             .replace(r&quot;\u003D&quot;, &quot;=&quot;)
             .replace(r&quot;\u003F&quot;, &quot;?&quot;)
             .replace(r&quot;\u003A&quot;, &quot;:&quot;)
             .replace(r&quot;\n&quot;, &quot;\n&quot;).replace(r&quot;\t&quot;, &quot;\t&quot;)
             .replace(r&quot;\&quot;&quot;, &#x27;&quot;&#x27;))
</code></pre>
<p>统计字段这里有个跨路线差异要记一笔：<strong>移动端 HTML 里是整数字符串</strong>（<code>&quot;likedCount&quot;:&quot;857&quot;</code>），而 API <code>/v1/feed</code> 返回的是<strong>可读格式</strong>（<code>&quot;liked_count&quot;:&quot;7.1万&quot;</code>）。两条路线同字段语义不一样，下游要拼 UI 的话得自己对齐。</p><h3 id="-taglist-desc">话题：必须走 tagList，不能走 desc</h3><p>话题抓取有个容易踩的坑：想从 <code>desc</code> 字段里的 <code>#话题名[话题]#</code> 模式抠，看起来能匹但结果经常碎。原因是 <code>desc</code> 已经被 JSON 转义，话题名里的中文经过 <code>\uXXXX</code> 序列化，regex 的边界判断很容易切错。正确做法是走 <code>tagList</code> 数组，先 locate 再 findall：</p><pre class="language-python lang-python"><code class="language-python lang-python">topics = []
m = re.search(r&#x27;&quot;tagList&quot;:\s*\[(.{0,5000}?)\]&#x27;, html, re.DOTALL)
if m:
    topics = re.findall(r&#x27;&quot;name&quot;:&quot;([^&quot;]+)&quot;&#x27;, m.group(1))
if not topics:                                               # 兜底：去 HTML 里扫 #xxx[话题]
    topics = re.findall(r&quot;#([^\s#\[]+)\[话题\]&quot;, html)
topics = list(dict.fromkeys(topics))[:20]                    # 去重保序，最多 20 个
</code></pre>
<p><code>tagList</code> 是结构化来源、无转义干扰，每个话题的 <code>name</code> 字段原样可读，一把拿全。</p><h3 id="-onix-carousel-item-dom">图片：锚定 onix-carousel-item DOM</h3><p>移动端分享页用 <code>&lt;div class=&quot;onix-carousel-item&quot;&gt;&lt;img src=&quot;...&quot;&gt;&lt;/div&gt;</code> 的结构渲染图片轮播，这个 class 名很独特不会误匹，正则抓 <code>src</code> 就能把图片全拿出来，每个 URL 再过 <code>cdn_strip_watermark</code> 换域名：</p><pre class="language-python lang-python"><code class="language-python lang-python">carousel = re.findall(
    r&#x27;class=&quot;onix-carousel-item&quot;[^&gt;]*&gt;.*?&lt;img[^&gt;]*src=[&quot;\&#x27;]([^&quot;\&#x27;\s]+)[&quot;\&#x27;]&#x27;,
    html, re.DOTALL,
)
images = [
    {&quot;index&quot;: i, &quot;id&quot;: image_id_from_url(u), &quot;url&quot;: u, &quot;raw_url&quot;: cdn_strip_watermark(u)}
    for i, u in enumerate(carousel, 1)
]
</code></pre>
<h3 id="-masterurl--">视频和实况：扫 masterUrl + 过滤水印变体</h3><p>视频 URL 散点扫三种模式，每个都要过滤带水印的 <code>_259.mp4</code> 变体：</p><pre class="language-python lang-python"><code class="language-python lang-python">video_urls = []
for pat in [
    r&#x27;&quot;masterUrl&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;master_url&quot;:&quot;([^&quot;]+)&quot;&#x27;,
    r&#x27;&quot;url&quot;:&quot;(https://v\.xhscdn\.com[^&quot;]+)&quot;&#x27;,
]:
    for m in re.finditer(pat, html):
        v = _unescape_js_string(m.group(1))
        if &quot;_259.mp4&quot; in v:                                  # 带水印视频变体，跳过
            continue
        if v not in video_urls:
            video_urls.append(v)
</code></pre>
<p>其他后缀（<code>_adapt_720p.mp4</code> / master URL 等）默认无水印。</p><h3 id="">内容类型判定</h3><p><code>content_type</code> 三个值：<code>image</code> / <code>video</code> / <code>live_photo</code>。判定顺序：</p><pre class="language-python lang-python"><code class="language-python lang-python">type_param = parse_qs(urlparse(final_url).query).get(&quot;type&quot;, [&quot;&quot;])[0]

if type_param == &quot;video&quot; and video_urls:
    content_type = &quot;video&quot;
    videos = video_urls[:1]                                  # 主视频一条就够
elif video_urls:
    # 视频数跟图片数接近（比如都是 3 个），大概率是实况：每张静态图配一段 motion 视频
    content_type = &quot;live_photo&quot;
    live_photos = [
        {&quot;index&quot;: i, &quot;image_url&quot;: images[i-1][&quot;raw_url&quot;], &quot;video_url&quot;: video_urls[i-1]}
        for i in range(1, min(len(images), len(video_urls)) + 1)
    ]
else:
    content_type = &quot;image&quot;
</code></pre>
<p>实况笔记下游下载时每对存成 <code>live_01_still.jpg</code> + <code>live_01_motion.mp4</code>，打包后用户在手机相册就能还原成实况效果。</p><h3 id="">实测</h3><p><strong>图片笔记</strong>：<code>http://xhslink.com/o/4IVKoZeuj0O</code></p><pre class=""><code class="">note_id      : 6955f790000000001f0042e2
title        : 𝐰𝐞𝐜𝐡𝐚𝐭｜情侣头像
author       : zhang / 5cd3f6730000000012033a83
content_type : image
images       : 12 张（全部换 CDN 域名拿无水印原图）
stats        : likes=857  comments=22  collects=290  shares=200
topics       : [&#x27;今天你换头像了吗&#x27;, &#x27;情侣头像&#x27;, &#x27;cp&#x27;, &#x27;头像分享&#x27;, &#x27;今日头像分享&#x27;,
               &#x27;可爱小猫&#x27;, &#x27;猫猫是世界上最可爱的生物&#x27;, &#x27;每日分享&#x27;, &#x27;小动物头像&#x27;, &#x27;头像&#x27;]
publish      : 2026-01-01T12:26:56
</code></pre>
<p><strong>视频笔记</strong>：<code>http://xhslink.com/o/Ap3mwS5Q0UD</code></p><pre class=""><code class="">note_id      : 69e3114b000000002202916e
title        : 仲夏可可很萌！
author       : 用眼泪把你复习一遍 / 6690bced000000000f0348e9
content_type : video
videos       : 1 条 master URL
stats        : likes=1209  comments=154  collects=160  shares=42
topics       : [&#x27;仲夏可可&#x27;, &#x27;莓喵jk&#x27;]
publish      : 2026-04-18T13:06:19
</code></pre>
<p><strong>实况笔记</strong>：<code>http://xhslink.com/o/LRYdx90zeV</code></p><pre class=""><code class="">note_id      : 69e1594b000000000b010eaf
title        : 🇫🇷尼斯老城遇到杨超越董思成
author       : 喵了个汪 / 6161f5460000000002022ced
content_type : live_photo
images       : 3 张静态图 + 逐张配对的 motion 视频
stats        : likes=7  comments=3419  collects=5392  shares=5024
topics       : [&#x27;偶遇明星&#x27;, &#x27;偶遇&#x27;, &#x27;杨超越&#x27;, &#x27;董思成&#x27;, &#x27;法国&#x27;, &#x27;尼斯&#x27;, &#x27;尼斯老城区&#x27;]
publish      : 2026-04-17T05:48:59
</code></pre>
<p>三种内容类型都能出，主数据基本齐全。这条路的局限也很明确：</p><ul><li>评论正文拿不到。评论不在分享页 HTML 里，要另打 <code>/api/sns/web/v2/comment/page</code>，那个接口又回到了需要完整签名的世界。</li><li>统计字段是整数而非可读格式。上面实况那条实际有 7.9 万赞，但这条路抓到的是整数 <code>7</code>——移动端 HTML 里点赞数就以被 CDN/SSR 截断的散落片段存在，精度不够。</li></ul><h2 id="pc-web--html--initialstate">路线二：PC Web 会话加 HTML 里的 <code>__INITIAL_STATE__</code></h2><p>小红书 PC 的分享页是<strong>服务端渲染</strong>的，数据直接嵌在 HTML 里：</p><pre class="language-html lang-html"><code class="language-html lang-html">&lt;script&gt;
  window.__INITIAL_STATE__ = { &quot;note&quot;: { &quot;noteDetailMap&quot;: { &quot;&lt;note_id&gt;&quot;: { &quot;note&quot;: { ... } } } }, ... }
&lt;/script&gt;
</code></pre>
<p>这里面是一份几乎完整的笔记 JSON——标题、正文、图片列表（带 infoList 多分辨率变体）、作者、交互数据、话题标签、视频流信息一应俱全，而且图片 URL 是<strong>无水印</strong>的原始 CDN 链接。</p><p>不需要调 <code>/v1/feed</code>，也就不需要 JSVMP 签名。但有代价：得先能以&quot;像浏览器&quot;的状态打开这个分享页。直接 <code>aiohttp</code> 加一个 UA 打过去会被重定向到 <code>/login</code> 或 404，所以要把浏览器那一整套初始化跑一遍。</p><h3 id="cookie-9--bootstrap">Cookie 生成链：9 步 bootstrap</h3><p>完整的 session 初始化跑下来是这 9 步，每一步产出的 cookie 会被下一步依赖：</p><pre class=""><code class="">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
</code></pre>
<p>少跑一步，后面某个接口就会挂。里面几个关键 cookie 的生成方式：</p><p><strong>a1</strong>：这是整套身份的种子，完全本地生成。时间戳 hex + 30 位随机字符 + 平台码 + CRC32 校验，截前 52 位：</p><pre class="language-python lang-python"><code class="language-python lang-python">def gen_a1():
    hex_data = hex(int(time.time() * 1000))[2:]
    random_30 = &#x27;&#x27;.join(random.choices(
        &quot;abcdefghijklmnopqrstuvwxyz1234567890&quot;, k=30))
    # GET_PLAT_FROM_CODE = 5（Windows 在前端 getPlatformCode 里走 other 分支返回 5）
    text = hex_data + random_30 + &quot;5&quot; + &quot;0&quot; + &quot;000&quot;
    crc32 = crc32_encode(text)
    return (text + str(crc32))[:52]                      # 52 字节定长
</code></pre>
<p><strong>webId</strong>：<code>MD5(a1)</code>，跟 a1 绑定的设备标识。</p><p><strong>websectiga</strong> 和 <strong>sec<em>poison</em>id</strong>：第 6 步 <code>POST /api/sec/v1/scripting callback=seccallback</code> 返回一段 JS 字符串，形如 <code>{&quot;b&quot;:&quot;&lt;base64&gt;&quot;,&quot;d&quot;:[...]})</code>。服务端是想让你在浏览器里跑一遍 VM 解出 64 位密钥，我们静态解：</p><pre class="language-python lang-python"><code class="language-python lang-python">def gen_websectiga(js_text: str) -&gt; str:
    b = re.search(r&#x27;&quot;b&quot;:&quot;(.*?)&quot;,&#x27;, js_text).group(1)
    d = json.loads(re.search(r&#x27;&quot;d&quot;:(.*?)\}\)&#x27;, js_text).group(1))

    # 1. base64 解码 b，按每 5 个字符一组拆列表，每个字符值取 ord(c) - 1
    padding = len(b) % 4
    if padding:
        b += &#x27;=&#x27; * (4 - padding)
    decoded = base64.b64decode(b).decode(&#x27;utf-8&#x27;)
    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 &quot;&quot;.join(chr(key[i + j]) for i in range(56, -1, -8) for j in range(8))
</code></pre>
<p>那一串偏移量（92 / 93 / 675 / 56 / -1 / -8 / 8）都是从 JSVMP 字节码里抠出来的 magic 数字，会随版本微调。<code>sec_poison_id</code> 从同一次响应的另一个字段直接取。</p><p><strong>gid</strong> 和 <strong>acw_tc</strong>：把 80+ 字段的浏览器指纹（UA、screen、WebGL、Canvas 哈希等）序列化 → base64 → DES-ECB 加密（密钥 <code>zbp30y86</code>，零填充到 8 字节块）→ hex。作为 <code>profileData</code> POST 到 <code>webprofile</code>，服务端在响应里 <code>Set-Cookie</code> 回这两个 cookie：</p><pre class="language-python lang-python"><code class="language-python lang-python">def encrypt_profile_data(fp: dict) -&gt; str:
    fp_json = json.dumps(fp, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False)
    fp_b64 = base64.b64encode(fp_json.encode())
    cipher = DES.new(b&quot;zbp30y86&quot;, DES.MODE_ECB)
    # 零填充到 8 字节倍数
    pad_len = 8 - len(fp_b64) % 8
    padded = fp_b64 + b&#x27;\x00&#x27; * pad_len
    return cipher.encrypt(padded).hex()
</code></pre>
<p><strong>web_session</strong>：最后一步游客激活 <code>POST /api/sns/web/v1/login/activate</code> 空 body，服务端下发。前缀分两种：<code>03</code> 开头是设备级游客态，空 body POST 就能拿到；<code>04</code> 开头是真实登录态，只有带着已登录浏览器的 session cookie 进 activate 才能拿到。<strong>03 对 <code>/v1/feed</code>、分享页 HTML 这些公开数据都是够用的</strong>，真正需要 04 的是关注流、私信之类跟真实用户关系绑定的接口，跟笔记解析无关。</p><p>除此之外还会顺手写几个辅助 cookie：<code>loadts</code>（签名用的时间戳，每次 encrypted request 都会更新）、<code>webBuild</code>（等于 <code>ARTIFACT_VERSION</code>）、<code>xsecappid</code>（等于 <code>xhs-pc-web</code>）、<code>abRequestId</code>（一个 UUID）。这些缺一个都会被服务端当作异常客户端。</p><p>除了 cookie，headers 也要严格对齐。UA 里的 Chrome 版本号（比如 <code>Chrome/147</code>）必须和 <code>sec-ch-ua</code> 里的 Chromium 版本号一致，<code>sec-ch-ua-platform</code>、<code>sec-ch-ua-mobile</code> 也要给全。只要有一项对不上，签名接口就会 461。</p><h3 id="-artifactversion-">版本同步：别把 ARTIFACT_VERSION 写死</h3><p><code>ARTIFACT_VERSION</code> 写死的话早晚会挂——一年多里线上从 <code>4.83.1</code> 一路爬到 <code>6.7.0</code>（<code>LANGUAGE_VERSION</code> 从 <code>4.2.6</code> 变成 <code>4.3.5</code>），大概一个季度一次大版本。版本落后最典型的症状是 <code>shield/webprofile</code> 阶段直接 <code>471 verifyType=290</code>：</p><pre class="language-json lang-json"><code class="language-json lang-json">{&quot;msg&quot;: &quot;当前版本过低，请刷新页面或关闭后重新打开页面&quot;, &quot;code&quot;: 300042}
</code></pre>
<p>稳妥的做法是启动时去拉 <code>https://www.xiaohongshu.com/</code>，从返回 HTML 里找 <code>&lt;script src=&quot;...vendor-dynamic.xxx.js&quot;&gt;</code>，下载下来用正则抽版本号：</p><pre class="language-python lang-python"><code class="language-python lang-python">html = requests.get(&quot;https://www.xiaohongshu.com/&quot;).text
m = re.search(r&#x27;/vendor-dynamic\.([a-f0-9]+)\.js&#x27;, html)
js = requests.get(f&quot;https://static-resource.xhscdn.com/.../vendor-dynamic.{m.group(1)}.js&quot;).text
artifact_version = re.search(r&#x27;artifactVersion.*?(\d+\.\d+\.\d+)&#x27;, js).group(1)
language_version = re.search(r&#x27;languageVersion.*?(\d+\.\d+\.\d+)&#x27;, js).group(1)
</code></pre>
<p>同样的办法抽 <code>sdkVersion</code>、<code>appId</code>。抽不到就 fallback 到本地配置，但<strong>本地配置要定期跟</strong>，别把两年前的值留在那里。</p><h3 id="">打开分享页</h3><p>Session 建好之后，按顺序试两个 URL：</p><pre class="language-python lang-python"><code class="language-python lang-python">candidates = [
    f&quot;https://www.xiaohongshu.com/discovery/item/{note_id}&quot;
    f&quot;?app_platform=ios&amp;app_version=9.22.1&amp;share_from_user_hidden=true&quot;
    f&quot;&amp;xsec_source=app_share&amp;type=normal&amp;xsec_token={quote(xsec_token, safe=&#x27;&#x27;)}&quot;,
    f&quot;https://www.xiaohongshu.com/explore/{note_id}&quot;
    f&quot;?xsec_token={quote(xsec_token, safe=&#x27;&#x27;)}&amp;xsec_source=pc_feed&quot;,
]
</code></pre>
<p>第一个模拟 App 分享起源，第二个模拟从 PC feed 点进去。服务端会把 <code>noteDetailMap[&lt;note_id&gt;].note</code> 直接填到 HTML 里返回。请求头要模仿 document 级跳转，服务端才会把它当真实浏览器：</p><pre class="language-python lang-python"><code class="language-python lang-python">doc_headers = {
    &quot;accept&quot;: &quot;text/html,application/xhtml+xml,application/xml;q=0.9,&quot;
              &quot;image/avif,image/webp,image/apng,*/*;q=0.8&quot;,
    &quot;referer&quot;: &quot;https://www.xiaohongshu.com/&quot;,
    &quot;sec-fetch-dest&quot;: &quot;document&quot;,
    &quot;sec-fetch-mode&quot;: &quot;navigate&quot;,
    &quot;sec-fetch-site&quot;: &quot;same-origin&quot;,
    &quot;upgrade-insecure-requests&quot;: &quot;1&quot;,
}
</code></pre>
<p><code>xsec_token</code> 必须 <code>quote(token, safe=&#x27;&#x27;)</code> 整串转义后再嵌 URL，否则末尾的 <code>=</code> 会被当成 URL query 分隔符。</p><h3 id="-html--initialstate">从 HTML 里抠 <code>__INITIAL_STATE__</code></h3><p>小红书 PC 前端的 build 在某次更新后把赋值变成了紧贴写法 <code>window.__INITIAL_STATE__={...}</code>（原来是 <code>window.__INITIAL_STATE__ = {...}</code>，两边各一个空格）。老代码 <code>html.find(&quot;window.__INITIAL_STATE__ = &quot;)</code> 直接返回 <code>-1</code>，整条 HTML 解析就死在第一步，整个系统会静默退化成 <code>&lt;meta og:*&gt;</code> 兜底——表面上没报错，实际只剩标题和封面。</p><p>稳妥的写法是用正则匹配赋值符号、再从第一个 <code>{</code> 开始做<strong>大括号平衡扫描</strong>精确截取整个 JSON 对象，同时避开字符串字面量里的括号：</p><pre class="language-python lang-python"><code class="language-python lang-python">def extract_assigned_json_object(html: str, var_name: str) -&gt; str:
    m = re.search(rf&quot;{re.escape(var_name)}\s*=\s*&quot;, html, flags=re.IGNORECASE)
    if not m:
        return &quot;&quot;
    # 跳过等号后的空白，定位第一个 &#x27;{&#x27;
    i = m.end()
    while i &lt; len(html) and html[i].isspace():
        i += 1
    if i &gt;= len(html) or html[i] != &quot;{&quot;:
        return &quot;&quot;

    depth, in_string, quote_char, escaped = 0, False, &quot;&quot;, False
    start = i
    for cursor in range(i, len(html)):
        ch = html[cursor]
        if in_string:
            if escaped:
                escaped = False
            elif ch == &quot;\\&quot;:
                escaped = True
            elif ch == quote_char:
                in_string = False
            continue
        if ch in (&#x27;&quot;&#x27;, &quot;&#x27;&quot;):
            in_string, quote_char = True, ch
        elif ch == &quot;{&quot;:
            depth += 1
        elif ch == &quot;}&quot;:
            depth -= 1
            if depth == 0:
                return html[start : cursor + 1]
    return &quot;&quot;
</code></pre>
<p>抠出来的 JSON <strong>不是合法 JSON</strong>——小红书前端会塞 <code>undefined</code> 字面量：</p><pre class=""><code class="">&quot;someField&quot;: undefined,
&quot;someArray&quot;: [undefined]
</code></pre>
<p><code>json.loads</code> 直接爆炸，先做两轮替换：</p><pre class="language-python lang-python"><code class="language-python lang-python">def sanitize_initial_state(raw: str) -&gt; str:
    s = re.sub(r&quot;:\s*undefined(?=[,}])&quot;, &quot;: null&quot;, raw)
    s = re.sub(r&quot;\[\s*undefined\s*\]&quot;, &quot;[null]&quot;, s)
    return s

state = json.loads(sanitize_initial_state(extract_assigned_json_object(html, &quot;window.__INITIAL_STATE__&quot;)))
</code></pre>
<p>笔记主体在 <code>state[&quot;note&quot;][&quot;noteDetailMap&quot;][&lt;note_id&gt;][&quot;note&quot;]</code>。两个候选 URL 里第一个能命中就直接返回；如果都没命中，<code>noteDetailMap</code> 可能只剩一个 <code>&quot;undefined&quot;</code> 占位键（笔记被风控或已删），代码里专门留了&quot;找第一个非 <code>undefined</code> key&quot;的兜底：</p><pre class="language-python lang-python"><code class="language-python lang-python">def choose_note_payload(state: dict, note_id: str) -&gt; dict:
    detail_map = (state.get(&quot;note&quot;) or {}).get(&quot;noteDetailMap&quot;) or {}
    if note_id and note_id in detail_map:
        return (detail_map[note_id] or {}).get(&quot;note&quot;) or {}
    for key, value in detail_map.items():
        if key != &quot;undefined&quot; and isinstance(value, dict):
            return value.get(&quot;note&quot;) or {}
    return {}
</code></pre>
<p>拿到 <code>note</code> 之后字段都是结构化的：<code>note[&quot;title&quot;]</code>、<code>note[&quot;desc&quot;]</code>、<code>note[&quot;user&quot;][&quot;nickname&quot;]</code>、<code>note[&quot;user&quot;][&quot;userId&quot;]</code>、<code>note[&quot;interactInfo&quot;][&quot;likedCount&quot;]</code>（可读格式 <code>&quot;7.9万&quot;</code>）、<code>note[&quot;tagList&quot;]</code> 是 <code>[{&quot;id&quot;, &quot;name&quot;, &quot;type&quot;}, ...]</code>、<code>note[&quot;imageList&quot;]</code> 每项带 <code>urlDefault</code> / <code>urlPre</code> / <code>url</code> / <code>infoList</code>（多分辨率变体）和 <code>livePhoto</code> 标志、<code>note[&quot;video&quot;][&quot;media&quot;][&quot;stream&quot;]</code> 里有 h264 / h265 / av1 的 master URL 和备用 URL。</p><h3 id="">补评论和附属数据</h3><p>评论不在 HTML 里，得单独请求 <code>/api/sns/web/v2/comment/page</code>。这个接口和 <code>/api/sns/web/share/code</code>（分享码）、<code>/api/sns/web/v2/widgets</code>（组件信息）一样，都是走完整签名流程的——和 <code>/v1/feed</code> 用的是同一套 session 和同一套加密头。也就是说，一旦 PC session 养干净了，拿评论、分享码、组件信息这些附属数据基本就是顺手的事。</p><h3 id="">实测</h3><p>路线二在同一条 session 基础上跑三个测试链接，拿到的主字段跟路线一完全一致：</p><table><thead><tr><th> 字段 </th><th> 图片笔记 </th><th> 视频笔记 </th><th> 实况笔记 </th></tr></thead><tbody><tr><td> note_id </td><td> 6955f790... </td><td> 69e3114b... </td><td> 69e1594b... </td></tr><tr><td> title </td><td> 𝐰𝐞𝐜𝐡𝐚𝐭｜情侣头像 </td><td> 仲夏可可很萌！ </td><td> 🇫🇷尼斯老城遇到杨超越董思成 </td></tr><tr><td> content_type </td><td> image </td><td> video </td><td> live_photo </td></tr><tr><td> images </td><td> 12 </td><td> — </td><td> 3 </td></tr><tr><td> videos </td><td> — </td><td> 1 master URL </td><td> 3 motion（每张静态图配一段） </td></tr><tr><td> stats </td><td> 857 / 22 / 290 / 200 </td><td> 1209 / 154 / 160 / 42 </td><td> 79000 / 3419 / 5392 / 5024 </td></tr></tbody></table><p>跟路线一比，路线二的两点优势：</p><ul><li>图片 URL 默认就是无水印的 <code>!nd_dft_wlteh_jpg_3</code> 变体，不用再换域名也能拿干净图</li><li>统计数是结构化字段（上面实况那条在 HTML 层面是 <code>79000</code>，不是路线一那个被切碎的 <code>7</code>）</li></ul><p>代价是每次要跑完整 9 步 bootstrap，冷启动 10–30 秒；想拿评论正文还得再过一次签名接口。</p><h2 id="-apisnswebv1feed">路线三：走 <code>/api/sns/web/v1/feed</code></h2><p>HTML 能拿到绝大多数字段，但有几个场景还是只有 API 才干净。比如完整的 <code>infoList</code> 多分辨率变体、视频的多条备用 CDN URL、精确可读格式的统计数、实况的 stream.h264 结构、某些话题的完整元数据。所以最里面还留了一条直接调 feed 接口的路。</p><p>走通这条路的核心在于两件事：一是路线二里那套 session 和 cookie 生成链要先建好（<code>/v1/feed</code> 复用同一条 session，<code>a1</code> / <code>web_session</code> / <code>gid</code> 一个都不能少）；二是每个请求要带 5 个自己算出来的签名头。下面逐个说。</p><h3 id="">请求签名总览</h3><table><thead><tr><th> Header </th><th> 构造方式 </th></tr></thead><tbody><tr><td> <code>x-s</code> </td><td> 11 段字节流拼成的 124 字节数组 → 逐位 XOR 固定 124 字节 key → Base58 编码 → <code>mns0101_</code> 前缀 → 外层包一层自定义 Base64 → <code>XYS_</code> 前缀 </td></tr><tr><td> <code>x-s-common</code> </td><td> 指纹 18 字段子集 ARC4 加密得到 <code>b1</code> → 外层包含 <code>plat_from_code</code> / <code>language_version</code> / <code>artifact_version</code> / <code>cookie_a1</code> / <code>b1</code> / MRC 校验的字典 → 自定义 Base64 </td></tr><tr><td> <code>x-b3-traceid</code> </td><td> 16 位随机 hex </td></tr><tr><td> <code>x-xray-traceid</code> </td><td> <code>(timestamp_ms &lt;&lt; 23) | seq</code> 补 16 hex + 64 位随机数补 16 hex，拼成 32 位 </td></tr><tr><td> <code>x-t</code> </td><td> 当前毫秒时间戳 </td></tr></tbody></table><p>这五个头里后三个是纯本地运算，一看就能实现。真正重头戏是 <code>x-s</code> 和 <code>x-s-common</code>——下面把它们拆开讲。</p><h3 id="x-s11--xor--base58">x-s：11 段字节拼装后 XOR + Base58</h3><p>浏览器里这个头由 <code>window.mnsv2()</code> 算出来，实际逻辑被编译成自定义 VM 字节码跑在 JSVMP 虚拟机里。静态还原方式是对着字节码逐条指令解释，还原出下面这段 Python：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _encrypt_headers_x3(a1, loadts, uri, params=None, data=None):
    # 把 query/body 拼到 uri 后面一起算签名
    if params:
        uri = f&quot;{uri}?{urlencode(params).replace(&#x27;%2C&#x27;, &#x27;,&#x27;)}&quot;
    if data is not None:
        uri = uri + json.dumps(data, separators=(&#x27;,&#x27;, &#x27;:&#x27;))

    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, &#x27;little&#x27;))
    # timestamp 8 字节 LE，第 0 字节改成 sum(b[1:5])+sum(b[5:8]) 低 8 位，全字节 XOR 41
    b = list(timestamp.to_bytes(8, &#x27;little&#x27;))
    b[0] = (sum(b[1:5]) &amp; 255) + sum(b[5:8]) &amp; 0xFF
    part3  = [x ^ 41 for x in b]
    part4  = list(loadts.to_bytes(8, &#x27;little&#x27;))
    part5  = list((int(random.random() * 99) + 1).to_bytes(4, &#x27;little&#x27;))
    part6  = list((1293).to_bytes(4, &#x27;little&#x27;))                       # window 属性数
    part7  = list(len(uri.encode()).to_bytes(4, &#x27;little&#x27;))
    part8  = [b ^ (random_num &amp; 255) for b in bytes.fromhex(md5_url)][:8]
    part9  = [len(a1)] + list(a1.encode())                             # 53
    part10 = [len(&#x27;xhs-pc-web&#x27;)] + list(b&#x27;xhs-pc-web&#x27;)                 # 11
    part11 = [1, (random_num &amp; 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 &quot;mns0101_&quot; + base58_encode(bytes(encrypted), CUSTOM_BASE58_TABLE)
</code></pre>
<p>几个容易搞错的细节：</p><ul><li><code>part3</code> 里先对 timestamp 的字节自校验一把再全体 XOR 41，校验没过（比如自己随手给的 timestamp 不对）服务端会直接 461。</li><li><code>loadts</code> 不是笔记接口的时间戳，而是&quot;本次签名&quot;的时间戳——签名前先 <code>loadts = str(int(time.time() * 1000))</code> 写回 cookie jar，让服务端 echo 回来的 cookie 里永远带一个最新值；然后把这个毫秒数作为 <code>part4</code> 拼进数组。换句话说每次签名都重算，和 <code>x-t</code> 只差一两毫秒（但不是同一个值，不能混用）。</li><li><code>1293</code> 是&quot;Chrome 里 <code>Object.getOwnPropertyNames(window).length</code>&quot;的硬编码值。Chromium 版本迭代会微调这个数，偶尔得对着线上浏览器实测更新一把。</li><li><code>XOR_KEY_124</code> 是 124 字节的常量表（<code>[175, 87, 43, 149, ...]</code>），从 JS 里一把拖出来用。</li><li><code>part9</code> 长度是动态的——<code>len(a1)</code> + a1 bytes，但 a1 始终截到 52 字节，所以 <code>part9</code> 一直 53 字节，整个数组长度稳定在 124。</li></ul><p>外层再包一层：</p><pre class="language-python lang-python"><code class="language-python lang-python">p = {
    &#x27;x0&#x27;: LANGUAGE_VERSION, &#x27;x1&#x27;: &#x27;xhs-pc-web&#x27;, &#x27;x2&#x27;: &#x27;Windows&#x27;,
    &#x27;x3&#x27;: _encrypt_headers_x3(...),
    &#x27;x4&#x27;: &#x27;&#x27; if data is None else &#x27;object&#x27;,
}
payload = url_quote(json.dumps(p, separators=(&#x27;,&#x27;, &#x27;:&#x27;)))
return &quot;XYS_&quot; + custom_base64(utf8_to_bytes(payload), CUSTOM_BASE64_TABLE)
</code></pre>
<p><code>CUSTOM_BASE64_TABLE</code> 是小红书自己的码表 <code>ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5</code>——标准 <code>base64</code> 是解不出来的，得实现一版按码表查表的 encoder。</p><h3 id="x-s-commonb1---mrc-">x-s-common：b1 指纹 + MRC 校验</h3><p><code>x-s-common</code> 的主体是个字典，真正难的是其中的 <code>b1</code> 字段——指纹的子集经过 ARC4 加密再做一次自定义 Base64：</p><pre class="language-python lang-python"><code class="language-python lang-python">def _encrypt_b1(fp):
    # 从 80+ 字段指纹里挑 18 个（x33~x39 + x42~x46 + x48~x52 + x82）
    subset = {k: fp[k] for k in (
        &#x27;x33&#x27;,&#x27;x34&#x27;,&#x27;x35&#x27;,&#x27;x36&#x27;,&#x27;x37&#x27;,&#x27;x38&#x27;,&#x27;x39&#x27;,
        &#x27;x42&#x27;,&#x27;x43&#x27;,&#x27;x44&#x27;,&#x27;x45&#x27;,&#x27;x46&#x27;,&#x27;x48&#x27;,&#x27;x49&#x27;,&#x27;x50&#x27;,&#x27;x51&#x27;,&#x27;x52&#x27;,&#x27;x82&#x27;,
    )}
    raw = json.dumps(subset, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False).encode()
    cipher = ARC4.new(b&#x27;xhswebmplfbt&#x27;)
    ct = cipher.encrypt(raw).decode(&#x27;latin1&#x27;)
    # URL 编码后再手动拆百分号序列，目的是把非 ASCII 拆成单字节
    encoded = url_quote(ct, safe=&quot;!*&#x27;()~_-&quot;)
    b = []
    for c in encoded.split(&#x27;%&#x27;)[1:]:
        chars = list(c)
        b.append(int(&#x27;&#x27;.join(chars[:2]), 16))
        [b.append(ord(j)) for j in chars[2:]]
    return custom_base64(bytes(b), CUSTOM_BASE64_TABLE)
</code></pre>
<p>外层字典：</p><pre class="language-python lang-python"><code class="language-python lang-python">source = {
    &#x27;s0&#x27;: 5,                       # GET_PLAT_FROM_CODE（Windows 走 other 分支 = 5）
    &#x27;s1&#x27;: &#x27;&#x27;,
    &#x27;x0&#x27;: &#x27;1&#x27;,                     # localStorage.getItem(&quot;b1b1&quot;)，写死
    &#x27;x1&#x27;: LANGUAGE_VERSION,        # 4.3.5
    &#x27;x2&#x27;: &#x27;Windows&#x27;,
    &#x27;x3&#x27;: &#x27;xhs-pc-web&#x27;,
    &#x27;x4&#x27;: ARTIFACT_VERSION,        # 6.7.0
    &#x27;x5&#x27;: cookie_a1,
    &#x27;x6&#x27;: &#x27;&#x27;, &#x27;x7&#x27;: &#x27;&#x27;,            # 旧版是 XS / XT，现在写死
    &#x27;x8&#x27;: b1,
    &#x27;x9&#x27;: diy_mrc(&#x27;&#x27; + &#x27;&#x27; + b1),   # 自实现 CRC32，校验 b1
    &#x27;x10&#x27;: fp[&#x27;x39&#x27;],              # 调用计数器，写死也能过
    &#x27;x11&#x27;: &#x27;normal&#x27;,
}
return custom_base64(utf8_bytes(url_quote(json.dumps(source, separators=(&#x27;,&#x27;, &#x27;:&#x27;), ensure_ascii=False))),
                     CUSTOM_BASE64_TABLE)
</code></pre>
<p><code>diy_mrc</code> 是改版 CRC32：表按标准多项式 <code>0xedb88320</code> 生成，但最后一步会对累加值做 JS 风格的 int32 wraparound（<code>num &gt;= 2**31 ? num % 2**32 : num - 2**32</code>）。Python 里不做这个 wraparound 就对不上 JS 的有符号整型行为，服务端会把 <code>x9</code> 判成非法。</p><h3 id="">其他三个头</h3><p>剩下三个头相对朴素：</p><pre class="language-python lang-python"><code class="language-python lang-python">x_b3_traceid   = &#x27;&#x27;.join(random.choices(&#x27;abcdef0123456789&#x27;, 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) &lt;&lt; 23 | seq)[2:].zfill(16)
part2 = hex((random_u32() &lt;&lt; 32) | random_u32())[2:].zfill(16)
x_xray_traceid = part1 + part2
</code></pre>
<p>一个常见误区是&quot;签名反正会换字节码，不如复用别人维护的实现&quot;。实测下来只对一半：VM <strong>指令集</strong>不换，换的只是字节码里若干操作的常量（124 字节 XOR key、Base58/Base64 码表、<code>part11</code> 尾巴、<code>part1</code> magic、websectiga 里那几个 <code>d[92]/d[93]/675</code> 偏移）。一旦把静态实现写通了，下次服务端换 key，diff 一下新旧 <code>vendor-dynamic.xxx.js</code> 十几分钟就能定位到新常量替换掉；反倒是靠第三方实现时，它不更新你就得等。自己逆一遍最大的收获是知道&quot;<strong>哪些常量会变、哪些结构不变</strong>&quot;——结构这一层几乎从来不变。</p><h3 id="-context-">指纹 context 是动态的</h3><p>容易被忽视的细节是指纹<strong>不是一次性的</strong>。<code>x-s-common</code> 里的 <code>b1</code> 每次都拿当前 <code>fp</code> 重算，而 <code>fp</code> 会根据 <code>storage_state</code>（页面状态）和 <code>page_context</code>（当前 URL 和 referer）更新——比如调笔记详情要设成 <code>explore/{note_id}</code>，调首页推荐设成首页。服务端看到的&quot;你在哪个页面&quot;和请求本身对不上，风控直接拉闸。调 feed 之前要先把 <code>page_context</code> 切到 <code>explore/{note_id}</code> 再算签名，这一步忘了就 461 verifyType=301。</p><p><code>page_context</code> 根据 URL 里的路径映射成 <code>page_type</code>：</p><pre class="language-python lang-python"><code class="language-python lang-python">def normalize_page_context(ctx: dict | None) -&gt; dict:
    out = {
        &quot;location&quot;: &quot;https://www.xiaohongshu.com/explore&quot;,
        &quot;referer&quot;: &quot;https://www.xiaohongshu.com/&quot;,
        &quot;page_type&quot;: &quot;explore&quot;,
    }
    if ctx:
        out.update({k: v for k, v in ctx.items() if v is not None})
    path = urlparse(out[&quot;location&quot;]).path or &quot;/&quot;
    if &quot;/search_result&quot; in path:
        out[&quot;page_type&quot;] = &quot;search&quot;
    elif &quot;/user/profile&quot; in path:
        out[&quot;page_type&quot;] = &quot;user_profile&quot;
    elif &quot;/explore/&quot; in path or &quot;noteId=&quot; in out[&quot;location&quot;]:
        out[&quot;page_type&quot;] = &quot;note_detail&quot;
    else:
        out[&quot;page_type&quot;] = &quot;explore&quot;
    return out
</code></pre>
<p>指纹本身是 80+ 字段的字典 <code>fp = {&quot;x1&quot;: ua, &quot;x2&quot;: &quot;false&quot;, &quot;x3&quot;: &quot;zh-CN&quot;, &quot;x4&quot;: 24, ...}</code>——UA、语言、色深、设备内存、CPU、屏幕分辨率（<code>&quot;1920;1080&quot;</code>）、可用区域（<code>&quot;1920;1040&quot;</code>）、时区（<code>-480</code>，<code>&quot;Asia/Shanghai&quot;</code>）、GPU vendor/renderer、plugins、canvas 指纹（<code>x22</code>）、voice 哈希（<code>x53</code>）、WebGL 扩展哈希（<code>x56</code>）、cookie 拷贝（<code>x57</code>）、DOM 相关计数（<code>x58</code> div / <code>x59</code> resource / <code>x61</code> window.* 属性数 / <code>x73</code> DOM 节点数）、<code>x66</code> 里的 <code>{referer, location, frame}</code>、<code>x69</code> 的 <code>{prefix}|{window_props}|{script_count}</code> 字符串、以及一些写死的 magic 字段（<code>x30</code> <code>&quot;swf object not loaded&quot;</code>、<code>x45</code> <code>&quot;__SEC_CAV__1-1-1-1-1|__SEC_WSA__|&quot;</code> 等）。整个 dict 的作用就是描述一次&quot;浏览器在某个 URL 上某一刻的状态&quot;。</p><p>每次调 encrypted 接口前会<strong>重算 <code>fp</code> 里受当前请求影响的字段</strong>：</p><pre class="language-python lang-python"><code class="language-python lang-python"># 关键：受请求变化的字段
fp[&quot;x39&quot;] = str(storage[&quot;sc&quot;])                       # session counter，每请求 +1
fp[&quot;x44&quot;] = str(int(time.time()*1000))               # 当前毫秒
fp[&quot;x57&quot;] = &quot;; &quot;.join(f&quot;{k}={v}&quot; for k, v in cookies.items())  # 当前 cookie 快照
fp[&quot;x58&quot;] = str(div_count)                           # 当前 page_type 下的 DOM div 数
fp[&quot;x59&quot;] = str(resource_count)
fp[&quot;x61&quot;] = str(window_props)
fp[&quot;x66&quot;] = {&quot;referer&quot;: ctx[&quot;referer&quot;], &quot;location&quot;: ctx[&quot;location&quot;], &quot;frame&quot;: 0}
fp[&quot;x69&quot;] = f&quot;{prefix}|{window_props}|{script_count}&quot;
fp[&quot;x73&quot;] = str(dom_count)
</code></pre>
<p><code>x58</code>/<code>x59</code>/<code>x73</code> 在不同 <code>page_type</code> 上有不同的 baseline：<code>explore</code> 是 <code>(204, 14, 1240)</code>，<code>note_detail</code> 加 <code>(+36, +12, +420)</code>，<code>search</code> 加 <code>(+18, +8, +180)</code>，<code>user_profile</code> 加 <code>(+22, +10, +260)</code>。这些 baseline 是对着真实浏览器在各页面抓出来的值，微调一两个数不影响风控，但整个量级和 <code>page_type</code> 必须对得上。</p><h3 id="feed-">Feed 请求</h3><p><code>/api/sns/web/v1/feed</code> 的 POST body 很简单：</p><pre class="language-python lang-python"><code class="language-python lang-python">data = {
    &quot;source_note_id&quot;: note_id,
    &quot;image_formats&quot;: [&quot;jpg&quot;, &quot;webp&quot;, &quot;avif&quot;],
    &quot;extra&quot;: {&quot;need_body_topic&quot;: &quot;1&quot;},
    &quot;xsec_source&quot;: &quot;pc_feed&quot;,               # 从分享 URL 的 query 取
    &quot;xsec_token&quot;: xsec_token,
}
</code></pre>
<p>签名输入是 <code>(a1, loadts, &quot;/api/sns/web/v1/feed&quot;, None, data)</code>——把 <code>data</code> JSON 序列化（<code>separators=(&#x27;,&#x27;, &#x27;:&#x27;)</code>）直接拼到 URI 后面一起 MD5。返回体形如：</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;code&quot;: 0, &quot;success&quot;: true, &quot;msg&quot;: &quot;成功&quot;,
  &quot;data&quot;: {
    &quot;items&quot;: [{
      &quot;id&quot;: &quot;...&quot;, &quot;model_type&quot;: &quot;note&quot;,
      &quot;note_card&quot;: {
        &quot;note_id&quot;: &quot;...&quot;, &quot;type&quot;: &quot;video&quot;,
        &quot;title&quot;: &quot;...&quot;, &quot;desc&quot;: &quot;...&quot;,
        &quot;user&quot;: {&quot;user_id&quot;: &quot;...&quot;, &quot;nickname&quot;: &quot;...&quot;},
        &quot;interact_info&quot;: {&quot;liked_count&quot;: &quot;7.9万&quot;, &quot;comment_count&quot;: &quot;3419&quot;, ...},
        &quot;image_list&quot;: [{&quot;url_default&quot;: &quot;...&quot;, &quot;info_list&quot;: [...], &quot;live_photo&quot;: true}],
        &quot;video&quot;: {&quot;media&quot;: {&quot;stream&quot;: {&quot;h264&quot;: [{&quot;master_url&quot;: &quot;...&quot;, &quot;backup_urls&quot;: [...]}]}}},
        &quot;tag_list&quot;: [{&quot;id&quot;: &quot;...&quot;, &quot;name&quot;: &quot;...&quot;, &quot;type&quot;: &quot;topic&quot;}]
      }
    }]
  }
}
</code></pre>
<p>拿 <code>items[0].note_card</code> 下游处理。</p><h3 id="-session-">首发 session 质量与自动重试</h3><p>即便签名、cookie、bootstrap 都对，新建 session 首次打 feed 仍有概率返回外壳成功但 data 为空的响应（<code>success=True</code> 但 <code>items</code> 空）。这不是代码问题，是服务端对&quot;全新身份首次访问&quot;的概率性降权。同一套代码同一个 note，这次返回空、重建一次 session 再打就有数据。</p><p>识别标志：<code>success=True</code> 且 <code>items</code> 为空。处理方式是自动重试循环——删掉持久化的设备状态（<code>device_state.json</code>）、重新跑一遍 9 步 bootstrap 换一套身份、再打 feed。单次调用里最多试 3 轮。冷启动单次命中大约 70-90%，加 retry 能拉到 95%+：</p><pre class="language-python lang-python"><code class="language-python lang-python">MAX_ATTEMPTS = 3
note_card = {}
for attempt in range(1, MAX_ATTEMPTS + 1):
    if attempt &gt; 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(&quot;data&quot;) or {}).get(&quot;items&quot;) or []
        if items and (items[0].get(&quot;note_card&quot;) or {}):
            note_card = items[0][&quot;note_card&quot;]
            break                                      # 成功
    except NeedCaptchaVerify as e:
        # 216 语义验证码 / 102 滑块验证；捕获后让下一轮重置身份重试
        last_err = f&quot;attempt={attempt} verify={e.details.get(&#x27;verifyType&#x27;)}&quot;
    finally:
        await session.close_session()
</code></pre>
<p>返回结构里还带两个可观测字段 <code>feed_attempts</code>（试了几次）和 <code>feed_recovered_on_attempt</code>（成功那次是第几轮），一眼能看出是不是靠重试救回来的——不走 feed 的路线恒为 <code>1/1</code>，只有 API 路线会动。</p><h3 id="">实测</h3><p><strong>图片笔记</strong>：</p><pre class=""><code class="">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   ← 本次首发抖动，第二轮捞回
</code></pre>
<p><strong>视频笔记</strong>：</p><pre class=""><code class="">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
</code></pre>
<p><strong>实况笔记</strong>：</p><pre class=""><code class="">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
</code></pre>
<p>路线三的字段粒度最细：视频多条备用 URL、实况 motion 流结构规整、统计数是可读的 <code>7.9万</code>。代价是每次要跑完整 9 步 bootstrap 再在本地算一次 JS 签名、签名里的常量随服务端版本可能要跟更新、偶发需要重试。</p><h2 id="">三条路线合到一起</h2><p>实际跑起来，三条路线是按数据质量排序叠一起的：</p><pre class=""><code class="">step 1  /v1/feed + 自算 x-s / x-s-common        → source=feed_api         最干净
step 2  PC 会话打开分享页，读 __INITIAL_STATE__  → source=initial_state    完整笔记
step 3  读 &lt;meta og:*&gt; 标签                       → source=meta_fallback    只剩标题和封面
</code></pre>
<p>每次输出带一个 <code>extract_source</code> 字段，一眼就能看出这次数据是哪一档。</p><p>路线一是另一个入口——只要图、顺手拿点元数据的场景走它，不建 PC session，整体开销是完整路径的十分之一。把三条测试链接在三条路线上的结果拉到一起看，<strong>三条路线在&quot;能拿什么数据&quot;上其实几乎等价，真正的区别是建立连接的成本</strong>：</p><table><thead><tr><th> 路线 </th><th> session 成本 </th><th> 签名成本 </th><th> 数据鲜度 </th><th> 评论内容 </th><th> 图片水印 </th></tr></thead><tbody><tr><td> 移动端 UA + CDN 替换 </td><td> 无 </td><td> 无 </td><td> SSR 快照 </td><td> 拿不到 </td><td> <code>!h5_1080jpg</code> 带（换域名解） </td></tr><tr><td> PC 会话 + <code>__INITIAL_STATE__</code> </td><td> 9 步 bootstrap </td><td> 无 </td><td> 实时 </td><td> 需另打接口 </td><td> 默认不带 </td></tr><tr><td> PC 会话 + <code>/v1/feed</code> API </td><td> 9 步 bootstrap + 指纹 </td><td> 自算 x-s / x-s-common </td><td> 实时 </td><td> 一并返回 </td><td> 默认不带 </td></tr></tbody></table><p>统一输出结构（三条路线都归到这一个 shape 方便下游）：</p><pre class="language-python lang-python"><code class="language-python lang-python">{
    &quot;source&quot;: &quot;mobile | html | api&quot;,
    &quot;extract_source&quot;: &quot;feed_api | initial_state | meta_fallback&quot;,
    &quot;original_url&quot;: &quot;...&quot;,
    &quot;final_url&quot;: &quot;...&quot;,
    &quot;note_id&quot;: &quot;...&quot;,
    &quot;xsec_token&quot;: &quot;...&quot;,
    &quot;title&quot;: &quot;...&quot;, &quot;content&quot;: &quot;...&quot;,
    &quot;content_type&quot;: &quot;image | video | live_photo&quot;,
    &quot;author&quot;: {&quot;name&quot;: &quot;...&quot;, &quot;user_id&quot;: &quot;...&quot;},
    &quot;stats&quot;: {&quot;liked&quot;: &quot;...&quot;, &quot;comment&quot;: &quot;...&quot;, &quot;collect&quot;: &quot;...&quot;, &quot;share&quot;: &quot;...&quot;},
    &quot;topics&quot;: [&quot;...&quot;],
    &quot;publish_time_ms&quot;: 0, &quot;publish_time_iso&quot;: &quot;...&quot;,
    &quot;images&quot;: [{&quot;index&quot;, &quot;id&quot;, &quot;url&quot;, &quot;raw_url&quot;, &quot;live_photo&quot;}],
    &quot;videos&quot;: [&quot;...&quot;],
    &quot;live_photos&quot;: [{&quot;index&quot;, &quot;image_url&quot;, &quot;video_url&quot;}],
    &quot;feed_attempts&quot;: 1,
    &quot;feed_recovered_on_attempt&quot;: 1,
}
</code></pre>
<h2 id="">图片变体谱系</h2><p>三条路线的实测下载结果放一起看，虽然 image_id 完全一致，CDN 给的&quot;默认变体&quot;差别不小：</p><table><thead><tr><th> 来源 </th><th> URL 后缀 </th><th> 大小 </th><th> 水印 </th></tr></thead><tbody><tr><td> 移动端 HTML（<code>!h5_1080jpg</code>） </td><td> 缩放到 1080，jpg </td><td> ~77 KB </td><td> <strong>带</strong> </td></tr><tr><td> 桌面 HTML <code>__INITIAL_STATE__</code>（<code>!nd_dft_wlteh_jpg_3</code>） </td><td> 默认处理，jpg </td><td> ~147 KB </td><td> 不带 </td></tr><tr><td> API <code>/v1/feed</code>（<code>!nd_dft_wlteh_webp_3</code>） </td><td> 默认处理，webp </td><td> ~42 KB </td><td> 不带 </td></tr><tr><td> 换 <code>sns-img-qc.xhscdn.com/</code> </td><td> 无后缀、无签名 </td><td> ~147 KB </td><td> 不带 </td></tr></tbody></table><p>几条值得记下来的规律：</p><ul><li><strong>水印是特定后缀变体 <code>!h5_1080jpg</code> 的特性，不是 <code>sns-webpic-qc</code> 这个域名的特性</strong>。同一域名下的 <code>!nd_dft_wlteh_*</code> 变体默认不加水印。</li><li>桌面 HTML 给的 jpg 变体跟裸原图几乎同大小（147KB vs 147KB），说明那条变体是近无损的；换域名在这条路线上<strong>不是必须</strong>。</li><li>API 给的 webp 变体才是真的小（42KB），体积优化占了主导。要原图得换域名。</li><li>三条路线的 <code>image_id</code> 完全一致。所以只要知道任意一条路线给的 URL，都能通过&quot;跳过 date 和 sign、保留 <code>image_id</code>、换域名&quot;拿到任意其他变体（包括无水印原图）。</li></ul><h2 id="">排障顺序：链路坏了从哪一层查起</h2><p>链路挂了的时候，最怕的是从&quot;最后一步&quot;往回猜。正确的方向是从最前面的、最便宜的检查往后推，每一层都有具体的信号：</p><p><strong>第 1 层：入口参数</strong></p><p>先确认 <code>note_id</code> 和 <code>xsec_token</code> 解析对了——特别是 <code>xsec_token</code> 尾部的 <code>=</code> 有没有被吃掉。排查方法：打印 resolve 完的 <code>note_id</code> 和 <code>xsec_token</code>，用它们直接 <code>quote</code> 之后手工拼一个 <code>explore/{note_id}?xsec_token=...</code> URL 在浏览器里打开。能看到笔记就说明入口参数没问题。</p><p><strong>第 2 层：版本号</strong></p><p>打到这一层的典型症状是 <code>shield/webprofile</code> 返回 <code>471 verifyType=290</code>（<code>&quot;当前版本过低&quot;</code>）。排查：看当前 <code>ARTIFACT_VERSION</code> / <code>LANGUAGE_VERSION</code> 跟从线上 <code>vendor-dynamic.xxx.js</code> 抽出来的是不是一样；不一样就手动同步一次再重跑。</p><p><strong>第 3 层：Bootstrap</strong></p><p>9 步任一步挂了，后面全是连锁反应。排查：每一步都 print 出状态码和关键 cookie 是否写入（<code>websectiga</code> / <code>sec_poison_id</code> / <code>gid</code> / <code>acw_tc</code> / <code>web_session</code>）。典型病灶：</p><ul><li>6、7 两步 <code>scripting</code> 的 type 参数错了 → <code>websectiga</code> 解不出来</li><li><code>profileData</code> 里的 fingerprint 被 JSON encoder 转义了 non-ASCII → webprofile 401 / 461</li><li>activate 之前 cookie jar 不干净 → 拿不到 web_session，或拿到的是错误前缀</li></ul><p><strong>第 4 层：签名（路线三专属）</strong></p><p>feed 返回 461 时看 header 里的 <code>Verifytype</code>：</p><table><thead><tr><th> 现象 </th><th> 阶段 </th><th> 含义 </th><th> 对策 </th></tr></thead><tbody><tr><td> <code>461 verifyType=216</code> </td><td> <code>/v1/feed</code> </td><td> 签名无效或 JSVMP 字节码换了 </td><td> 对齐新常量 / 重新解字节码 </td></tr><tr><td> <code>461 verifyType=301</code> </td><td> <code>/v1/feed</code> </td><td> session 质量不够或 <code>page_context</code> 不匹配 </td><td> 重置设备状态，切对 page_type </td></tr><tr><td> <code>461 verifyType=102</code> </td><td> <code>/v1/feed</code> </td><td> 触发滑块验证 </td><td> 人工过或挂验证码服务 </td></tr><tr><td> <code>461 verifyType=216</code> + HTML </td><td> 分享页 HTML </td><td> 语义验证码（选图点字） </td><td> 解析 theme/grid，调专门分支 </td></tr><tr><td> <code>code=0 success=true data={}</code> </td><td> <code>/v1/feed</code> </td><td> 软风控：外壳成功但 data 被抽空 </td><td> 先查 <code>xsec_token</code> 末尾 <code>=</code> 是否被截断；否则重置设备状态重试 </td></tr><tr><td> <code>SSLError / UNEXPECTED_EOF</code> </td><td> 任意 </td><td> TLS 指纹被识别或网络抖动 </td><td> 重试，换代理 </td></tr><tr><td> 被重定向到 <code>/login</code> </td><td> HTML 分享页 </td><td> session 过期或游客态被收回 </td><td> 清设备状态，重新激活 </td></tr></tbody></table><p>特别要警惕 <code>code=0 data={}</code>。它长得一切正常，实际上什么都没返回，很容易骗过粗糙的成功判断。</p><p><strong>第 5 层：HTML 强提取（路线二）</strong></p><p>如果 feed 持续挂、但 HTML 分享页能打开，退到路线二检查 <code>__INITIAL_STATE__</code> 是否能抠出来。典型病灶是前面讲的&quot;等号两边空格消失&quot;的 marker regression——症状是 <code>extract_source</code> 一直落到 <code>meta_fallback</code>，标题正文还在但作者/图片/时间全空。这不是站点砍能力，是本地解析器回归。</p><p><strong>第 6 层：移动端兜底（路线一）</strong></p><p>PC 两条都波动时用移动端路线当最后的验证：如果这条也出不来数据，大概率是笔记本身被删或者风控直接拉黑了这个出口 IP；如果这条能出基础字段，说明内容还活着，只是 PC 这边状态不干净，重置 session 重来。</p><h2 id="">已证实、推断、不要再神化</h2><p>做下来攒了不少经验，区分一下这三类免得后人走回头路。</p><p><strong>已证实</strong>：</p><ul><li><code>xsec_token</code> 尾部 <code>=</code> 被截断会导致 <code>/v1/feed</code> 出现 <code>code=0 success=true data={}</code></li><li><code>window.__INITIAL_STATE__</code> marker 写死会导致 HTML 完整提取整条退化到 <code>meta_fallback</code></li><li><code>/v1/feed</code> 当前存在首发失败、二次重建 session 后恢复的真实波动</li><li><code>web_session</code> 是服务端签发的，本地无论怎么拼都换不到&quot;有效但服务端没见过&quot;的值</li></ul><p><strong>高可信推断</strong>：</p><ul><li>session 质量（cookie 完整度 + 指纹一致性 + page_context 匹配）会显著影响 Route C 首发成功率</li><li>持久化的&quot;稳定设备态&quot;比完全随机新设备态更像真实浏览器，通过率更高</li><li>JSVMP VM 指令集长期稳定，换的主要是字节码里的常量——意味着自己逆一遍后的维护成本其实可接受</li></ul><p><strong>不要再神化</strong>：</p><ul><li>&quot;<code>web_session</code> 是 03 前缀就一定不行、04 前缀就一定可以&quot; —— 不成立。03 对公开数据足够用，04 只在关注流 / 私信这些接口上才必要。</li><li>&quot;只要补上某个 header 就一定能过 feed&quot; —— 不成立。feed 的成败是 session 质量、cookie 组合、指纹 context、时机波动综合结果。</li><li>&quot;路线一只能拿图&quot; —— 不成立。移动端 HTML 里实际散落着大部分字段，散点 regex 就能扫出来；评论正文确实拿不到，但别的都能要。</li><li>&quot;水印是 <code>sns-webpic-qc</code> 域名的特性&quot; —— 不成立。水印是 <code>!h5_1080jpg</code> 这个处理后缀的特性，同域名的 <code>!nd_dft_wlteh_*</code> 变体默认不带水印。</li></ul><h2 id="">小红书的反爬长什么样</h2><p>把三条路线叠起来看，大致能画出小红书的防护层级：</p><table><thead><tr><th> 层级 </th><th> 机制 </th><th> 说明 </th></tr></thead><tbody><tr><td> 1 </td><td> xsec_token </td><td> URL 级别的防盗链令牌，绑定会话，别被末尾的 <code>=</code> 坑到 </td></tr><tr><td> 2 </td><td> JS Cookie 生成 </td><td> a1/webId/websectiga/gid 等由 JS 运行时生成 </td></tr><tr><td> 3 </td><td> 浏览器指纹 </td><td> 80+ 字段的设备指纹，DES 加密上报 </td></tr><tr><td> 4 </td><td> 请求签名 x-s </td><td> JSVMP 虚拟机执行，定期更换字节码常量 </td></tr><tr><td> 5 </td><td> TLS 指纹 + UA/client hints </td><td> UA 和 sec-ch-ua 不一致直接识别 </td></tr><tr><td> 6 </td><td> 版本校验 </td><td> <code>ARTIFACT_VERSION</code> 过期直接 471/290 </td></tr><tr><td> 7 </td><td> page_context 一致性 </td><td> 请求 URL 和 referer/location 对不上触发 301 </td></tr><tr><td> 8 </td><td> 语义验证码 </td><td> 216 型，要识别图片里的主题和网格 </td></tr><tr><td> 9 </td><td> 行为分析 </td><td> 频率、轨迹、时序等综合风控 </td></tr></tbody></table><p>第 4 层是硬骨头，但前面 8 层加起来其实已经卡掉了 99% 的脚本，大部分人连到第 9 层的门都没摸到。JSVMP 把签名算法编译成自定义字节码，VM 指令集基本稳定、换的是字节码里的常量（XOR key、码表、magic 字节等）——所以一次逆通之后维护只是 diff 新旧 <code>vendor-dynamic.js</code> 抓常量的事。真正每天要跟服务端博弈的反而是 session 质量：cookie 下发、版本号同步、指纹一致性，这些才是每次请求都在变的。</p><h2 id="">免责声明</h2><p>本文仅记录个人在技术学习过程中的探索和思考，用于安全研究和教育目的。文中涉及的所有技术分析均针对公开可访问的页面和网络请求，未涉及任何非授权访问、数据批量抓取或商业用途。请遵守相关平台的服务条款和当地法律法规，不要将文中内容用于侵犯他人合法权益的场景。</p><h2 id="">后言</h2><p>三条路线从&quot;裸 HTTP&quot;到&quot;完整 Web 会话加官方 API&quot;跨度挺大，但真正花时间的从来不是签名算法本身，而是 session 的质量——UA 和 client hints 要不要对齐、bootstrap 能不能跑完、<code>web_session</code> 拿到的是 03 还是 04、<code>page_context</code> 对不对应当前请求。签名算法一次写完就能用很久，session 质量是每次请求都要跟服务端博弈的日常工作。</p><p>另一个观察是，最贵的那条路（路线三）和最便宜的那条路（路线一）在&quot;能拿什么数据&quot;上差别并不大——两者返回的字段差异主要在评论正文、视频多备用 URL、可读格式统计数这三块。分层 fallback 把它们叠在一起，把昂贵路径的代价摊薄，同时用 <code>extract_source</code> 字段显式标记每次走到了哪一档，出问题不用手动猜。</p><p><code>sns-webpic-qc</code> 和 <code>sns-img-qc</code> 这对 CDN 域名大概是整个系统里最适合作为收尾的例子。前者走签名路径，按 URL 后缀决定出图规格——加不加水印、分辨率、编码格式都藏在后缀里；后者按裸 <code>image_id</code> 直接给原图，不签名，也没有后缀概念。两个域名共用同一套 <code>image_id</code> 命名空间——知道了一张图在 webpic-qc 上的 id，在 img-qc 拿原图是免费的。正是这点让移动端分享页里那个 <code>!h5_1080jpg</code> 的水印版本可以被轻松绕过：不需要知道它怎么算签名，只要保留 image_id 然后换域名。这扇门未来可能会关上。但至少今天，它还开着。</p><h2 id="">附录：最小复现清单</h2><p>按这个顺序把能力补齐就能把三条路线全跑通，对着打勾排查：</p><p><strong>入口层</strong></p><ul><li><input readOnly="" type="checkbox"/> 跟随 <code>xhslink.com/o/...</code> 302，拿到最终 URL</li><li><input readOnly="" type="checkbox"/> 从最终 URL 抽 <code>note_id</code> / <code>xsec_token</code> / <code>xsec_source</code>，用 <code>parse_qs</code> 保留 <code>=</code> padding</li></ul><p><strong>路线一（零 session）</strong></p><ul><li><input readOnly="" type="checkbox"/> iPhone UA 请求分享页，跟随重定向</li><li><input readOnly="" type="checkbox"/> 散点 regex 抽标题/作者/正文/stats/topics/时间，带&quot;小红书&quot;占位过滤</li><li><input readOnly="" type="checkbox"/> <code>onix-carousel-item</code> 抓图片，<code>_259.mp4</code> 过滤水印视频</li><li><input readOnly="" type="checkbox"/> <code>cdn_strip_watermark</code> 换 <code>sns-img-qc</code> 拿原图</li><li><input readOnly="" type="checkbox"/> content<em>type 三分支判定（video / `live</em>photo` / image）</li></ul><p><strong>Cookie 生成（路线二、三共用）</strong></p><ul><li><input readOnly="" type="checkbox"/> <code>a1</code> = <code>hex(ts) + rand30 + &quot;50000&quot; + crc32[:52]</code>，<code>webId</code> = MD5(a1)</li><li><input readOnly="" type="checkbox"/> 版本同步：从线上 <code>vendor-dynamic.xxx.js</code> 抽 <code>artifactVersion</code> / <code>languageVersion</code></li><li><input readOnly="" type="checkbox"/> 9 步 bootstrap 按顺序跑通，每步检查对应 cookie 写入</li><li><input readOnly="" type="checkbox"/> <code>websectiga</code> 的 base64 + 5 字符分组 + 二次查表解码</li><li><input readOnly="" type="checkbox"/> <code>gid</code> 的 JSON → base64 → DES-ECB(zbp30y86) → hex</li><li><input readOnly="" type="checkbox"/> <code>web_session</code> 从 activate 响应拿，区分 03 / 04</li></ul><p><strong>路线二（HTML）</strong></p><ul><li><input readOnly="" type="checkbox"/> 两个候选 URL（discovery/item + explore），带 document 级 headers</li><li><input readOnly="" type="checkbox"/> <code>extract_assigned_json_object</code>：正则 marker + brace-balance + 字符串转义</li><li><input readOnly="" type="checkbox"/> <code>sanitize_initial_state</code>：两轮 <code>undefined</code> → <code>null</code></li><li><input readOnly="" type="checkbox"/> <code>choose_note_payload</code>：按 note_id 查，兜底找第一个非 <code>undefined</code> key</li></ul><p><strong>路线三（API）</strong></p><ul><li><input readOnly="" type="checkbox"/> <code>x-s</code>：11 段字节 → XOR<em>KEY</em>124 → Base58(custom) → <code>mns0101_</code> + 外层 Base64(custom) + <code>XYS_</code></li><li><input readOnly="" type="checkbox"/> <code>x-s-common</code>：ARC4(xhswebmplfbt) 加密 18 字段指纹 → <code>b1</code>，外层 dict + diy_mrc + Base64</li><li><input readOnly="" type="checkbox"/> <code>x-b3-traceid</code> / <code>x-xray-traceid</code> / <code>x-t</code></li><li><input readOnly="" type="checkbox"/> 每次签名前更新 <code>loadts</code> 和 <code>fp</code> 的动态字段（x39/x44/x57/x58/x59/x61/x66/x69/x73）</li><li><input readOnly="" type="checkbox"/> <code>page_context</code> 根据 URL 切到 <code>note_detail</code></li><li><input readOnly="" type="checkbox"/> feed POST body 固定 shape（<code>source_note_id</code> / <code>image_formats</code> / <code>extra</code> / <code>xsec_source</code> / <code>xsec_token</code>）</li><li><input readOnly="" type="checkbox"/> 3 轮 auto-retry，每轮失败删 <code>device_state.json</code> + 清 init cookie 缓存 + 重建 session</li></ul><p><strong>统一输出</strong></p><ul><li><input readOnly="" type="checkbox"/> 三条路线归到同一套 dict shape，带 <code>source</code> / <code>extract_source</code> / <code>feed_attempts</code> / <code>feed_recovered_on_attempt</code></li><li><input readOnly="" type="checkbox"/> 实测三种内容类型（image / video / live_photo）都能出完整字段</li></ul></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/cat-catches-mouse#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/cat-catches-mouse</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/cat-catches-mouse</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Mon, 06 Apr 2026 19:41:16 GMT</pubDate></item><item><title><![CDATA[DST 皮肤模组的简单分析]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development">https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</a></blockquote><div><h2 id="-dst-">我以为这只是一个全皮肤资源包，结果它几乎重做了一套 DST 皮肤系统</h2><p>最近翻到一个已经停止维护、并被公开源码的《饥荒联机版》全皮肤 <code>mod</code>。最开始我以为这类项目的实现方式都差不多：把官方皮肤资源打包进模组目录，改几个表，最后在游戏里“显示出来”就算完事。</p><p>但把这个仓库真正读下来之后，我的判断完全变了。</p><p>它根本不是一个普通的资源包。更准确地说，它是一套运行时皮肤注入系统。它不是单纯把官方皮肤搬进来，而是尽量复刻了《饥荒联机版》原本的皮肤工作方式，然后在这套体系上额外加了一层 <code>custom_</code> 命名空间和一批运行时 <code>Hook</code>，让本地皮肤看起来像官方皮肤一样参与名称查询、图标查询、所有权判断、实体生成和换肤流程。</p><hr/><h2 id="">它真正做的，不是“加皮肤”，而是“接管皮肤系统”</h2><p>这个项目最值得注意的一点，是它并没有绕开官方皮肤体系，自己硬搓一套完全独立的逻辑。相反，它尽量贴着官方的结构走，只在必要的地方插入一层自己的运行时改写。</p><p>比如在 <a href="/E:/Desktop/skin/modmain.lua"><code>modmain.lua</code></a> 里，模组首先定义了 <code>custom_</code> 前缀的判断和剥离逻辑。这里的关键不只是“给名字加个前缀”，而是建立了一层稳定的映射关系：</p><ul><li>官方皮肤 <code>wilson_formal</code></li><li>模组皮肤 <code>custom_wilson_formal</code></li></ul><p>一旦这层映射建立起来，后面的事情就都顺了。模组可以在运行时把 <code>custom_wilson_formal</code> 识别成自己的皮肤，又可以在需要调用官方数据的地方，把它重新还原成 <code>wilson_formal</code> 去查名称、描述、图标和稀有度。</p><p>也就是说，<code>custom_</code> 不是一个装饰性前缀，而是整个系统的入口。</p><hr/><h2 id="">它没有复制官方文本，而是偷走了官方查询能力</h2><p>真正让我觉得这个项目“有点意思”的，是 <a href="/E:/Desktop/skin/skinloader/skinloader.lua"><code>skinloader/skinloader.lua</code></a> 里的那组代理逻辑。</p><p>这个文件没有去维护一份巨大的本地化文本表，也没有把官方图标名、稀有度字符串整套复制出来。它做的事更直接：拦截查询函数。</p><p>它会把：</p><ul><li><code>GetSkinDescription</code></li><li><code>GetSkinName</code></li><li><code>GetSkinInvIconName</code></li><li><code>GetModifiedRarityStringForItem</code></li><li><code>GetColorForItem</code></li></ul><p>这类全局函数统一包一层代理。如果传入的项目带 <code>custom_</code> 前缀，就先把前缀剥掉，再把请求交还给官方函数。</p><p>结果就是，模组不需要自己维护一大坨显示层数据。游戏在查询 <code>custom_wilson_formal</code> 时，最后拿到的仍然是官方 <code>wilson_formal</code> 的名字、描述、图标和稀有度逻辑。</p><p>从工程实现上说，这是非常典型也非常聪明的做法：不去复制结果，而是复用官方的“生成结果的能力”。</p><hr/><h2 id="">“全皮肤解锁”最核心的地方，其实是所有权伪造</h2><p>很多人看到这类项目，第一反应会是“是不是把皮肤列表显示出来了”。但这个理解只对了一半。</p><p>真正让它成立的地方，不在 UI，而在库存代理层。</p><p>在 <a href="/E:/Desktop/skin/skinloader/skinloader.lua"><code>skinloader/skinloader.lua</code></a> 里，这个模组重写了和库存所有权相关的多组接口，比如：</p><ul><li><code>InventoryProxy.CheckOwnership</code></li><li><code>InventoryProxy.CheckOwnershipGetLatest</code></li><li><code>InventoryProxy.GetOwnedItemCount</code></li><li><code>InventoryProxy.GetFullInventory</code></li></ul><p>它的核心思路很简单：只要某个皮肤被注册进模组自己的 <code>SKINS</code> 表，就让官方逻辑把它当成“已拥有”。</p><p>这一步非常关键。因为 DST 的皮肤系统不是“资源在本地就能直接穿上”，它中间还有一层所有权检查。这个模组真正绕过去的，就是这道门。</p><p>所以说，这类项目不是“把皮肤文件放进来就能用”，而是要同时解决三件事：</p><ul><li>皮肤数据能不能注册进去</li><li>皮肤所有权能不能骗过去</li><li>皮肤资源最终能不能正确渲染出来</li></ul><p>这三者缺一个都不行。</p><hr/><h2 id="">它连生成和换肤流程都一起接管了</h2><p>如果事情只做到“已拥有”，玩家最多只是能在某些界面里看到皮肤项，真正套到实体上时依然可能失败。</p><p>这个项目没有停在这里。它继续在运行时接管了几段更底层的皮肤流程：</p><ul><li><code>CreatePrefabSkin(...)</code></li><li><code>SpawnPrefab(...)</code></li><li><code>Sim:ReskinEntity(...)</code></li><li><code>AnimState:GetSkinBuild(...)</code></li></ul><p>这几个点串起来，才构成了“皮肤从定义到显示”的整条链路。</p><p>它的意思其实很直接：模组不是把资源丢在目录里，等引擎自己偶然发现；而是在实体生成、换肤和动画构建名解析这些关键节点上，明确告诉游戏“现在该用的是这个 <code>custom_</code> 皮肤”。</p><p>这也是为什么我会觉得它更像一套皮肤系统注入器，而不是一个素材集合。</p><hr/><h2 id="">这个仓库最容易看乱的地方，是你没有先分清三层</h2><p>如果直接在这个仓库里硬搜 <code>CreatePrefabSkin(...)</code>，很容易看得头大。因为同一个官方皮肤，往往会同时出现在几套不同文件里。</p><p>我后来发现，理解这个项目最有效的方法，是强行把它拆成三层去看。</p><p>第一层是“镜像层”，主要是：</p><ul><li><a href="/E:/Desktop/skin/scripts/prefabskins.lua"><code>scripts/prefabskins.lua</code></a></li><li><a href="/E:/Desktop/skin/scripts/prefabs/skinprefabs.lua"><code>scripts/prefabs/skinprefabs.lua</code></a></li></ul><p>这层的任务，是尽量保持和官方皮肤结构一致。这里通常还是官方命名，不加 <code>custom_</code>，更像是“官方结构的本地镜像”。</p><p>第二层是“激活层”，也就是：</p><ul><li><a href="/E:/Desktop/skin/scripts/prefabs/kleiskinprefabs.lua"><code>scripts/prefabs/kleiskinprefabs.lua</code></a></li></ul><p>这一层才是模组真正运行时要用的内容。这里的条目大多会变成 <code>custom_&lt;official_id&gt;</code>，并补上 <code>assets</code>、<code>build_name_override</code>、<code>init_fn</code> 这些运行时所需字段。当前仓库里，这一层大量条目还共用一个 <code>groupid = 0825</code>。</p><p>第三层是“资源层”，也就是：</p><ul><li><code>anim/dynamic/*.zip</code></li><li><code>anim/dynamic/*.dyn</code></li></ul><p>这层看上去最直观，但其实最不应该先看。因为资源层只是结果，不是逻辑中心。真正决定这些资源什么时候被加载、用什么名字匹配、是否能显示出来的，还是前面那套 Lua 注入逻辑。</p><p>只要这三层没有分开，后面几乎所有判断都会混乱。</p><hr/><h2 id="">这类项目最危险的误区，就是“我懂后缀规则了”</h2><p>读这类皮肤仓库时，很容易形成一种错觉：只要我记住 <code>_d</code>、<code>_p</code>、<code>_none</code> 各自是什么意思，后面就可以机械套模板了。</p><p>但这个仓库越往下看，我越觉得这是最容易犯错的思路。</p><p>通常情况下：</p><ul><li><code>_d</code> 常常代表有独立资源的变体</li><li><code>_p</code> 常常通过 <code>build_name_override</code> 去复用 <code>_d</code></li><li><code>_none</code> 往往是无皮肤占位条目</li></ul><p>问题在于，这些都只是经验规律，不是绝对规则。</p><p>真正能决定一个新皮肤该怎么接入的，从来不是后缀本身，而是官方原始定义里到底写了什么：</p><ul><li>它有没有 <code>assets</code></li><li>它是否复用别的 <code>build</code></li><li>它有没有 <code>init_fn</code></li><li>它的 <code>ghost_skin</code> 到底是官方命名还是 <code>custom_ghost_*</code></li><li>它是否还带 <code>powerup</code>、<code>stage2</code>、<code>stage3</code> 之类的变体链</li></ul><p>如果跳过这一步，只凭“看起来像 <code>_p</code>”就开始改文件，最后十有八九会把项目搞坏。</p><hr/><h2 id="-buildbin">这个项目里最容易被低估的文件，其实是 <code>build.bin</code></h2><p>如果只看文件名，很多人会以为资源接入的工作量不大：把官方 <code>.dyn</code> 和 <code>.zip</code> 拷过来，改成 <code>custom_xxx</code> 不就完了吗？</p><p>但真正的坑就在这里。</p><p><code>.dyn</code> 相对简单。这个仓库当前的结论是：<code>.dyn</code> 保存的是动画数据，本身不嵌入 <code>build</code> 名，所以很多情况下直接复制并改名就够了。</p><p>真正麻烦的是 <code>.zip</code>。</p><p>因为 <code>.zip</code> 里的 <code>build.bin</code> 会写入内部 <code>build</code> 名。如果模组运行时要找的是 <code>custom_backpack_invisible</code>，但你拷来的资源包内部还写着 <code>backpack_invisible</code>，那引擎在匹配时就可能直接对不上。表现出来就是最经典的那种问题：</p><ul><li>皮肤隐身</li><li>模型不显示</li><li>贴图丢失</li></ul><p>也就是说，很多时候“文件名改对了”根本不够，<code>build.bin</code> 里的内部名字也必须跟着一起改。</p><p>这一点恰恰是很多外行最容易忽略、但实际决定成败的地方。</p><hr/><h2 id="-backpackinvisible-">为什么 <code>backpack_invisible</code> 这个补档案例很有代表性</h2><p>这个仓库最近一次比较典型的补档，就是 <code>backpack_invisible</code>。它之所以值得单独提出来，不是因为这个皮肤本身有多特殊，而是因为它把整个维护流程都暴露得很完整。</p><p>为了补这个皮肤，仓库里做了几件彼此关联的事：</p><ol start="1"><li>在 <a href="/E:/Desktop/skin/scripts/prefabskins.lua"><code>scripts/prefabskins.lua</code></a> 里把它加进 <code>backpack</code> 分类。</li><li>在 <a href="/E:/Desktop/skin/scripts/prefabs/skinprefabs.lua"><code>scripts/prefabs/skinprefabs.lua</code></a> 里补上镜像定义。</li><li>在 <a href="/E:/Desktop/skin/scripts/prefabs/kleiskinprefabs.lua"><code>scripts/prefabs/kleiskinprefabs.lua</code></a> 里加入 <code>custom_backpack_invisible</code> 激活条目。</li><li>复制官方 <code>.dyn</code> 到 <code>anim/dynamic/custom_backpack_invisible.dyn</code>。</li><li>从官方的 <code>anim_dynamic.zip</code> 提取对应 <code>.zip</code>，再去 patch <code>build.bin</code> 的内部名称，最后保存成 <code>anim/dynamic/custom_backpack_invisible.zip</code>。</li></ol><p>这个例子很好地说明了三件事：</p><ul><li>更新不是只改 Lua，也不是只拷资源，而是数据层和资源层都要一起动。</li><li>官方资源并不一定都在松散目录里，有些要去 <code>databundle</code> 里找。</li><li>真正决定能否显示的关键之一，是运行时 <code>build</code> 名和资源包内部 <code>build</code> 名必须一致。</li></ul><p>如果把这个案例理解透了，这个项目后续大部分皮肤更新其实都只是同类问题的不同变体。</p><hr/><h2 id="">这不是一份“怎么免费用皮肤”的答案，而是一份很典型的工程样本</h2><p>我最后对这个项目的评价，其实和最开始完全不一样。</p><p>一开始我把它当成一个“全皮肤 mod 源码包”；读完之后，我更愿意把它看作一个非常典型的 DST 皮肤系统工程样本。它最值得研究的，不是“怎么绕过官方所有权”，而是它在绕过这件事的同时，尽量没有破坏官方皮肤系统原本的语义。</p><p>它没有简单粗暴地把每个皮肤写成一套独立逻辑，而是尽量去复用官方已经存在的东西：</p><ul><li>名称和描述查询</li><li>图标查询</li><li>稀有度逻辑</li><li>prefab skin 数据结构</li><li>资源复用关系</li></ul><p>真正额外增加的，只是它自己必须控制的那一层：<code>custom_</code> 命名、所有权伪造和运行时注入。</p><p>所以，这个仓库最难的地方，并不是会不会写 Lua，而是能不能在改动时保持足够克制。你得始终知道哪些东西应该跟官方保持一致，哪些地方才是这个项目真正需要自己接管的。</p><p>如果以后 DST 再更新新皮肤，那么最稳妥的路线仍然不会变：先确认官方新增了什么，再读原始定义，接着分别处理镜像层、激活层和资源层，最后再去验证 <code>build</code> 名、<code>ghost</code> 链和各种变体链有没有断。</p><p>从这个角度看，这个项目真正有价值的地方，恰恰不是它“做成了什么效果”，而是它展示了一个皮肤类模组在工程上可以做到多接近官方系统本身。</p></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/dst-skin-system-reverse-engineering-mod-development</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Thu, 02 Apr 2026 14:38:08 GMT</pubDate></item><item><title><![CDATA[当故事结束时，人们总会想起它的开始]]></title><description><![CDATA[<link rel="preload" as="image" href="https://img.gufei.life/mx-images/u0a3ah8aea1zrb2s9n.jpg"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/81">https://blog.ovoii.io/notes/81</a></blockquote><img alt="人最擅长记录痛苦，因为幸福的时候不自知" height="1153" src="https://img.gufei.life/mx-images/u0a3ah8aea1zrb2s9n.jpg" width="1170"/><p style="text-align:right"><a href="https://blog.ovoii.io/notes/81#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/81</link><guid isPermaLink="true">https://blog.ovoii.io/notes/81</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Thu, 26 Mar 2026 19:00:06 GMT</pubDate></item><item><title><![CDATA[好多人都在死掉]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/80">https://blog.ovoii.io/notes/80</a></blockquote><span>好多人都在死掉</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/80#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/80</link><guid isPermaLink="true">https://blog.ovoii.io/notes/80</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Mon, 16 Mar 2026 22:02:18 GMT</pubDate></item><item><title><![CDATA[记录 2026 年第 71 天]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/79">https://blog.ovoii.io/notes/79</a></blockquote><span>在年轻时候，我们误以为，我们生活中的重要人物和有影响的事件会大张旗鼓地露面和发生。到了老年以后，对生活所做的回顾和考察却告诉我们，这些人物和事件都是悄无声息、不经意地从后门进入我们的生活。</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/79#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/79</link><guid isPermaLink="true">https://blog.ovoii.io/notes/79</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Wed, 11 Mar 2026 22:34:29 GMT</pubDate></item><item><title><![CDATA[记录 2026 年 第 68 天]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/78">https://blog.ovoii.io/notes/78</a></blockquote><span>每个人都在自己的二十多岁痛哭</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/78#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/78</link><guid isPermaLink="true">https://blog.ovoii.io/notes/78</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sun, 08 Mar 2026 22:00:55 GMT</pubDate></item><item><title><![CDATA[记一次 Web 端私有格式加密 PDF (.zjjd) 的逆向与脱壳]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/posts/notes/zjjd">https://blog.ovoii.io/posts/notes/zjjd</a></blockquote><div><p>最近在寻找某电子书时遇到了一个采用自定义 <code>.zjjd</code> 文件格式来保护 PDF 电子书资源的 Web 端阅读器。它的前端代码不仅经过了深度混淆，还套上了一层 WebAssembly (Wasm) 的渲染引擎壳子。</p><h2 id="">初步分析</h2><p>在最初的抓包分析中，我并没有找到任何 <code>.pdf</code> 链接，只有几个静态资源和数据分片：</p><ol start="1"><li><strong><code>libmupdf.wasm.br</code></strong>：原文件约 21.7MB，解压后约 38.2MB，是经过 Brotli 压缩的 MuPDF WebAssembly 渲染核心。</li><li><strong><code>libmupdf.js</code></strong>：负责将上面的 Wasm 引擎解压并实例化。</li><li><strong><code>0.zjjd</code>、<code>1.zjjd</code>...</strong>：这是按页码请求的文档数据文件。大小约 300KB，没有 <code>%PDF-</code> 标记，没有 ZIP 结构，字节分布表现出极高的信息熵。</li></ol><p>初步分析可知这不是一个把 PDF 静态扔在服务器上的简易网站，而是通过 <code>libmupdf.js</code> 和 <code>libmupdf.wasm.br</code> 还原 <code>.zjjd</code> ，并没有直接在网络上暴露真实的 PDF。</p><h2 id="">寻找解密链路</h2><p>既然文件是加密的，那么它一定会在进入 MuPDF 渲染前被还原。</p><p>通过监控控制台日志，我注意到了 MuPDF 引擎抛出的一个隐蔽警告：<code>warning: ignoring CMap range (0-0) that is outside of the codespace</code>。</p><p>这是解析 PDF 字体/字符映射时的典型错误，也就是说最后交给 MuPDF 的是标准的 PDF 字节流。</p><p>顺着这条线索，我定位到了阅读页的核心业务 Chunk（<code>chunk-78d10754.e585c9ee.js</code>）。尽管代码被重度混淆，但它依然暴露出了一套 API 组合：
<code>ArrayBuffer</code> + <code>SHA-256</code> + <code>deriveKey</code> + <code>openDocumentFromBuffer</code>。</p><p>到这里，整条链路已经闭环：</p><p>前端按页请求 <code>.zjjd</code> -&gt; 业务 JS 使用 Web Crypto API 解密 -&gt; 得到明文 PDF 的 <code>ArrayBuffer</code> -&gt; 传递给 MuPDF Worker 进行渲染。</p><h2 id="">拦截</h2><p>既然明确了 <code>.zjjd</code> 经过解密后会变成 PDF 流给 MuPDF，那么根本不需要去死磕混淆。</p><p>通过下面这段 Hook 代码，可直接覆盖原生的 <code>Worker.prototype.postMessage</code>。因为主线程在解密完成后，必然要通过这个方法把二进制流传给 Worker。</p><pre class="language-javascript lang-javascript"><code class="language-javascript lang-javascript">// Hook Worker 的 postMessage 通信
const oldPostMessage = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (msg, transfer) {
  try {
    // 拦截发往 MuPDF 的指令
    if (Array.isArray(msg) &amp;&amp; msg[0] === &quot;openDocumentFromBuffer&quot;) {
      const data = msg[1]?.[0];
      const magic = msg[1]?.[1];

      // 如果载荷是 Uint8Array，说明明文 PDF 已经解密完成准备渲染
      if (data instanceof Uint8Array) {
        console.log(&quot;🔥 成功拦截&quot;);
        console.log(&quot;-&gt; 伪装文件名:&quot;, magic); 
        console.log(&quot;-&gt; ASCII 头:&quot;, new TextDecoder().decode(data.slice(0, 16))); 

        // 自动打包为 PDF 并触发下载
        const blob = new Blob([data], { type: &quot;application/pdf&quot; });
        const url = URL.createObjectURL(blob);
        const a = document.createElement(&quot;a&quot;);
        a.href = url;
        a.download = `decrypted_page.pdf`;
        a.click();
      } 
    }
  } catch (e) {
    console.log(&quot;hook error:&quot;, e);
  }
  return oldPostMessage.call(this, msg, transfer);
};

</code></pre>
<p>这段脚本注入后，随着在页面上滚动鼠标加载电子书，可以看到这控制台打印出传给 worker 的参数是 <code>[Uint8Array(330508), &#x27;a.pdf&#x27;]</code>，即系统将解密出的单页 PDF 数据流包装成名为 <code>a.pdf</code> 的虚拟文件塞给了 MuPDF。</p><h2 id="">简易脚本自动脱壳</h2><p>仅仅在浏览器里拿缓存是不够的。</p><p>在随后对 Web Crypto API 的底层 Hook，我拿到了加密系统的所有信息：</p><ol start="1"><li><strong>基础密钥 (BaseKey)</strong>：前端硬编码的字符串 <code>xSeZw1dY2HKAj3yk</code>。</li><li><strong>派生算法</strong>：PBKDF2 (SHA-256, 65536 次迭代)。</li><li><strong>文件结构</strong>：每一个 <code>.zjjd</code> 文件的前 24 个字节并非加密内容，而是动态的参数。其中 <code>[0:8]</code> 字节为 Salt，<code>[8:24]</code> 字节为 AES-CBC-128 需要的 IV。</li></ol><pre class="language-python lang-python"><code class="language-python lang-python">import os
import glob
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend

def decrypt_zjjd(input_path, output_path):
    # 硬编码基础密码
    base_key = b&quot;xSeZw1dY2HKAj3yk&quot;

    with open(input_path, &quot;rb&quot;) as f:
        data = f.read()

    # 动态剥离文件头中的 Salt (8字节) 和 IV (16字节)
    if len(data) &lt;= 24: return False
    salt, iv, encrypted_payload = data[:8], data[8:24], data[24:]

    # 派生密钥
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(), length=16, salt=salt,
        iterations=65536, backend=default_backend()
    )
    derived_key = kdf.derive(base_key)

    # AES-CBC 内存解密
    cipher = Cipher(algorithms.AES(derived_key), modes.CBC(iv), backend=default_backend())
    decryptor = cipher.decryptor()
    decrypted_data = decryptor.update(encrypted_payload) + decryptor.finalize()

    # 写入 PDF 明文
    with open(output_path, &quot;wb&quot;) as f: f.write(decrypted_data)
    return True

if __name__ == &quot;__main__&quot;:
    # 批量执行脱壳
    zjjd_files = sorted(glob.glob(&quot;./*.zjjd&quot;))
    print(f&quot;[*] 共发现 {len(zjjd_files)} 个加密块，开始脱壳...&quot;)
    
    for file_path in zjjd_files:
        output_path = file_path.replace(&quot;.zjjd&quot;, &quot;.pdf&quot;)
        if decrypt_zjjd(file_path, output_path):
            print(f&quot;  [+] 脱壳成功: {os.path.basename(file_path)}&quot;)
            
    print(&quot;\n 全部解密完成，可以使用 copy /b *.pdf final.pdf 合并成书。&quot;)

</code></pre></div><p style="text-align:right"><a href="https://blog.ovoii.io/posts/notes/zjjd#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/posts/notes/zjjd</link><guid isPermaLink="true">https://blog.ovoii.io/posts/notes/zjjd</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Fri, 06 Mar 2026 14:50:54 GMT</pubDate></item><item><title><![CDATA[子弹飞多久才会变成白鸽]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/77">https://blog.ovoii.io/notes/77</a></blockquote><div><p>子弹飞多久才会变成白鸽</p><p><a href="https://www.bilibili.com/video/BV1Lr4y1y7ZW">https://www.bilibili.com/video/BV1Lr4y1y7ZW</a></p></div><p style="text-align:right"><a href="https://blog.ovoii.io/notes/77#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/77</link><guid isPermaLink="true">https://blog.ovoii.io/notes/77</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Sun, 01 Mar 2026 19:47:58 GMT</pubDate></item><item><title><![CDATA[记录 2026 年第 56 天]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://blog.ovoii.io/notes/76">https://blog.ovoii.io/notes/76</a></blockquote><span>对坦白的烂人没有一丝反感 对装真诚的蠢货极致厌烦</span><p style="text-align:right"><a href="https://blog.ovoii.io/notes/76#comments">览毕，何不一言？</a></p></div>]]></description><link>https://blog.ovoii.io/notes/76</link><guid isPermaLink="true">https://blog.ovoii.io/notes/76</guid><dc:creator><![CDATA[顾绯]]></dc:creator><pubDate>Wed, 25 Feb 2026 15:00:58 GMT</pubDate></item></channel></rss>