Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

480 lines
19 KiB
JavaScript

/**
* Xiaohongshu Creator Note List — per-note metrics from the creator backend.
*
* In CDP mode we capture the real creator analytics API response so the list
* includes stable note ids and detail-page URLs. If that capture is unavailable,
* we fall back to the older interceptor and DOM parsing paths.
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/;
const METRIC_LINE_RE = /^\d+$/;
const VISIBILITY_LINE_RE = /可见$/;
const NOTE_ANALYZE_API_PATH = '/api/galaxy/creator/datacenter/note/analyze/list';
const NOTE_ANALYZE_PAGE_SIZE = 10;
const CAPTURE_POLL_ATTEMPTS = 20;
const CAPTURE_POLL_INTERVAL_S = 0.5;
const NOTE_DETAIL_PAGE_URL = 'https://creator.xiaohongshu.com/statistics/note-detail';
const NOTE_ID_HTML_RE = /"noteId":"([0-9a-f]{24})"/g;
function buildNoteDetailUrl(noteId) {
return noteId ? `${NOTE_DETAIL_PAGE_URL}?noteId=${encodeURIComponent(noteId)}` : '';
}
function formatPostTime(ts) {
if (!ts)
return '';
// XHS API timestamps are Beijing time (UTC+8)
const date = new Date(ts + 8 * 3600_000);
const pad = (value) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
}
export function parseCreatorNotesText(bodyText) {
const lines = bodyText
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const results = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
const dateMatch = lines[i].match(DATE_LINE_RE);
if (!dateMatch)
continue;
let titleIndex = i - 1;
while (titleIndex >= 0 && VISIBILITY_LINE_RE.test(lines[titleIndex]))
titleIndex--;
if (titleIndex < 0)
continue;
const title = lines[titleIndex];
const metrics = [];
let cursor = i + 1;
while (cursor < lines.length && METRIC_LINE_RE.test(lines[cursor]) && metrics.length < 5) {
metrics.push(parseInt(lines[cursor], 10));
cursor++;
}
if (metrics.length < 4)
continue;
const key = `${title}@@${dateMatch[1]}`;
if (seen.has(key))
continue;
seen.add(key);
results.push({
id: '',
title,
date: dateMatch[1],
views: metrics[0] ?? 0,
likes: metrics[2] ?? 0,
collects: metrics[3] ?? 0,
comments: metrics[1] ?? 0,
url: '',
});
i = cursor - 1;
}
return results;
}
export function parseCreatorNoteIdsFromHtml(bodyHtml) {
const ids = [];
const seen = new Set();
for (const match of bodyHtml.matchAll(NOTE_ID_HTML_RE)) {
const id = match[1];
if (!id || seen.has(id))
continue;
seen.add(id);
ids.push(id);
}
return ids;
}
function mapDomCards(cards) {
return cards.map((card) => ({
id: card.id,
title: card.title,
date: card.date,
views: card.metrics[0] ?? 0,
likes: card.metrics[2] ?? 0,
collects: card.metrics[3] ?? 0,
comments: card.metrics[1] ?? 0,
url: buildNoteDetailUrl(card.id),
}));
}
function mapAnalyzeItems(items) {
return (items ?? []).map((item) => ({
id: item.id ?? '',
title: item.title ?? '',
date: formatPostTime(item.post_time),
views: item.read_count ?? 0,
likes: item.like_count ?? 0,
collects: item.fav_count ?? 0,
comments: item.comment_count ?? 0,
url: buildNoteDetailUrl(item.id),
}));
}
function unwrapEvaluateResult(payload) {
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
// Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture
// since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406.
async function installXhsFetchCaptureHook(page) {
await page.evaluate(`(() => {
window.__xhsCapture = {};
if (window.__xhsCaptureInstalled) return;
window.__xhsCaptureInstalled = true;
const origFetch = window.fetch;
window.fetch = async function(...args) {
const resp = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
if (url.includes('/api/galaxy/')) {
resp.clone().text().then((body) => {
try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {}
}).catch(() => {});
}
} catch (_) {}
return resp;
};
const OrigXHR = window.XMLHttpRequest;
function HookedXHR() {
const xhr = new OrigXHR();
const origOpen = xhr.open;
let capturedUrl = '';
xhr.open = function(method, url, ...rest) {
capturedUrl = url;
return origOpen.call(this, method, url, ...rest);
};
xhr.addEventListener('load', () => {
try {
if (capturedUrl.includes('/api/galaxy/')) {
window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText };
}
} catch (_) {}
});
return xhr;
}
HookedXHR.prototype = OrigXHR.prototype;
for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
if (key in OrigXHR) HookedXHR[key] = OrigXHR[key];
}
window.XMLHttpRequest = HookedXHR;
})()`);
}
function parseCaptureMapPayload(raw) {
const payload = unwrapEvaluateResult(raw);
if (typeof payload === 'string') {
try {
return JSON.parse(payload);
}
catch {
return {};
}
}
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload;
}
return {};
}
function getAnalyzeListPageNumber(url) {
try {
const parsed = new URL(url, 'https://creator.xiaohongshu.com');
const pageNum = Number.parseInt(parsed.searchParams.get('page_num') || '', 10);
if (Number.isFinite(pageNum) && pageNum > 0)
return pageNum;
}
catch { }
const match = String(url || '').match(/[?&]page_num=(\d+)/);
const pageNum = Number.parseInt(match?.[1] || '', 10);
return Number.isFinite(pageNum) && pageNum > 0 ? pageNum : Number.MAX_SAFE_INTEGER;
}
function harvestAnalyzeListCaptures(captureMap) {
const items = [];
const seen = new Set();
let total = 0;
const entries = Object.entries(captureMap)
.filter(([url]) => url.includes('/note/analyze/list'))
.sort(([a], [b]) => getAnalyzeListPageNumber(a) - getAnalyzeListPageNumber(b));
for (const [url, capture] of entries) {
if (!capture?.ok) continue;
try {
const json = JSON.parse(capture.body);
const data = json?.data ?? {};
if (typeof data.total === 'number' && data.total > total) total = data.total;
for (const note of data.note_infos ?? []) {
if (!note?.id || seen.has(note.id)) continue;
seen.add(note.id);
items.push(note);
}
}
catch { }
}
return { items, total };
}
function isAnalyzeCaptureComplete(items, total, limit) {
if (total <= 0)
return true;
return items.length >= Math.min(total, limit);
}
async function pollCaptureMap(page) {
let captureMap = {};
for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) {
await page.wait(CAPTURE_POLL_INTERVAL_S);
const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
captureMap = parseCaptureMapPayload(raw);
if (Object.keys(captureMap).some((url) => url.includes('/note/analyze/list'))) break;
}
return captureMap;
}
// Fresh-published notes return title: "" from /note/analyze/list. Scrape the
// /new/note-manager card DOM (under its "全部笔记" tab, which surfaces every
// state including 审核中) so the rows the API leaves empty still get the
// derived title that the note-manager UI shows.
async function fetchNoteManagerTitleMap(page, neededCount) {
const map = new Map();
const scrapeCards = async () => {
const cards = unwrapEvaluateResult(await page.evaluate(`() => {
const noteIdRe = /"noteId":"([0-9a-f]{24})"/;
return Array.from(document.querySelectorAll('div.note[data-impression], div.note')).map((card) => {
const impression = card.getAttribute('data-impression') || '';
const id = impression.match(noteIdRe)?.[1] || '';
const title = (card.querySelector('.title, .raw')?.innerText || '').trim();
return { id, title };
}).filter((entry) => entry.id && entry.title);
}`));
for (const card of Array.isArray(cards) ? cards : []) {
if (!map.has(card.id)) map.set(card.id, card.title);
}
};
// Scroll the first scrollable ancestor of a note card to the bottom so
// the list lazy-loads the rest of its rows. Page-level scrollTo does not
// work because the cards live inside an inner overflow-auto container.
const scrollInnerListToBottom = async () => {
return unwrapEvaluateResult(await page.evaluate(`(() => {
const firstCard = document.querySelector('div.note[data-impression]');
let el = firstCard && firstCard.parentElement;
while (el) {
const s = window.getComputedStyle(el);
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
el.scrollTop = el.scrollHeight;
return true;
}
el = el.parentElement;
}
return false;
})()`));
};
try {
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
// Poll for the initial hydration batch and then scroll the inner list
// container to surface the rest of the rows. The all-notes tab is the
// default state so no tab click is needed here.
for (let i = 0; i < 12; i++) {
await page.wait(1);
await scrapeCards();
if (map.size >= neededCount) return map;
await scrollInnerListToBottom();
}
return map;
}
catch {
return map;
}
}
async function fetchCreatorNotesByCapture(page, limit) {
// Land on dashboard root before installing the hook so the data-analysis
// SPA navigation fires page_num=1's signed request UNDER the hook.
await page.goto('https://creator.xiaohongshu.com/statistics');
await installXhsFetchCaptureHook(page);
await page.evaluate(`(() => {
history.pushState({}, '', '/statistics/data-analysis?source=official');
window.dispatchEvent(new PopStateEvent('popstate'));
})()`);
let captureMap = await pollCaptureMap(page);
let { items, total } = harvestAnalyzeListCaptures(captureMap);
if (items.length === 0) return [];
const totalPages = total > 0 ? Math.ceil(total / NOTE_ANALYZE_PAGE_SIZE) : 1;
const neededPages = Math.min(totalPages, Math.ceil(limit / NOTE_ANALYZE_PAGE_SIZE));
for (let pageNum = 2; pageNum <= neededPages && items.length < limit; pageNum++) {
const clicked = unwrapEvaluateResult(await page.evaluate(`(() => {
const target = String(${pageNum});
// .d-pagination-page renders the page number doubled (a visible span +
// an accessibility span), so textContent for page 2 reads "22". Match
// both the raw digit and the doubled form to tolerate either render.
const btns = Array.from(document.querySelectorAll('.d-pagination-page'));
const match = btns.find((btn) => {
const text = (btn.textContent || '').trim();
return text === target || text === target + target;
});
if (match) { match.click(); return true; }
return false;
})()`));
if (!clicked) break;
const before = items.length;
let advanced = false;
for (let attempt = 0; attempt < CAPTURE_POLL_ATTEMPTS; attempt++) {
await page.wait(CAPTURE_POLL_INTERVAL_S);
const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
captureMap = parseCaptureMapPayload(raw);
const harvested = harvestAnalyzeListCaptures(captureMap);
if (harvested.items.length > before) {
items = harvested.items;
total = Math.max(total, harvested.total);
advanced = true;
break;
}
}
if (!advanced) break;
}
if (!isAnalyzeCaptureComplete(items, total, limit)) {
throw new CommandExecutionError(`xiaohongshu creator-notes: captured ${items.length} of ${Math.min(total, limit)} expected analyze rows; refusing partial results`);
}
const notes = mapAnalyzeItems(items).slice(0, limit);
const missingTitles = notes.filter((note) => !note.title).length;
if (missingTitles > 0) {
const titleMap = await fetchNoteManagerTitleMap(page, notes.length);
for (const note of notes) {
if (!note.title && note.id && titleMap.has(note.id)) {
note.title = titleMap.get(note.id);
}
}
}
return notes;
}
async function fetchCreatorNotesByApi(page, limit) {
const pageSize = Math.min(Math.max(limit, 10), 20);
const maxPages = Math.max(1, Math.ceil(limit / pageSize));
const notes = [];
await page.goto(`https://creator.xiaohongshu.com/statistics/data-analysis?type=0&page_size=${pageSize}&page_num=1`);
for (let pageNum = 1; pageNum <= maxPages && notes.length < limit; pageNum++) {
const apiPath = `${NOTE_ANALYZE_API_PATH}?type=0&page_size=${pageSize}&page_num=${pageNum}`;
const fetched = await page.evaluate(`
async () => {
try {
const resp = await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
if (!resp.ok) return { error: 'HTTP ' + resp.status };
return await resp.json();
} catch (e) {
return { error: e?.message ?? String(e) };
}
}
`);
let items = fetched?.data?.note_infos ?? [];
if (!items.length) {
await page.installInterceptor(NOTE_ANALYZE_API_PATH);
await page.evaluate(`
async () => {
try {
await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
} catch {}
return true;
}
`);
await page.wait(1);
const intercepted = await page.getInterceptedRequests();
const data = intercepted.find((entry) => Array.isArray(entry?.data?.note_infos));
items = data?.data?.note_infos ?? [];
}
if (!items.length)
break;
notes.push(...mapAnalyzeItems(items));
if (items.length < pageSize)
break;
}
return notes.slice(0, limit);
}
export async function fetchCreatorNotes(page, limit) {
let notes = [];
try {
notes = await fetchCreatorNotesByCapture(page, limit);
}
catch (error) {
if (error instanceof CommandExecutionError) throw error;
}
if (notes.length === 0) {
notes = await fetchCreatorNotesByApi(page, limit);
}
if (notes.length === 0) {
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
const maxPageDowns = Math.max(0, Math.ceil(limit / 10) + 1);
for (let i = 0; i <= maxPageDowns; i++) {
const domCards = await page.evaluate(`() => {
const noteIdRe = /"noteId":"([0-9a-f]{24})"/;
return Array.from(document.querySelectorAll('div.note[data-impression], div.note')).map((card) => {
const impression = card.getAttribute('data-impression') || '';
const id = impression.match(noteIdRe)?.[1] || '';
const title = (card.querySelector('.title, .raw')?.innerText || '').trim();
const dateText = (card.querySelector('.time_status, .time')?.innerText || '').trim();
const date = dateText.replace(/^发布于\\s*/, '');
const metrics = Array.from(card.querySelectorAll('.icon_list .icon'))
.map((el) => parseInt((el.innerText || '').trim(), 10))
.filter((value) => Number.isFinite(value));
return { id, title, date, metrics };
});
}`);
const parsedDomNotes = mapDomCards(Array.isArray(domCards) ? domCards : []).filter((note) => note.title && note.date);
if (parsedDomNotes.length > 0) {
notes = parsedDomNotes;
}
if (notes.length >= limit || (notes.length > 0 && i === 0))
break;
const body = await page.evaluate('() => ({ text: document.body.innerText, html: document.body.innerHTML })');
const bodyText = typeof body?.text === 'string' ? body.text : '';
const bodyHtml = typeof body?.html === 'string' ? body.html : '';
const parsedNotes = parseCreatorNotesText(bodyText);
const noteIds = parseCreatorNoteIdsFromHtml(bodyHtml);
notes = parsedNotes.map((note, index) => {
const id = noteIds[index] ?? '';
return {
...note,
id,
url: buildNoteDetailUrl(id),
};
});
if (notes.length >= limit || i === maxPageDowns)
break;
await page.pressKey('PageDown');
await page.wait(1);
}
}
return notes.slice(0, limit);
}
cli({
site: 'xiaohongshu',
name: 'creator-notes',
access: 'read',
description: '小红书创作者笔记列表 + 每篇数据 (标题/日期/观看/点赞/收藏/评论)',
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of notes to return' },
],
columns: ['rank', 'id', 'title', 'date', 'views', 'likes', 'collects', 'comments', 'url'],
func: async (page, kwargs) => {
const limit = kwargs.limit || 20;
const notes = await fetchCreatorNotes(page, limit);
if (!Array.isArray(notes) || notes.length === 0) {
throw new EmptyResultError('xiaohongshu creator-notes', 'No notes found. Ensure you are logged into creator.xiaohongshu.com and the account has published notes.');
}
return notes
.slice(0, limit)
.map((n, i) => ({
rank: i + 1,
id: n.id,
title: n.title,
date: n.date,
views: n.views,
likes: n.likes,
collects: n.collects,
comments: n.comments,
url: n.url,
}));
},
});
export const __test__ = {
harvestAnalyzeListCaptures,
isAnalyzeCaptureComplete,
parseCaptureMapPayload,
unwrapEvaluateResult,
};