Install
$ agentstack add skill-yijiaduan-download-xhs-videos-download-xhs-videos ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
download-xhs-videos — batch-download a 小红书 creator's videos
Xiaohongshu (RedNote) fights scrapers hard, and the two "obvious" approaches both fail: yt-dlp support is flaky, and a plain headless scraper hits signed-request (x-s/x-t) walls. The path that actually works is driving the logged-in browser through the Claude-in-Chrome extension, but even that has four non-obvious traps that will silently eat your downloads. This skill encodes the route that survives all four.
The one-line shape of the working pipeline:
> Real mouse-click to open each note → read the video URL out of the page's __INITIAL_STATE__ → hand the URL to curl via the clipboard → download outside the browser. Never let the browser do the download; never let the video URL pass through the agent's own context.
Before you start — preconditions
This skill is macOS + Claude-in-Chrome only (it relies on pbpaste and on the extension's trusted clicks). Confirm all of these or stop and ask:
- The Claude-in-Chrome extension is connected to this session (
list_connected_browsersreturns a device; if empty,switch_browserand have the user click Connect). - The user is logged into their 小红书 account in that Chrome.
- You have the creator's profile URL (the
…/user/profile/?xsec_token=…link). The token in it is what authorizes the profile load. curlandpbpasteexist (they do, on macOS).- A target folder, default
~/Documents/xhs-/.
Ethics gate — do this, don't skip it
Open the profile and read the creator's bio first. Many creators write 「原创作品,禁止搬运和商用」 (original work, no reposting/commercial use). 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.
Why the naive approaches fail (read this or you'll waste an hour)
These are the landmines, in the order you'll hit them:
yt-dlp/ direct API scraping — XHS web requests need a signedx-s/x-theader generated by their obfuscated JS. Replicating it is fragile and breaks often. Don't go down this road; drive the real browser instead.
- **The SPA only opens notes on a real card click.** Setting
location.hrefto a note URL, or building your own `and clicking it, gets **bounced back to/explore** by XHS's router.mcp navigateto a note URL bounces too. Only a genuine click on the actual feed card (via the extension'scomputer left_click`) triggers the in-app navigation that loads the note — with its video stream.
- 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, critically, the extension's synthetic clicks don't grant download activation either (they fire DOMclickhandlers, soexecCommand('copy')works, but the download is dropped with no error and no file). Verify this yourself once if you doubt it: adata:text download won't land either. Conclusion: do not download through the browser at all. Download withcurl.
- The agent harness blocks query-string data (the
xsec_token) from entering your context. If your in-page JS returns the video URL to you, the tool result is[BLOCKED: Cookie/query string data]. So you can't read the URL, build acurlcommand with it, and run it — the URL would pass through your context. Route the URL clipboard →curlso it never touches your context: in-page JS writes the URL to the clipboard on a trusted click; the shell reads it with$(pbpaste)and never prints it.
Two more that bite during the loop:
- Don't
fetch()the video inside the page. Chrome caps ~6 connections per host; a few hung/leaked fetches to the CDN exhaust the pool and every later fetch hangs forever (you'll see the JS promise staypendingand CDP time out).curlsidesteps this entirely.
- The profile grid is a masonry layout — DOM order ≠ visual columns. Don't compute click coordinates from a column guess.
scrollIntoView({block:'center'})the target card, then read itsgetBoundingClientRect()and click the returned center.
- The CDN URL is
http://, nothttps://, and needs aReferer.curlit with-H "Referer: https://www.xiaohongshu.com/"and a normal User-Agent.
The 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, and get_page_text to read the bio (ethics gate) and eyeball the note titles.
Step 1 — Load every note and count
Scroll the profile to the bottom in-page until the note count stabilizes, then count distinct note ids. Note ids appear in anchors as /user/profile//?xsec_token=…. See scripts/01-load-and-count.js. Report the real total to the user (a "video creator" may have far fewer or more than they think; "first 50" might be "all 30").
Step 2 — Per-note loop (3 calls per note)
For note index N (0-based), in DOM order:
Call A — locate (one browser_batch): navigate back to the profile, then run JS that rebuilds the ordered card list, scrollIntoView card N, and returns its screenshot-space center coords. See scripts/02-locate-card.js. (Scale viewport coords to screenshot pixels — the script does this.)
Call B — open + extract + copy (one browser_batch):
computer left_clickthe coords from Call A → opens the note (real click, so the SPA loads it).javascript_toolrunsscripts/03-extract-and-copy.js: it polls__INITIAL_STATE__.note.noteDetailMap[].note.video.media.stream, picks the best codec (h264→h265→av1→h266), takesmasterUrl(fallbackbackupUrls[0]), stashes it onwindow.__urlForCopy, and builds a fixed-position copy button whose click copies that URL to the clipboard viaexecCommand('copy').computer left_click [150,155]— a trusted click on the copy button → URL is now on the clipboard.
Call C — download (Bash):
cd ~/Documents/xhs-
URL="$(pbpaste)"
case "$URL" in http*://*xhscdn.com/*) ;; *) echo "BAD clipboard"; exit 1;; esac
curl -sS -L -A "Mozilla/5.0" -H "Referer: https://www.xiaohongshu.com/" \
-o "NN_.mp4" "$URL"
file "NN_.mp4" | grep -q 'ISO Media' || echo "NOT_MP4"
The case guard catches the rare "no-video" note (clipboard wouldn't be a CDN url). The URL is never echoed, so it never enters your context.
Name files NN_.mp4 using the titles you already read in Step 0/1 — you do not need to extract the title per note.
Step 3 — Verify
After the loop, ls/file every .mp4: count matches the total, none are { const ta = document.createElement('textarea'); ta.value = window.__urlForCopy; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); });
## Things that will bite you
- **Navigating to a note URL (any way except a real card click) bounces to `/explore`.** Re-open from the profile by clicking the card.
- **A returned URL containing `xsec_token` gets `[BLOCKED]`.** Only ever move the URL via the clipboard; never return it from JS, never print it in a shell command.
- **Browser downloads silently vanish** when "Ask where to save each file" is on (pending Save dialog the extension can't touch) *and* when it's off (no user-activation). Stop fighting it — use `curl`.
- **In-page `fetch()` of the video hangs after a few notes** (connection-pool exhaustion). `curl` only.
- **Masonry layout**: trust `getBoundingClientRect()`, not column math. The card vertical center lands near viewport-center after `scrollIntoView({block:'center'})`.
- **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 refs/ids from a prior `read_page` are stale. Rebuild the card list each loop (the locate script does).
- **Quality**: `masterUrl` is the highest stream of the chosen codec; XHS source bitrate is modest, so some clips being 1–2 MB is normal, not a bug.
## Files in this skill
- `SKILL.md` — this playbook.
- `scripts/01-load-and-count.js` — scroll the profile, count distinct video notes.
- `scripts/02-locate-card.js` — rebuild ordered card list, scroll to card N, return click coords.
- `scripts/03-extract-and-copy.js` — read the note's video URL, build the clipboard-copy button.
- `scripts/download.sh` — the clipboard→`curl` download with the CDN guard.
## Dependencies (explicit)
- Claude-in-Chrome extension connected, signed into 小红书.
- macOS (`pbpaste`, `curl`).
- The agent's browser MCP tools: `navigate`, `tabs_context_mcp`, `javascript_tool`, `computer` (clicks), `browser_batch`, `read_page`/`get_page_text`.
## Related skills
- [`waitlist-farmer`](../waitlist-farmer/SKILL.md) — same "drive a real logged-in browser through a repetitive flow" muscle, different domain.
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [YijiaDuan](https://github.com/YijiaDuan)
- **Source:** [YijiaDuan/download-xhs-videos](https://github.com/YijiaDuan/download-xhs-videos)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.