Xiaohongshu Trending Short-Video Titles
Purpose
Return the current batch of trending Xiaohongshu (RedNote, 小红书) feed cards from xiaohongshu.com/explore, with each card's 1-line title (displayTitle), content type (video vs image post), video duration in seconds, like count, author handle, note id, and canonical reader URL. Filter noteCard.type === "video" for the short-video subset. The same shape works for the algorithmic recommend feed (default) and for any of the ten category channels (fashion, food, beauty, fitness, etc.). Read-only — never likes, comments, follows, or posts.
When to Use
- A short-video pipeline that needs fresh trending titles + durations + like counts to seed downstream summarization, captioning, or trend-spotting.
- Daily / hourly polling of XHS hot content for marketing or content-research dashboards.
- A "what's hot on Xiaohongshu right now in {fashion|food|fitness|...}" agent query.
- Anywhere you would otherwise scrape the rendered explore page — the SSR
__INITIAL_STATE__blob beats a snapshot loop by ~50× and never needs the signed XHS API.
Workflow
The Xiaohongshu explore page is server-side rendered: every response embeds a full window.__INITIAL_STATE__ = {...} JSON dump whose state.feed.feeds[] array contains the 22–27 trending notes the homepage would have rendered. You do not need a real browser. One HTTP GET via Browserbase Fetch with a residential proxy returns the same data as opening the page in Chrome.
1. Fetch the explore page through a residential proxy
bb fetch "https://www.xiaohongshu.com/explore" \
--allow-redirects --proxies \
--output /tmp/xhs-explore.html
--proxies is mandatory. From a datacenter IP, Xiaohongshu returns a 10 KB anti-bot stub with no __INITIAL_STATE__. With residential, the response is ~580 KB and contains the full SSR feed. Status code is 200 in both cases — check the response body size or the presence of window.__INITIAL_STATE__, not the status, to detect the wall.
To scope to a category, append ?channel_id=<id>. The channel enum is published in the SSR payload itself (state.feed.channels.categories); copy below:
channel_id | Category (中文) | English label |
|---|---|---|
homefeed_recommend (default — no param) | 推荐 | Algorithmic recommend (mixed) |
homefeed.fashion_v3 | 穿搭 | Fashion |
homefeed.food_v3 | 美食 | Food |
homefeed.cosmetics_v3 | 彩妆 | Beauty / Makeup |
homefeed.movie_and_tv_v3 | 影视 | Movies & TV |
homefeed.career_v3 | 职场 | Career |
homefeed.love_v3 | 情感 | Relationships |
homefeed.household_product_v3 | 家居 | Home |
homefeed.gaming_v3 | 游戏 | Gaming |
homefeed.travel_v3 | 旅行 | Travel |
homefeed.fitness_v3 | 健身 | Fitness |
There are also two legacy/unlisted channels not in state.feed.channels.categories but still served: homefeed.video_feed and homefeed.video_v2_feed. They are not strict video filters — they return mixed content that simply leans heavier on video. Don't rely on them as a server-side video filter; filter client-side.
2. Extract __INITIAL_STATE__ and decode the feed
import fs from "node:fs";
const html = fs.readFileSync("/tmp/xhs-explore.html", "utf8");
const m = html.match(/window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?\})\s*<\/script>/);
// XHS emits `undefined` literals — replace before JSON.parse
const state = JSON.parse(m[1].replace(/:\s*undefined/g, ":null"));
const feeds = state.feed.feeds;
feeds is an array of { id, modelType, noteCard, xsecToken, trackId, ... }. Each noteCard has the fields you actually want:
| Field | Description |
|---|---|
noteCard.displayTitle | The 1-line title shown on the card. Can be very short (1–2 chars) or contain emoji — never strip non-ASCII. |
noteCard.type | "video" or "normal" (image post). Filter === "video" for the short-video subset. |
noteCard.video.capa.duration | Video length in seconds (integer). Present only when type === "video". Range observed: 7 s – 560 s. |
noteCard.interactInfo.likedCount | Pre-formatted Chinese number string: "5844", "2.9万" (29k), "10万+" (100k+). Don't try to parse to an int — keep the string. |
noteCard.user.nickName / user.nickname | Author display name. Both casings appear; prefer nickName, fall back to nickname. |
noteCard.user.userId | Author's user id. |
f.id | Note id (24-char hex). |
f.xsecToken | Per-request access token. Required to build the canonical URL — without it the reader page returns "笔记不见了" / 404. |
3. Construct the canonical reader URL
https://www.xiaohongshu.com/explore/{f.id}?xsec_token={f.xsecToken}&xsec_source=pc_feed
xsec_source=pc_feed is the source tag matching what the SSR page would set when you click through. Any other value (e.g. pc_search) will also work but the source attribution differs.
4. Filter and return
For the short-video use case:
const shortVideos = feeds
.filter(f => f.noteCard?.type === "video")
.map(f => ({
title: f.noteCard.displayTitle,
duration_sec: f.noteCard.video.capa.duration,
likes: f.noteCard.interactInfo.likedCount,
author: f.noteCard.user.nickName || f.noteCard.user.nickname,
note_id: f.id,
url: `https://www.xiaohongshu.com/explore/${f.id}?xsec_token=${f.xsecToken}&xsec_source=pc_feed`,
}));
A single request yields ~6–19 short videos depending on the channel (fitness_v3 is heaviest at ~70% video; fashion_v3 is lightest at ~25%). To collect more than one batch, re-fetch — every fresh GET returns a different batch (0% overlap between two consecutive default-channel fetches in the converged test). The feed is freshly resampled per request, not paginated by cursor.
5. Pagination beyond the SSR batch — only if you really need it
The XHS internal endpoint https://edith.xiaohongshu.com/api/sns/web/v1/homefeed accepts {cursor_score, num, category, refresh_type} POST bodies but is gated by an X-S HMAC signature and X-T timestamp computed in obfuscated browser JS. The signature rotates per build and there is no maintained open-source library that tracks it reliably. Don't try to forge the signature. If you need more than the SSR batch, the honest path is to drive a real browser (Browserbase remote, --advanced-stealth --proxies), navigate to /explore, scroll, and re-extract window.__INITIAL_STATE__.feed.feeds after each scroll-triggered append.
Browser fallback (only for pagination)
# Mandatory: residential proxy + advanced stealth, both flags.
SID=$(bb sessions create --keep-alive --proxies --advanced-stealth | jq -r '.id')
browse --connect "$SID" open "https://www.xiaohongshu.com/explore"
browse --connect "$SID" wait selector ".note-item"
# Read the first batch from the rendered page
browse --connect "$SID" get html body > page-1.html
# Trigger more loads
for i in 1 2 3 4; do
browse --connect "$SID" scroll 600 400 0 1500
browse --connect "$SID" wait timeout 1500
done
browse --connect "$SID" get html body > page-deep.html
# Same regex on the rendered DOM yields the deeper appended cards.
Deduplicate by f.id across batches.
Site-Specific Gotchas
--proxiesis required, full stop. Datacenter IPs receive a 10,352-byte HTML stub with no__INITIAL_STATE__, nofeeds[], and only aformula-runtimeJS bootstrap. Residential IPs receive ~579 KB with the full SSR payload. Both responses return HTTP 200, so don't gate on status code — gate on response body size (>100 KB) or onhtml.includes("__INITIAL_STATE__").- The page emits
undefinedliterals inside the JSON state, whichJSON.parserejects. Always runraw.replace(/:\s*undefined/g, ":null")(or equivalent) before parsing. The OpenSearch CSP-friendly value isnull. xsec_tokenis per-request and required for canonical URLs. Reusing a token from yesterday's fetch will 404 the reader page. Either keep the token alongside the note id, or re-fetch immediately before navigating.displayTitlemay be 1–2 characters or pure emoji ("练","📷课代表上线"). Don't strip non-ASCII, don't drop short titles — those are real cards, often the most viral.likedCountis a pre-formatted Chinese display string, not an integer:"5844","2.9万"(29,000),"10万+"(>100k). Decode formula:"X.Y万" = X.Y × 10000,"X万+" = >X × 10000. Keep the raw string in your output for fidelity; parse to a numeric only at sort/threshold time.- The legacy channels
homefeed.video_feedandhomefeed.video_v2_feedare NOT strict video filters, despite the name. They return mixed content (15 video + 12 image in one observed run forvideo_feed; 7 video + 15 image forvideo_v2_feed). Always filternoteCard.type === "video"client-side regardless of channel. - The feed is fresh-per-request, not cursor-paginated. Two consecutive GETs of
/explorereturned 23 and 27 cards with zero overlap byf.id. To collect a wider sample, loop the GET (with a short delay) and dedupe by id — don't try to find a?page=2param. None exist. - Don't try to forge the
X-S/X-Tsignature for the/api/sns/web/v1/homefeedPOST endpoint. The hash is computed by minified, regularly-rotated browser JS and there is no maintained signature lib. Confirmed dead-end during testing — only reachable from a real browser context after the page boots. - Note ids are 24-char hex (e.g.
640c6b480000000027011016). The first 8 hex chars encode the post creation timestamp (Unix epoch in seconds) —parseInt(id.slice(0,8), 16)gives the creation time, useful for filtering "trending in the last 24h". - CJK only. Almost all titles are Simplified Chinese; if your downstream pipeline expects English, plan to translate after extraction. The site has an English newsroom subdomain (
www.xiaohongshu.com/en/newsroom/) but no English explore feed. - No auth required for explore. Don't waste time threading cookies or login state for this skill — the SSR feed is available anonymously. Cookies only matter if you need personalized recommendations.
Expected Output
A JSON object with success, the channel queried, the timestamp of capture, and an array of short-video items. Each item carries the title, type, duration, like count, author, and a clickable canonical URL.
{
"success": true,
"channel_id": "homefeed_recommend",
"fetched_at": "2026-05-18T21:19:14Z",
"total_cards": 23,
"short_videos": [
{
"title": "救命🆘这里有一只芭蕾小熊哇~",
"type": "video",
"duration_sec": 19,
"likes": "3.4万",
"author": "饭一碗",
"note_id": "640c6b480000000027011016",
"url": "https://www.xiaohongshu.com/explore/640c6b480000000027011016?xsec_token=ABDqV1xAR3SEoDdoB40_yD04rQV8-A8MpNSgFQSdePSyc=&xsec_source=pc_feed"
},
{
"title": "姐妹试这个中分丸子新扎法技巧 巨好看!",
"type": "video",
"duration_sec": 81,
"likes": "4.8万",
"author": "漫妮Fiona",
"note_id": "63e9d57e0000000014024b16",
"url": "https://www.xiaohongshu.com/explore/63e9d57e0000000014024b16?xsec_token=...&xsec_source=pc_feed"
},
{
"title": "练",
"type": "video",
"duration_sec": 75,
"likes": "10万+",
"author": "白敬亭",
"note_id": "6481cf3b000000001300a7a6",
"url": "https://www.xiaohongshu.com/explore/6481cf3b000000001300a7a6?xsec_token=...&xsec_source=pc_feed"
}
]
}
Failure shape when the anti-bot wall fires (datacenter IP, no --proxies):
{
"success": false,
"reason": "antibot_stub",
"detail": "Response body is 10352 bytes with no window.__INITIAL_STATE__ block — residential proxy was not applied. Re-issue with `bb fetch --proxies`."
}
Empty-channel shape (none of the canonical channels return empty in practice, but be defensive):
{
"success": true,
"channel_id": "homefeed.gaming_v3",
"fetched_at": "2026-05-18T21:19:14Z",
"total_cards": 22,
"short_videos": []
}