download-xhs-videos — batch-download a 小红书 creator's videos
Xiaohongshu gives you no download button (checked 2026-07: the web share panel offers only a QR code and "copy link", the … menu only "report", and the creator dashboard won't even export your own videos). The two obvious workarounds both fail: yt-dlp support is flaky, and a plain scraper hits signed-request (x-s/x-t) walls.
What works is driving a real, logged-in Chrome:
Real click on the note's TITLE to open it → read the video URL out of the page's __INITIAL_STATE__ → download with curl, outside the browser.
How the URL gets from the page to curl is the only part that differs per agent.
Supported agents
| Agent |
Browser layer |
Status |
Route |
| Claude Code |
Claude in Chrome extension |
✅ verified end-to-end 2026-07 |
Route A |
| Codex (desktop / CLI) |
chrome@openai-bundled, ChatGPT for Chrome |
✅ verified end-to-end 2026-07 |
Route B |
| Anything else |
— |
❓ not tested |
— |
Only these two have been tested. Other coding agents may have the needed pieces — a trusted click into the real logged-in Chrome, a way to read page JS state, and a shell for curl — but nobody has run this skill on them. If you are a different agent: read The invariants, map them onto your own browser API, and tell the user you are improvising off an untested path.
Before you start — preconditions
- macOS. (Route A needs
pbpaste; the extension hosts are mac-only today.)
- Your browser layer is connected to a Chrome that is logged into 小红书.
- The creator's profile URL —
https://www.xiaohongshu.com/user/profile/<uid>. A bare uid URL works when you're logged in; an ?xsec_token=… copy works too.
- A target folder, default
~/Documents/xhs-<handle>/.
Cross-account works. The logged-in account does not have to be the creator you're downloading — verified on both routes with a profile belonging to someone other than the logged-in user. You just need some valid 小红书 session so pages render.
Ethics gate — do this, don't skip it
Open the profile and read the creator's bio first. Many creators write 「原创作品,禁止搬运和商用」. If so, confirm with the user that this is personal/offline viewing only. Downloading public videos for yourself is a defensible grey area; re-publishing or monetizing someone's flagged original work is not — refuse that. State what the bio says and get a clear yes before mass-downloading.
The invariants
Properties of Xiaohongshu and Chrome, not of any agent. They hold on every route.
A note only opens on a real click. location.href, a hand-built <a>, or navigating to a note URL all get bounced back to /explore by XHS's router. Only a genuine mouse click at the card's coordinates loads the note with its video stream.
Click the card's TITLE, not its cover. Each card has three anchors to the same note: a zero-size one, a.cover (223×297), and a.title. Clicking the cover's center is swallowed by XHS's 「图搜同款」 visual-search hover overlay — the click lands, nothing navigates, and you burn a loop iteration wondering why. a.title has no overlay.
Wait for the cover images before trusting any rect. Until the covers finish loading, the masonry layout has not run and every card returns the same getBoundingClientRect() (observed: all five first cards reporting x=307). Clicking that opens the wrong note or nothing. The tell is duplicate centers across cards. Fix: scroll down ~5 ticks, scroll back up, wait ~2 s — that forces the lazy-load. Once loaded, centers are correctly distinct (307 / 562 / 817 / 1072 / 1327). scripts/02-locate-card.js refuses to guess when it detects duplicates.
The stream's codec keys are not stable — never hardcode them. They used to be h264 / h265 / av1 / h266. As of 2026-07 live pages ship EF4 / EF5 / EF6 / EF7 (EF4 is their h264 ladder — a downloaded EF4 stream probes as h264). Worse: key order is not preference order (observed EF4, EF6, EF5, EF7) and some keys are empty arrays. Walk a preference list, then fall back to whatever keys exist, skip empties, and never index blindly. (This bug silently broke the skill for everyone until it was fixed.)
Don't fetch() the video inside the page. Chrome caps ~6 connections per host; a few hung fetches to the CDN exhaust the pool and every later fetch hangs forever. curl sidesteps this.
The CDN URL is http://, not https://, and needs a Referer. Pass --referer https://www.xiaohongshu.com/ plus a normal User-Agent. (Use curl's --referer flag rather than spelling the header out with -H — Xiaohongshu's own upload gateway runs an Aliyun WAF that false-positives curl -H "…" as header injection and 405s the request. Same for || chains; see Verify.)
Only one agent per tab. If two browser-driving agents both attach to the same tab, you get endless CDP timeouts and kernel resets that look exactly like a missing capability. Claim a tab nobody else is on.
Heavy automated use trips XHS's security verification. After enough automated navigation in one session, profile loads start redirecting to /website-login/captcha?…verifyType=… and the page has no feed at all (noteAnchors: 0). This is rate-limiting, not a bug in your code.
Do not attempt to solve the CAPTCHA. Stop, tell the user their session hit a verification wall, and ask them to clear it themselves in that Chrome window. Then resume. Pace the loop (the per-note navigation is already slow enough for normal use) and don't re-run the whole profile repeatedly while debugging — test on 1–2 notes.
Quality: masterUrl is the top stream of the chosen ladder; XHS source bitrate is modest, so 1–5 MB for a 2-minute clip is normal, not a bug.
Route A — Claude Code
Browser layer: the Claude in Chrome extension (list_connected_browsers, navigate, tabs_context_mcp, javascript_tool, computer, browser_batch).
Two extra traps specific to this route
Chrome silently blocks automation-triggered downloads. A blob download via a.click() from injected JS needs a user-activation gesture that injected code doesn't carry — and the extension's synthetic clicks don't grant download activation either (they fire DOM click handlers, so execCommand('copy') works, but the download is dropped with no error and no file). So: never download through the browser. Use curl.
The harness blocks query-string data from your context. If in-page JS returns the video URL, the tool result is [BLOCKED: Cookie/query string data] because of the xsec_token. So you can't read the URL and build a curl command with it. Route the URL clipboard → curl so it never touches your context.
javascript_tool does not await an async IIFE. An async () => {…} returns a pending Promise and you receive {} — the side effects still happen, but the return value is lost. All three scripts here are therefore synchronous (except part 1 of 01, whose return value genuinely doesn't matter). If you write your own, keep them sync or you'll never get your coordinates back.
Recipe
Step 0 — Connect and open the profile. Connect the browser, open a fresh tab (tabs_context_mcp createIfEmpty:true), navigate to the profile URL, get_page_text to read the bio (ethics gate) and the note titles.
Step 1 — Load every note and count. Run part 1 of scripts/01-load-and-count.js (async scroll, return value ignored), then part 2 (sync) to read the count. Re-run part 2 until growing is false. Report the total to the user — it counts image + video notes both.
Step 2 — Per-note loop. For note index N (0-based, DOM order):
- Call A — locate (one
browser_batch): navigate back to the profile, then scripts/02-locate-card.js with N set. It returns {count, x, y, text, ready} in screenshot pixels. If it returns err: 'masonry-not-laid-out', scroll down 5 / up 10, wait 2 s, and re-run.
- Call B — open + extract + copy (one
browser_batch):
computer left_click the returned coords → the note opens (you're clicking the title).
- Wait ~5 s, then
javascript_tool runs scripts/03-extract-and-copy.js. It returns {ok, codec, height, urlLooksRight, buttonPlaced}. On err: 'no-video-yet', wait and re-run; on err: 'no-video' it's an image note — skip it.
computer left_click [150,155] — a trusted click on the copy button → URL is on the clipboard.
- Call C — download (Bash):
scripts/download.sh <out-dir> "NN_<title>.mp4".
Step 3 — Verify. See Verify.
Route A gotchas
- Viewport vs screenshot pixels differ ~2%. Scale rect coords by
1496/innerWidth and 812/innerHeight (the locate script does this) or clicks drift on the rightmost column.
back navigation re-renders the DOM, so prior refs are stale. Rebuild the card list every loop.
- Browser downloads vanish both ways: "Ask where to save each file" on → a native Save dialog the extension can't touch; off → no user activation. Stop fighting it.
Route B — Codex
Browser layer: the chrome@openai-bundled plugin driven through mcp__node_repl__js. Check codex plugin list shows it installed, enabled, and that ChatGPT for Chrome is installed in the Chrome profile that's logged into 小红书.
Route B is simpler than Route A — it has neither of Route A's first two traps. There is no context guardrail on token-bearing URLs, so you can just read the URL and curl it. Do not port the clipboard trick over: you don't need it, and it cannot work here.
Setup
// inside mcp__node_repl__js, once per session
if (globalThis.agent?.browsers == null) {
const { setupBrowserRuntime } = await import(
"<codexHome>/plugins/cache/openai-bundled/chrome/<version>/scripts/browser-client.mjs");
await setupBrowserRuntime({ globals: globalThis });
}
await chrome.nameSession("📥 下载小红书视频");
const tabs = await chrome.user.openTabs();
const tab = await chrome.user.claimTab(tabs[i]); // claim a tab NO ONE ELSE is on
Read await chrome.documentation() once — it's the authoritative spec and longer than this summary.
Two hard constraints on this route
tab.playwright.evaluate runs in a READ-ONLY scope. Global assignment and DOM mutation both throw:
Error: global assignment is not available in playwright.evaluate because the DOM is read-only
So Route A's "stash the URL on window.__urlForCopy and inject a copy button" is impossible. Fine — reading values and returning them is allowed, which is all you need.
window.__INITIAL_STATE__ is NOT visible from that scope. Reading it directly returns an empty object (noteDetailMap keys [], hasStream:false) — the evaluate world is isolated from the page's real window. You must parse the inline <script> instead, and replace bare undefined with null before JSON.parse (XHS serializes literal undefined, which is not valid JSON):
const raw = Array.from(document.scripts)
.find(el => (el.textContent || '').includes('window.__INITIAL_STATE__='))?.textContent || '';
const prefix = 'window.__INITIAL_STATE__=';
const state = JSON.parse(
raw.slice(raw.indexOf(prefix) + prefix.length).replace(/;\s*$/, '').replace(/\bundefined\b/g, 'null'));
Recipe
Step 0 — claim a fresh tab, goto the profile, read the bio (ethics gate) via tab.playwright.domSnapshot().
Step 1 — scroll to the bottom, count distinct note ids. Port 01's logic as pure, value-returning evaluate calls (no window assignments).
Step 2 — per-note loop:
- Locate —
evaluate that returns a.title's center for card N. Apply invariants 2 and 3 (title anchor; refuse duplicate rects until covers load).
- Open —
await tab.cua.click({x, y, button: 1}). Real trusted click; verified to open the note. Confirm with await tab.url().
- Extract —
evaluate that parses the inline state per constraint 2 above, picks the stream per invariant 4, and returns the URL string:const stream = state?.note?.noteDetailMap?.[noteId]?.note?.video?.media?.stream;
for (const k of ['h264','h265','av1','h266','EF4','EF5','EF6','EF7']) {
const it = stream?.[k]?.[0];
if (it) return it.masterUrl || it.backupUrls?.[0] || null;
}
- Download — plain shell, no clipboard:
curl -sS -L -A "Mozilla/5.0" --referer https://www.xiaohongshu.com/ \
-o "NN_<title>.mp4" "$URL"
- Back to the profile, repeat. Rebuild the card list each loop.
Step 3 — Verify. See Verify.
Route B gotchas
- CDP flakiness is normal.
Runtime.evaluate times out and the JS kernel resets (js execution timed out; kernel reset, rerun your request), sometimes several times running. Use timeout_ms: 60000, re-run setupBrowserRuntime() when globalThis.agent?.browsers == null, re-claimTab, retry the same step. Do not conclude the capability is missing and switch methods — the retry works. (If it never recovers, check invariant 7: another agent on your tab.)
- Codex has extras this skill doesn't use:
tab.capabilities.get("pageAssets") → list()/bundle() can inventory kind:"video" assets and export them to a local directory; downloadMedia({x,y}) triggers a real media download and path() returns the file; clipboard.readText() exists too. These are plausible shortcuts — untested here, so treat them as experiments, not the documented path.
Verify
After the loop: count matches, none <10 KB, all ISO Media.
cd <out-dir>
for f in *.mp4; do
if ! file "$f" | grep -q 'ISO Media'; then echo "NOT_MP4: $f"; fi
done
ls -1 *.mp4 | wc -l; du -sh .
If ffprobe is available, spot-check that durations match what the profile showed — that's how you catch truncated downloads.
Files in this skill
SKILL.md — this playbook.
scripts/01-load-and-count.js — two parts: async scroll-to-bottom, then a sync counter.
scripts/02-locate-card.js — sync; returns card N's title-anchor center, with the masonry sanity check.
scripts/03-extract-and-copy.js — sync; codec-agnostic stream pick + the clipboard copy button. Route A only (the button can't exist on Route B).
scripts/download.sh — clipboard → curl with a CDN guard. Route A only; Route B curls the URL it already has.
Dependencies (explicit)
- macOS (
curl; pbpaste for Route A).
- A Chrome signed into 小红书, plus one of:
- Claude Code with the Claude in Chrome extension connected, or
- Codex with
chrome@openai-bundled enabled and ChatGPT for Chrome installed in that profile.
Related skills
waitlist-farmer — same "drive a real logged-in browser through a repetitive flow" muscle, different domain.
1---2name: download-xhs-videos3description: download-xhs-videos — batch-download a 小红书 creator's videos4---56# download-xhs-videos — batch-download a 小红书 creator's videos78Xiaohongshu gives you no download button (checked 2026-07: the web share panel offers only a QR code and "copy link", the `…` menu only "report", and the creator dashboard won't even export your *own* videos). The two obvious workarounds both fail: `yt-dlp` support is flaky, and a plain scraper hits signed-request (`x-s`/`x-t`) walls.910What works is **driving a real, logged-in Chrome**:1112> **Real click on the note's TITLE to open it → read the video URL out of the page's `__INITIAL_STATE__` → download with `curl`, outside the browser.**1314How the URL gets from the page to `curl` is the only part that differs per agent.1516## Supported agents1718| Agent | Browser layer | Status | Route |19|---|---|---|---|20| **Claude Code** | Claude in Chrome extension | ✅ verified end-to-end 2026-07 | [Route A](#route-a--claude-code) |21| **Codex** (desktop / CLI) | `chrome@openai-bundled`, ChatGPT for Chrome | ✅ verified end-to-end 2026-07 | [Route B](#route-b--codex) |22| Anything else | — | ❓ **not tested** | — |2324**Only these two have been tested.** Other coding agents may have the needed pieces — a trusted click into the real logged-in Chrome, a way to read page JS state, and a shell for `curl` — but nobody has run this skill on them. If you are a different agent: read [The invariants](#the-invariants), map them onto your own browser API, and **tell the user you are improvising off an untested path**.2526## Before you start — preconditions27281. **macOS.** (Route A needs `pbpaste`; the extension hosts are mac-only today.)292. Your browser layer is connected to a Chrome that is **logged into 小红书**.303. The **creator's profile URL** — `https://www.xiaohongshu.com/user/profile/<uid>`. A bare uid URL works when you're logged in; an `?xsec_token=…` copy works too.314. A **target folder**, default `~/Documents/xhs-<handle>/`.3233**Cross-account works.** The logged-in account does not have to be the creator you're downloading — verified on **both** routes with a profile belonging to someone other than the logged-in user. You just need some valid 小红书 session so pages render.3435### Ethics gate — do this, don't skip it3637Open the profile and **read the creator's bio first.** Many creators write 「原创作品,禁止搬运和商用」. If so, confirm with the user that this is **personal/offline viewing only**. Downloading public videos for yourself is a defensible grey area; re-publishing or monetizing someone's flagged original work is not — refuse that. State what the bio says and get a clear yes before mass-downloading.3839## The invariants4041Properties of Xiaohongshu and Chrome, not of any agent. They hold on every route.42431. **A note only opens on a *real* click.** `location.href`, a hand-built `<a>`, or navigating to a note URL all get bounced back to `/explore` by XHS's router. Only a genuine mouse click at the card's coordinates loads the note *with* its video stream.44452. **Click the card's TITLE, not its cover.** Each card has three anchors to the same note: a zero-size one, `a.cover` (223×297), and `a.title`. **Clicking the cover's center is swallowed by XHS's 「图搜同款」 visual-search hover overlay** — the click lands, nothing navigates, and you burn a loop iteration wondering why. `a.title` has no overlay.46473. **Wait for the cover images before trusting *any* rect.** Until the covers finish loading, the masonry layout has not run and **every card returns the same `getBoundingClientRect()`** (observed: all five first cards reporting `x=307`). Clicking that opens the wrong note or nothing. The tell is duplicate centers across cards. Fix: scroll down ~5 ticks, scroll back up, wait ~2 s — that forces the lazy-load. Once loaded, centers are correctly distinct (`307 / 562 / 817 / 1072 / 1327`). `scripts/02-locate-card.js` refuses to guess when it detects duplicates.48494. **The stream's codec keys are not stable — never hardcode them.** They used to be `h264 / h265 / av1 / h266`. As of 2026-07 live pages ship **`EF4 / EF5 / EF6 / EF7`** (EF4 is their h264 ladder — a downloaded EF4 stream probes as `h264`). Worse: **key order is not preference order** (observed `EF4, EF6, EF5, EF7`) and **some keys are empty arrays**. Walk a preference list, then fall back to whatever keys exist, skip empties, and never index blindly. (This bug silently broke the skill for everyone until it was fixed.)50515. **Don't `fetch()` the video inside the page.** Chrome caps ~6 connections per host; a few hung fetches to the CDN exhaust the pool and every later fetch hangs forever. `curl` sidesteps this.52536. **The CDN URL is `http://`, not `https://`, and needs a Referer.** Pass `--referer https://www.xiaohongshu.com/` plus a normal User-Agent. (Use curl's `--referer` flag rather than spelling the header out with `-H` — Xiaohongshu's own upload gateway runs an Aliyun WAF that false-positives `curl -H "…"` as header injection and 405s the request. Same for `||` chains; see Verify.)54557. **Only one agent per tab.** If two browser-driving agents both attach to the same tab, you get endless CDP timeouts and kernel resets that look exactly like a missing capability. Claim a tab nobody else is on.56578. **Heavy automated use trips XHS's security verification.** After enough automated navigation in one session, profile loads start redirecting to `/website-login/captcha?…verifyType=…` and the page has no feed at all (`noteAnchors: 0`). This is rate-limiting, not a bug in your code.58 **Do not attempt to solve the CAPTCHA.** Stop, tell the user their session hit a verification wall, and ask them to clear it themselves in that Chrome window. Then resume. Pace the loop (the per-note navigation is already slow enough for normal use) and don't re-run the whole profile repeatedly while debugging — test on 1–2 notes.59609. **Quality**: `masterUrl` is the top stream of the chosen ladder; XHS source bitrate is modest, so 1–5 MB for a 2-minute clip is normal, not a bug.6162## Route A — Claude Code6364Browser layer: the **Claude in Chrome** extension (`list_connected_browsers`, `navigate`, `tabs_context_mcp`, `javascript_tool`, `computer`, `browser_batch`).6566### Two extra traps specific to this route67681. **Chrome silently blocks automation-triggered downloads.** A blob download via `a.click()` from injected JS needs a *user-activation* gesture that injected code doesn't carry — and the extension's synthetic clicks don't grant download activation either (they fire DOM `click` handlers, so `execCommand('copy')` works, but the download is dropped with no error and no file). **So: never download through the browser. Use `curl`.**69702. **The harness blocks query-string data from your context.** If in-page JS *returns* the video URL, the tool result is `[BLOCKED: Cookie/query string data]` because of the `xsec_token`. So you can't read the URL and build a `curl` command with it. **Route the URL clipboard → `curl` so it never touches your context.**71723. **`javascript_tool` does not await an async IIFE.** An `async () => {…}` returns a pending Promise and you receive `{}` — the side effects still happen, but the return value is lost. All three scripts here are therefore **synchronous** (except part 1 of `01`, whose return value genuinely doesn't matter). If you write your own, keep them sync or you'll never get your coordinates back.7374### Recipe7576**Step 0 — Connect and open the profile.** Connect the browser, open a fresh tab (`tabs_context_mcp createIfEmpty:true`), `navigate` to the profile URL, `get_page_text` to read the bio (ethics gate) and the note titles.7778**Step 1 — Load every note and count.** Run part 1 of `scripts/01-load-and-count.js` (async scroll, return value ignored), then part 2 (sync) to read the count. Re-run part 2 until `growing` is false. Report the total to the user — it counts image + video notes both.7980**Step 2 — Per-note loop.** For note index `N` (0-based, DOM order):8182- **Call A — locate** (one `browser_batch`): `navigate` back to the profile, then `scripts/02-locate-card.js` with `N` set. It returns `{count, x, y, text, ready}` in **screenshot pixels**. If it returns `err: 'masonry-not-laid-out'`, scroll down 5 / up 10, wait 2 s, and re-run.83- **Call B — open + extract + copy** (one `browser_batch`):84 1. `computer left_click` the returned coords → the note opens (you're clicking the title).85 2. Wait ~5 s, then `javascript_tool` runs `scripts/03-extract-and-copy.js`. It returns `{ok, codec, height, urlLooksRight, buttonPlaced}`. On `err: 'no-video-yet'`, wait and re-run; on `err: 'no-video'` it's an image note — skip it.86 3. `computer left_click [150,155]` — a **trusted** click on the copy button → URL is on the clipboard.87- **Call C — download** (Bash): `scripts/download.sh <out-dir> "NN_<title>.mp4"`.8889**Step 3 — Verify.** See [Verify](#verify).9091### Route A gotchas9293- **Viewport vs screenshot pixels differ ~2%.** Scale rect coords by `1496/innerWidth` and `812/innerHeight` (the locate script does this) or clicks drift on the rightmost column.94- **`back` navigation re-renders the DOM**, so prior refs are stale. Rebuild the card list every loop.95- **Browser downloads vanish both ways**: "Ask where to save each file" on → a native Save dialog the extension can't touch; off → no user activation. Stop fighting it.9697## Route B — Codex9899Browser layer: the **`chrome@openai-bundled` plugin** driven through `mcp__node_repl__js`. Check `codex plugin list` shows it *installed, enabled*, and that **ChatGPT for Chrome** is installed in the Chrome profile that's logged into 小红书.100101**Route B is simpler than Route A** — it has neither of Route A's first two traps. There is no context guardrail on token-bearing URLs, so you can just read the URL and `curl` it. **Do not port the clipboard trick over: you don't need it, and it cannot work here.**102103### Setup104105```js106// inside mcp__node_repl__js, once per session107if (globalThis.agent?.browsers == null) {108 const { setupBrowserRuntime } = await import(109 "<codexHome>/plugins/cache/openai-bundled/chrome/<version>/scripts/browser-client.mjs");110 await setupBrowserRuntime({ globals: globalThis });111}112await chrome.nameSession("📥 下载小红书视频");113const tabs = await chrome.user.openTabs();114const tab = await chrome.user.claimTab(tabs[i]); // claim a tab NO ONE ELSE is on115```116117Read `await chrome.documentation()` once — it's the authoritative spec and longer than this summary.118119### Two hard constraints on this route1201211. **`tab.playwright.evaluate` runs in a READ-ONLY scope.** Global assignment and DOM mutation both throw:122 > `Error: global assignment is not available in playwright.evaluate because the DOM is read-only`123124 So Route A's "stash the URL on `window.__urlForCopy` and inject a copy button" is impossible. Fine — *reading* values and `return`ing them is allowed, which is all you need.1251262. **`window.__INITIAL_STATE__` is NOT visible from that scope.** Reading it directly returns an empty object (`noteDetailMap` keys `[]`, `hasStream:false`) — the evaluate world is isolated from the page's real `window`. **You must parse the inline `<script>` instead, and replace bare `undefined` with `null` before `JSON.parse` (XHS serializes literal `undefined`, which is not valid JSON):**127128 ```js129 const raw = Array.from(document.scripts)130 .find(el => (el.textContent || '').includes('window.__INITIAL_STATE__='))?.textContent || '';131 const prefix = 'window.__INITIAL_STATE__=';132 const state = JSON.parse(133 raw.slice(raw.indexOf(prefix) + prefix.length).replace(/;\s*$/, '').replace(/\bundefined\b/g, 'null'));134 ```135136### Recipe137138**Step 0** — claim a fresh tab, `goto` the profile, read the bio (ethics gate) via `tab.playwright.domSnapshot()`.139140**Step 1** — scroll to the bottom, count distinct note ids. Port `01`'s logic as **pure, value-returning** `evaluate` calls (no `window` assignments).141142**Step 2 — per-note loop:**1431441. **Locate** — `evaluate` that returns `a.title`'s center for card `N`. Apply invariants 2 and 3 (title anchor; refuse duplicate rects until covers load).1452. **Open** — `await tab.cua.click({x, y, button: 1})`. Real trusted click; verified to open the note. Confirm with `await tab.url()`.1463. **Extract** — `evaluate` that parses the inline state per constraint 2 above, picks the stream per invariant 4, and **returns the URL string**:147 ```js148 const stream = state?.note?.noteDetailMap?.[noteId]?.note?.video?.media?.stream;149 for (const k of ['h264','h265','av1','h266','EF4','EF5','EF6','EF7']) {150 const it = stream?.[k]?.[0];151 if (it) return it.masterUrl || it.backupUrls?.[0] || null;152 }153 ```1544. **Download** — plain shell, no clipboard:155 ```bash156 curl -sS -L -A "Mozilla/5.0" --referer https://www.xiaohongshu.com/ \157 -o "NN_<title>.mp4" "$URL"158 ```1595. Back to the profile, repeat. Rebuild the card list each loop.160161**Step 3 — Verify.** See [Verify](#verify).162163### Route B gotchas164165- **CDP flakiness is normal.** `Runtime.evaluate` times out and the JS kernel resets (`js execution timed out; kernel reset, rerun your request`), sometimes several times running. Use `timeout_ms: 60000`, re-run `setupBrowserRuntime()` when `globalThis.agent?.browsers == null`, re-`claimTab`, retry the same step. **Do not conclude the capability is missing and switch methods** — the retry works. (If it never recovers, check invariant 7: another agent on your tab.)166- **Codex has extras this skill doesn't use**: `tab.capabilities.get("pageAssets")` → `list()/bundle()` can inventory `kind:"video"` assets and export them to a local directory; `downloadMedia({x,y})` triggers a real media download and `path()` returns the file; `clipboard.readText()` exists too. These are plausible shortcuts — **untested here**, so treat them as experiments, not the documented path.167168## Verify169170After the loop: count matches, none <10 KB, all `ISO Media`.171172```bash173cd <out-dir>174for f in *.mp4; do175 if ! file "$f" | grep -q 'ISO Media'; then echo "NOT_MP4: $f"; fi176done177ls -1 *.mp4 | wc -l; du -sh .178```179180If `ffprobe` is available, spot-check that durations match what the profile showed — that's how you catch truncated downloads.181182## Files in this skill183184- `SKILL.md` — this playbook.185- `scripts/01-load-and-count.js` — two parts: async scroll-to-bottom, then a **sync** counter.186- `scripts/02-locate-card.js` — **sync**; returns card `N`'s title-anchor center, with the masonry sanity check.187- `scripts/03-extract-and-copy.js` — **sync**; codec-agnostic stream pick + the clipboard copy button. **Route A only** (the button can't exist on Route B).188- `scripts/download.sh` — clipboard → `curl` with a CDN guard. **Route A only**; Route B curls the URL it already has.189190## Dependencies (explicit)191192- macOS (`curl`; `pbpaste` for Route A).193- A Chrome signed into 小红书, plus **one** of:194 - **Claude Code** with the Claude in Chrome extension connected, or195 - **Codex** with `chrome@openai-bundled` enabled and ChatGPT for Chrome installed in that profile.196197## Related skills198199- [`waitlist-farmer`](../waitlist-farmer/SKILL.md) — same "drive a real logged-in browser through a repetitive flow" muscle, different domain.