猫がネズミを捕まえる
発端
私は小红书のリンクパーサーを持っていて、ノートからタイトル、作者、画像、コメント、統計などの情報を取得している。ある日、誰かが共有リンクの解析に失敗したと報告してきた:
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 で、これは小红书のホットリンク防止トークンであり、サーバーはこれが現在のセッションと一致するか検証する。
ブラウザのパケットキャプチャと比較すると、2回目のリクエストで追加の Cookie が発生している:
a1, webId, websectiga, sec_poison_id, gid, web_session, acw_tc, abRequestId
これらの Cookie はサーバーが Set-Cookie で発行したものではなく、ページ内の JavaScript が生成したものである。純粋な HTTP クライアントでは取得できない。
身元情報の2つのソース
ルート2とルート3を再現するには、まず重要な認識を確立する必要がある——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 |
ルート1はこれらのいずれにも触れない(だから「ゼロセッションコスト」)。ルート2は9ステップを完了し、すべてのcookieを取得するが、署名インターフェースは呼び出さない。ルート3はすべてのcookieに加えて、自分で5つの署名ヘッダーを計算する必要がある。
ルート1:モバイル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("ノートは削除されたか、リスク管理に引っかかった可能性があります")
考えてみれば合理的である。アプリ内の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 は空オブジェクトである——モバイルのデータ注入方法は異なり、ノートフィールドはHTMLテキスト内にJSONの断片として散在している(SSRでプリレンダリングされたscriptブロック、またはテンプレートのシリアライズ成果物)。"nickname":"..." / "desc":"..." / "title":"..." のようなキーと値のペアはテキスト内に平文で可読であり、正規表現で直接スキャンできる。
各フィールドに優先順位を定義し、順にマッチングし、最初の非空・非プレースホルダの値を使用する:
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> タグはしばしば "小红书" または "小红书 - 你的生活指南" のようなサイト名のプレースホルダであり、遭遇したら次のパターンにスキップする。そうしないと、フォールバックでサイト名にマッチして「小红书」を取得し、ユーザーは解析成功と思っても実際には何も取得できない:
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万")を返す。2つのルートで同じフィールドの意味が異なるため、下流でUIを組み立てる場合は自分で揃える必要がある。
話題:必ず tagList を使い、desc は使わない
話題の取得には落とし穴がある:desc フィールド内の #話題名[話題]# パターンから抽出しようとすると、一見マッチしそうだが結果がよく壊れる。理由は desc がすでにJSONエスケープされており、話題名内の中国語が \uXXXX シリアライズされているため、正規表現の境界判定が間違えやすい。正しい方法は 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> の構造で画像カルーセルをレンダリングする。このクラス名は非常にユニークで誤マッチしないため、正規表現で 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は3つのパターンを散点的にスキャンし、それぞれで透かし入りの _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 の3つの値: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] # メイン動画は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
3種類のコンテンツタイプすべてが出力され、主要データはほぼ揃っている。このルートの限界も明らか:
- コメント本文は取得できない。コメントは共有ページのHTMLには含まれず、別途
/api/sns/web/v2/comment/pageを叩く必要があり、そのインターフェースは完全な署名が必要な世界に戻る。 - 統計フィールドは整数であり、可読形式ではない。上記のライブフォトは実際には7.9万いいねがあるが、このルートで取得できる整数は
7のみ——モバイルHTML内のいいね数はCDN/SSRで切り詰められた断片として存在し、精度が不十分。
ルート2: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
完全なセッション初期化は以下の9ステップで構成され、各ステップで生成されたcookieが次のステップで必要となる:
1. GET / トップページを読み込み
2. GET /api/sec/v1/ds?appId=xhs-pc-web JSVMP復号スクリプトをプリロード
3. POST /api/redcaptcha/v2/getconfig CAPTCHA設定
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
1ステップでも欠けると、後続のインターフェースが失敗する。いくつかの主要な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 でスライスし、さらに固定オフセットで2回目のテーブルルックアップで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バイトコードから抽出したマジックナンバーであり、バージョンによって微調整される。sec_poison_id は同じ応答の別のフィールドから直接取得する。
gid と acw_tc:80以上のフィールドからなるブラウザフィンガープリント(UA、screen、WebGL、Canvasハッシュなど)をシリアライズ → base64 → DES-ECB暗号化(鍵 zbp30y86、ゼロパディングで8バイトブロック)→ hex。これを profileData として webprofile にPOSTすると、サーバーが応答の Set-Cookie でこの2つの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で、サーバーが発行する。プレフィックスは2種類:03 で始まるものはデバイスレベルのゲスト状態、空bodyのPOSTで取得可能。04 で始まるものは実際のログイン状態であり、ログイン済みブラウザのセッションcookieを持ってactivateにアクセスした場合のみ取得できる。03 は /v1/feed や共有ページHTMLなどの公開データには十分であり、本当に04が必要なのはフォローフィードやダイレクトメッセージなど、実際のユーザー関係に紐づくインターフェースのみで、ノート解析には無関係。
さらに、補助的なcookieもいくつか書き込まれる:loadts(署名用のタイムスタンプ、暗号化リクエストのたびに更新される)、webBuild(ARTIFACT_VERSION と等しい)、xsecappid(xhs-pc-web と等しい)、abRequestId(UUID)。これらが1つでも欠けると、サーバーは異常なクライアントと判断する。
cookieだけでなく、ヘッダーも厳密に合わせる必要がある。UA内のChromeバージョン(例:Chrome/147)は sec-ch-ua 内のChromiumバージョンと一致しなければならず、sec-ch-ua-platform、sec-ch-ua-mobile もすべて指定する必要がある。1つでも合わないと、署名インターフェースは461を返す。
バージョン同期:ARTIFACT_VERSION をハードコードしない
ARTIFACT_VERSION をハードコードすると、いずれ壊れる——1年余りでオンラインのバージョンは 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 も抽出する。抽出できなければローカル設定にフォールバックするが、ローカル設定は定期的に更新すること、2年前の値をそのまま残さないこと。
共有ページを開く
セッションが構築されたら、以下の2つの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",
]
1つ目はアプリからの共有を模倣し、2つ目はPCフィードからクリックした場合を模倣する。サーバーは 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クエリの区切り文字と解釈される。
HTMLから __INITIAL_STATE__ を抽出
小红书のPCフロントエンドのビルドは、ある更新で代入を緊密な書き方 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*=", 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 は直接爆発するため、2回の置換を行う:
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"] にある。2つの候補URLのうち、最初のものがヒットすれば直接返す。どちらもヒットしない場合、noteDetailMap には "undefined" プレースホルダキーだけが残っている可能性がある(ノートがリスク管理対象または削除された場合)。コードには「最初の非 undefined キーを探す」フォールバックを明示的に追加する:
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 と同じセッションと暗号化ヘッダーを使用する。つまり、PCセッションがきれいに構築されていれば、コメント、共有コード、ウィジェット情報などの付属データを取得するのはほぼおまけのようなものだ。
実測
ルート2は同じセッションで3つのテストリンクを実行し、取得した主要フィールドはルート1と完全に一致した:
| フィールド | 画像ノート | 動画ノート | ライブフォトノート |
|---|---|---|---|
| note_id | 6955f790... | 69e3114b... | 69e1594b... |
| title | 𝐰𝐞𝐜𝐡𝐚𝐭|情侣头像 | 仲夏可可很萌! | 🇫🇷尼斯老城遇到杨超越董思成 |
| content_type | image | video | live_photo |
| images | 12 | — | 3 |
| videos | — | 1 master URL | 3 motion(各静止画に1つ) |
| stats | 857 / 22 / 290 / 200 | 1209 / 154 / 160 / 42 | 79000 / 3419 / 5392 / 5024 |
ルート1と比較したルート2の2つの利点:
- 画像URLはデフォルトで透かしなしの
!nd_dft_wlteh_jpg_3バリアントであり、ドメインを変更しなくてもクリーンな画像が取得できる。 - 統計数は構造化フィールド(上記のライブフォトはHTMLレベルで
79000であり、ルート1のように切り詰められた7ではない)。
代償として、毎回完全な9ステップのbootstrapを実行する必要があり、コールドスタートに10〜30秒かかる。コメント本文を取得するにはさらに署名インターフェースを通過する必要がある。
ルート3:/api/sns/web/v1/feed を利用する
HTMLからほとんどのフィールドを取得できるが、いくつかのシナリオではAPIだけがきれいなデータを提供する。例えば、完全な infoList の複数解像度バリアント、動画の複数のバックアップCDN URL、正確な可読形式の統計数、ライブフォトの stream.h264 構造、一部の話題の完全なメタデータなどである。そのため、最も内側にはfeedインターフェースを直接呼び出すルートを残している。
このルートを通すための核心は2つある:1つはルート2のセッションとcookie生成チェーンを事前に構築しておくこと(/v1/feed は同じセッションを再利用し、a1 / web_session / gid のいずれも欠かせない)。2つ目は、各リクエストに5つの自分で計算した署名ヘッダーを付与することである。以下で詳細を説明する。
リクエスト署名の概要
| ヘッダー | 構築方法 |
|---|---|
x-s | 11セグメントのバイトストリームを結合した124バイト配列 → 各バイトを固定124バイトの鍵でXOR → 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 | 現在のミリ秒タイムスタンプ |
これら5つのヘッダーのうち、後ろ3つは純粋なローカル計算であり、一目見れば実装できる。本当の核心は 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] # 固定マジック
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)] # 固定鍵
return "mns0101_" + base58_encode(bytes(encrypted), CUSTOM_BASE58_TABLE)
いくつかの間違いやすい詳細:
part3ではまずtimestampのバイトに対して自己チェックサムを実行し、その後全体をXOR 41する。チェックサムが通らない場合(例えば適当なtimestampを指定した場合)、サーバーは直接461を返す。loadtsはノートインターフェースのタイムスタンプではなく、「今回の署名」のタイムスタンプである——署名の前にloadts = str(int(time.time() * 1000))をcookie jarに書き戻し、サーバーがエコーバックするcookieに常に最新の値が含まれるようにする。そしてこのミリ秒数をpart4として配列に結合する。つまり、署名のたびに再計算され、x-tとは1〜2ミリ秒しか違わない(ただし同じ値ではないので混同してはいけない)。1293は「Chrome 内のObject.getOwnPropertyNames(window).length」のハードコード値である。Chromiumのバージョンが変わるとこの数値が微調整されるため、時々実ブラウザで確認して更新する必要がある。XOR_KEY_124は124バイトの定数テーブル([175, 87, 43, 149, ...])であり、JSからそのまま抽出して使用する。part9の長さは動的である——len(a1)+ a1 のバイト数。ただし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 は小红书独自のコード表 ZmserbBoHtNP+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を1バイトに分解
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ラップアラウンド(num >= 2**31 ? num % 2**32 : num - 2**32)する。Pythonでこのラップアラウンドを行わないと、JSの符号付き整数動作と一致せず、サーバーは x9 を不正と判断する。
他の3つのヘッダー
残りの3つのヘッダーは比較的シンプル:
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鍵、Base58/Base64コード表、part11 の末尾、part1 のマジック、websectiga の d[92]/d[93]/675 オフセット)だけである。一度静的な実装を書き上げれば、次にサーバーが鍵を変更した場合、新旧の vendor-dynamic.xxx.js をdiffして新しい定数を特定するのに10数分しかかからない。逆に、第三者実装に依存すると、それが更新されない限り待つ必要がある。自分でリバースする最大のメリットは、「どの定数が変わり、どの構造が変わらないか」を知ることである——構造の層はほぼ決して変わらない。
フィンガープリントの context は動的である
見落とされがちな詳細として、フィンガープリントは一度きりではない。x-s-common 内の b1 は毎回現在の fp を使って再計算され、fp は storage_state(ページの状態)と page_context(現在のURLとリファラー)に基づいて更新される——例えばノート詳細を呼ぶときは 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} 文字列、そしてハードコードされたマジックフィールド(x30 "swf object not loaded"、x45 "__SEC_CAV__1-1-1-1-1|__SEC_WSA__|" など)。この辞書全体の役割は、「ブラウザがあるURL上のある瞬間の状態」を記述することである。
暗号化インターフェースを呼ぶたびに、現在のリクエストの影響を受ける 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 によって異なるベースラインを持つ:explore は (204, 14, 1240)、note_detail は (+36, +12, +420)、search は (+18, +8, +180)、user_profile は (+22, +10, +260) を加算する。これらのベースラインは実際のブラウザで各ページから抽出した値であり、1〜2数値の微調整はリスク管理に影響しないが、全体のオーダーと 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 を下流で処理する。
初回セッション品質と自動リトライ
署名、cookie、bootstrapがすべて正しくても、新しく作成したセッションで初めてfeedを叩くと、見かけ上成功しているがdataが空の応答が返る確率がある(success=True だが items が空)。これはコードの問題ではなく、サーバーが「新しい身元の初回アクセス」に対して確率的に権限を低下させるためである。同じコード、同じノートでも、今回は空が返り、セッションを再構築してもう一度叩けばデータが返る。
識別フラグ:success=True かつ items が空。処理方法は自動リトライループ——永続化されたデバイス状態(device_state.json)を削除し、9ステップのbootstrapを再実行して新しい身元に変更し、再びfeedを叩く。1回の呼び出しで最大3回試行する。コールドスタートでの初回ヒット率は約70〜90%、リトライを追加すると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 セマンティックCAPTCHA / 102 スライダーCAPTCHA;キャッチ後、次のラウンドで身元をリセットしてリトライ
last_err = f"attempt={attempt} verify={e.details.get('verifyType')}"
finally:
await session.close_session()
返答構造には2つの観測可能フィールド 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 ← 今回は初回変動、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 動画が1つ、stream.h264 構造は整然)
stats : liked=7.9万 comment=3419 collect=5392 share=5024 ← 可読形式
publish : 2026-04-17T05:48:59
ルート3のフィールド粒度が最も細かい:動画の複数バックアップURL、ライブフォトのmotionストリーム構造が整然、統計数は可読な 7.9万。代償として、毎回完全な9ステップのbootstrapを実行し、ローカルでJS署名を一度計算する必要がある。署名内の定数はサーバーバージョンに合わせて更新が必要になる可能性があり、偶発的にリトライが必要になることもある。
3つのルートを統合する
実際に運用する際、3つのルートはデータ品質順に積み重ねられる:
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 フィールドが付き、今回のデータがどのレベルかを一目で判断できる。
ルート1は別の入口である——画像だけが必要で、ついでにメタデータを取得するシナリオではこれを使う。PCセッションを構築せず、全体的なオーバーヘッドは完全なルートの10分の1である。3つのテストリンクを3つのルートでの結果と並べると、3つのルートは「取得できるデータ」においてほぼ等価であり、本当の違いは接続を確立するコストにある:
| ルート | セッションコスト | 署名コスト | データ鮮度 | コメント内容 | 画像透かし |
|---|---|---|---|---|---|
| モバイルUA + CDN置換 | なし | なし | SSRスナップショット | 取得不可 | !h5_1080jpg あり(ドメイン変更で解決) |
PC セッション + __INITIAL_STATE__ | 9ステップ bootstrap | なし | リアルタイム | 別途API必要 | デフォルトなし |
PC セッション + /v1/feed API | 9ステップ bootstrap + フィンガープリント | 自前計算 x-s / x-s-common | リアルタイム | 一緒に返される | デフォルトなし |
統一出力構造(3つのルートすべてをこの形状に統一し、下流での処理を容易にする):
{
"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,
}
画像バリアントの系譜
3つのルートの実測ダウンロード結果を並べてみると、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)、サイズ最適化が支配的。原図が必要な場合はドメイン変更が必要。
- 3つのルートの
image_idは完全に一致する。したがって、任意のルートで得たURLが分かれば、「date と sign をスキップし、image_idを保持してドメインを変更する」ことで、任意の他のバリアント(透かしなし原図を含む)を取得できる。
トラブルシューティングの順序:リンクが壊れたらどこから調べるか
リンクが切れたとき、最も避けたいのは「最後のステップ」から推測を始めることである。正しい方向は、最も手前で最も安価なチェックから順に進めていくことであり、各層には具体的なシグナルがある:
第1層:入力パラメータ
まず note_id と xsec_token が正しく解析されているか確認する——特に xsec_token 末尾の = が欠落していないか。確認方法:解決後の 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ステップのいずれかが失敗すると、後続はすべて連鎖的に失敗する。確認:各ステップでステータスコードと主要なcookie(websectiga / sec_poison_id / gid / acw_tc / web_session)が書き込まれたかどうかを出力する。典型的な問題:
- ステップ6、7の
scriptingの type パラメータが間違っている →websectigaがデコードできない profileData内のフィンガープリントがJSONエンコーダによって非ASCIIがエスケープされた → webprofile 401 / 461- activate 前にcookie jarがクリーンでない → web_session が取得できない、または間違ったプレフィックスが取得される
第4層:署名(ルート3固有)
feed が461を返した場合、ヘッダー内の Verifytype を確認する:
| 現象 | 段階 | 意味 | 対策 |
|---|---|---|---|
461 verifyType=216 | /v1/feed | 署名が無効、またはJSVMPバイトコードが変更された | 新しい定数に合わせる / バイトコードを再解析 |
461 verifyType=301 | /v1/feed | セッション品質が不十分、または page_context が一致しない | デバイス状態をリセットし、正しい page_type に設定 |
461 verifyType=102 | /v1/feed | スライダーCAPTCHAがトリガーされた | 手動で通過するか、CAPTCHAサービスに接続 |
461 verifyType=216 + HTML | 共有ページHTML | セマンティックCAPTCHA(画像選択や文字入力) | theme/gridを解析し、専用ブランチを呼び出す |
code=0 success=true data={} | /v1/feed | ソフトリスク管理:外見は成功だがdataが空 | まず xsec_token 末尾の = が欠落していないか確認;そうでなければデバイス状態をリセットしてリトライ |
SSLError / UNEXPECTED_EOF | 任意 | TLSフィンガープリントが認識された、またはネットワークの揺れ | リトライ、プロキシを変更 |
リダイレクト先 /login | HTML共有ページ | セッションの有効期限切れ、またはゲスト状態が取り消された | デバイス状態をクリアし、再度アクティベート |
特に code=0 data={} には注意が必要である。一見正常に見えるが、実際には何も返しておらず、粗雑な成功判定を騙しやすい。
第5層:HTML強抽出(ルート2)
feed が継続的に失敗するが、HTML共有ページが開ける場合は、ルート2に戻って __INITIAL_STATE__ が抽出できるか確認する。典型的な問題は前述の「等号両側の空白がなくなった」マーカーのリグレッションである——症状は extract_source が常に meta_fallback にフォールバックし、タイトルと本文はあるが作者/画像/時間がすべて空になる。これはサイトが機能を削除したのではなく、ローカルパーサーのリグレッションである。
第6層:モバイルフォールバック(ルート1)
PCの両ルートが変動している場合、モバイルルートを最後の検証として使用する。このルートでもデータが出なければ、ノート自体が削除されたか、リスク管理でこの出口IPが直接ブラックリスト入りした可能性が高い。このルートで基本フィールドが出るなら、コンテンツはまだ生きており、PC側の状態がクリーンでないだけなので、セッションをリセットしてやり直す。
確認済み、推測、もう神話化しないでくれ
多くの経験を積んできたので、これらを3つのカテゴリに分けて、後続の人が回り道をしなくて済むようにする。
確認済み:
xsec_token末尾の=が欠落すると、/v1/feedでcode=0 success=true data={}が発生する。window.__INITIAL_STATE__マーカーをハードコードすると、HTML完全抽出全体がmeta_fallbackに退化する。/v1/feedは現在、初回失敗後にセッションを再構築すると復旧する実際の変動が存在する。web_sessionはサーバーサイドで発行され、ローカルでいくら組み合わせても「有効だがサーバーが見たことがない」値にはならない。
高信頼性の推測:
- セッション品質(cookieの完全性 + フィンガープリントの一貫性 + page_contextの一致)はルートCの初回成功率に顕著に影響する。
- 永続化された「安定したデバイス状態」は、完全にランダムな新しいデバイス状態よりも実際のブラウザに近く、通過率が高い。
- JSVMP VMの命令セットは長期的に安定しており、主にバイトコード内の定数が変わる——つまり、自分で一度リバースエンジニアリングすれば、その後のメンテナンスコストは実際には許容範囲である。
もう神話化しないでくれ:
- 「
web_sessionが03プレフィックスなら絶対ダメ、04プレフィックスなら絶対大丈夫」——それは正しくない。03は公開データに十分であり、04はフォローフィード/ダイレクトメッセージなどのインターフェースでのみ必要。 - 「特定のヘッダーを追加すれば必ずfeedを通せる」——それは正しくない。feedの成否はセッション品質、cookieの組み合わせ、フィンガープリントコンテキスト、タイミング変動の総合結果である。
- 「ルート1は画像しか取得できない」——それは正しくない。モバイルHTMLには実際にほとんどのフィールドが散在しており、散点的な正規表現でスキャンできる。コメント本文は確かに取得できないが、それ以外は必要なものを取得できる。
- 「透かしは
sns-webpic-qcドメインの特性である」——それは正しくない。透かしは!h5_1080jpgという処理サフィックスの特性であり、同じドメインの!nd_dft_wlteh_*バリアントはデフォルトで透かしがない。
小红书のアンチスクレイピングはどのようなものか
3つのルートを重ね合わせることで、おおよそ小红书の防御階層を描くことができる:
| 階層 | 仕組み | 説明 |
|---|---|---|
| 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 | セマンティックCAPTCHA | 216型、画像内のテーマやグリッドを認識する必要がある |
| 9 | 行動分析 | 頻度、軌跡、タイミングなどの総合リスク管理 |
第4層は難しいが、実際には最初の8層を合わせるだけで99%のスクリプトをブロックでき、ほとんどの人は第9層の門にすらたどり着かない。JSVMPは署名アルゴリズムをカスタムバイトコードにコンパイルするが、VM命令セットは基本的に安定しており、変わるのはバイトコード内の定数(XOR鍵、コード表、マジックバイトなど)だけである——したがって、一度リバースエンジニアリングしてしまえば、メンテナンスは新旧の vendor-dynamic.js をdiffして定数を抽出するだけの作業になる。実際に毎日サーバーとやり取りするのはセッション品質である:cookieの発行、バージョン番号の同期、フィンガープリントの一貫性——これらこそがリクエストごとに変わるものである。
免責事項
本稿は、技術学習の過程における個人の探求と思考を記録したものであり、セキュリティ研究および教育目的で使用されます。文中で言及されているすべての技術分析は、公開されたページとネットワークリクエストを対象としており、不正アクセス、データの一括収集、または商業目的は含まれていません。関連プラットフォームの利用規約および地域の法律を遵守し、本稿の内容を他人の正当な権利を侵害する目的で使用しないでください。
あとがき
3つのルートは「裸のHTTP」から「完全なWebセッション+公式API」までかなりの幅があるが、実際に時間がかかるのは決して署名アルゴリズム自体ではなく、セッションの品質である——UAとclient hintsを合わせる必要があるか、bootstrapが完了するか、web_session が03か04か、page_context が現在のリクエストと一致しているか。署名アルゴリズムは一度書けば長く使えるが、セッション品質はリクエストごとにサーバーとやり取りする日常業務である。
もう一つの観察は、最も高価なルート(ルート3)と最も安価なルート(ルート1)が「取得できるデータ」において大きな差がないことである——両者が返すフィールドの違いは主にコメント本文、動画の複数バックアップURL、可読形式の統計数の3つである。階層的なフォールバックでこれらを積み重ね、高価なルートのコストを分散し、同時に extract_source フィールドで毎回どのレベルに到達したかを明示的にマークすることで、問題が発生したときに手動で推測する必要がなくなる。
sns-webpic-qc と sns-img-qc というCDNドメインのペアは、おそらくこのシステム全体を締めくくるのに最適な例である。前者は署名パスを通り、URLサフィックスで画像の仕様を決定する——透かしの有無、解像度、エンコード形式はすべてサフィックスに隠されている。後者は生の image_id で直接原図を提供し、署名もサフィックスの概念もない。2つのドメインは同じ image_id 名前空間を共有している——webpic-qc上の画像のIDが分かれば、img-qcで原図を無料で取得できる。この点こそが、モバイル共有ページ内の !h5_1080jpg 透かしバージョンを簡単に回避できる理由である:署名の計算方法を知る必要はなく、image_id を保持してドメインを変更するだけでよい。この扉はいつか閉じられるかもしれない。しかし少なくとも今日は、まだ開いている。
付録:最小再現チェックリスト
この順序で能力を補完すれば、3つのルートすべてを実行できる。チェックを入れながら確認する:
入口層
-
xhslink.com/o/...の302を追跡し、最終URLを取得する - 最終URLから
note_id/xsec_token/xsec_sourceを抽出し、parse_qsで=のパディングを維持する
ルート1(ゼロセッション)
- iPhone UAで共有ページをリクエストし、リダイレクトを追跡する
- 散点的な正規表現でタイトル/作者/本文/stats/topics/時間を抽出し、「小红书」プレースホルダフィルターを適用する
-
onix-carousel-itemで画像を取得し、_259.mp4で透かし動画をフィルターする -
cdn_strip_watermarkでsns-img-qcに変更し原図を取得する - contenttype の3分岐判定(video / `livephoto` / image)
Cookie生成(ルート2、3共通)
-
a1=hex(ts) + rand30 + "50000" + crc32[:52]、webId= MD5(a1) - バージョン同期:オンラインの
vendor-dynamic.xxx.jsからartifactVersion/languageVersionを抽出する - 9ステップのbootstrapを順に実行し、各ステップで対応するcookieが書き込まれるか確認する
-
websectigaのbase64 + 5文字グループ + 2回目のテーブルルックアップデコード -
gidのJSON → base64 → DES-ECB(zbp30y86) → hex -
web_sessionをactivate応答から取得し、03 / 04を区別する
ルート2(HTML)
- 2つの候補URL(discovery/item + explore)、documentレベルのヘッダーを付与する
-
extract_assigned_json_object:正規表現マーカー + ブレースバランス + 文字列エスケープ -
sanitize_initial_state:2回のundefined→null -
choose_note_payload:note_idで検索し、フォールバックで最初の非undefinedキーを探す
ルート3(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の固定形状(
source_note_id/image_formats/extra/xsec_source/xsec_token) - 3回の自動リトライ、各回の失敗で
device_state.jsonを削除 + init cookieキャッシュをクリア + セッションを再構築
統一出力
- 3つのルートを同じdict形状に統一し、
source/extract_source/feed_attempts/feed_recovered_on_attemptを含める - 3種類のコンテンツタイプ(image / video / live_photo)すべてで完全なフィールドが出力されることを実測する