Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14815,7 +14815,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/comments.js",
"sourceFile": "xiaohongshu/comments.js"
"sourceFile": "xiaohongshu/comments.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand All @@ -14841,7 +14842,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/creator-note-detail.js",
"sourceFile": "xiaohongshu/creator-note-detail.js"
"sourceFile": "xiaohongshu/creator-note-detail.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -14872,7 +14874,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/creator-notes-summary.js",
"sourceFile": "xiaohongshu/creator-notes-summary.js"
"sourceFile": "xiaohongshu/creator-notes-summary.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -14908,7 +14911,8 @@
"timeout": 180,
"type": "js",
"modulePath": "xiaohongshu/creator-notes-summary.js",
"sourceFile": "xiaohongshu/creator-notes-summary.js"
"sourceFile": "xiaohongshu/creator-notes-summary.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand All @@ -14924,7 +14928,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/creator-profile.js",
"sourceFile": "xiaohongshu/creator-profile.js"
"sourceFile": "xiaohongshu/creator-profile.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -14953,7 +14958,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/creator-stats.js",
"sourceFile": "xiaohongshu/creator-stats.js"
"sourceFile": "xiaohongshu/creator-stats.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -14986,7 +14992,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/download.js",
"sourceFile": "xiaohongshu/download.js"
"sourceFile": "xiaohongshu/download.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -15037,7 +15044,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/note.js",
"sourceFile": "xiaohongshu/note.js"
"sourceFile": "xiaohongshu/note.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -15121,7 +15129,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/publish.js",
"sourceFile": "xiaohongshu/publish.js"
"sourceFile": "xiaohongshu/publish.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -15156,7 +15165,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/search.js",
"sourceFile": "xiaohongshu/search.js"
"sourceFile": "xiaohongshu/search.js",
"navigateBefore": false
},
{
"site": "xiaohongshu",
Expand Down Expand Up @@ -15190,7 +15200,8 @@
],
"type": "js",
"modulePath": "xiaohongshu/user.js",
"sourceFile": "xiaohongshu/user.js"
"sourceFile": "xiaohongshu/user.js",
"navigateBefore": false
},
{
"site": "xiaoyuzhou",
Expand Down Expand Up @@ -17029,4 +17040,4 @@
"modulePath": "zsxq/topics.js",
"sourceFile": "zsxq/topics.js"
}
]
]
24 changes: 18 additions & 6 deletions clis/xiaohongshu/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* the --with-replies flag.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
function parseCommentLimit(raw, fallback = 20) {
const n = Number(raw);
Expand All @@ -20,6 +20,7 @@ cli({
description: '获取小红书笔记评论(支持楼中楼子回复)',
domain: 'www.xiaohongshu.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
Expand All @@ -32,21 +33,27 @@ cli({
const raw = String(kwargs['note-id']);
const noteId = parseNoteId(raw);
await page.goto(buildNoteUrl(raw));
await page.wait(3);
await page.wait({ time: 2 + Math.random() * 3 });
const data = await page.evaluate(`
(async () => {
const wait = (ms) => new Promise(r => setTimeout(r, ms))
const withReplies = ${withReplies}

// Check login state
const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
const bodyText = document.body?.innerText || ''
const loginWall = /登录后查看|请登录/.test(bodyText)
const securityBlock = /安全限制|访问链接异常/.test(bodyText)
|| /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)

// Scroll the note container to trigger comment loading
const scroller = document.querySelector('.note-scroller') || document.querySelector('.container')
if (scroller) {
for (let i = 0; i < 3; i++) {
const beforeCount = scroller.querySelectorAll('.parent-comment').length
scroller.scrollTo(0, scroller.scrollHeight)
await wait(1000)
await wait(800 + Math.random() * 1200)
const afterCount = scroller.querySelectorAll('.parent-comment').length
if (afterCount <= beforeCount) break
}
}

Expand All @@ -72,7 +79,7 @@ cli({
const text = clean(el)
el.click()
clickedTexts.add(text)
await wait(300)
await wait(200 + Math.random() * 300)
}
}
}
Expand Down Expand Up @@ -105,12 +112,17 @@ cli({
}
}

return { loginWall, results }
return { pageUrl: location.href, securityBlock, loginWall, results }
})()
`);
if (!data || typeof data !== 'object') {
throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response');
}
if (data.securityBlock) {
throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
? 'The page may be temporarily restricted. Try again later or from a different session.'
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
}
if (data.loginWall) {
throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
}
Expand Down
36 changes: 36 additions & 0 deletions clis/xiaohongshu/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,46 @@ describe('xiaohongshu comments', () => {
const page = createPageMock({ loginWall: true, results: [] });
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
});
it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the comments page', async () => {
const page = createPageMock({
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
securityBlock: true,
loginWall: false,
results: [],
});
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toMatchObject({
code: 'SECURITY_BLOCK',
hint: expect.stringContaining('xsec_token'),
});
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
});
it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
const page = createPageMock({
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
securityBlock: true,
loginWall: false,
results: [],
});
await expect(command.func(page, {
'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
limit: 5,
})).rejects.toMatchObject({
code: 'SECURITY_BLOCK',
hint: expect.stringContaining('Try again later'),
});
});
it('returns empty array when no comments are found', async () => {
const page = createPageMock({ loginWall: false, results: [] });
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
});
it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
const page = createPageMock({ loginWall: false, results: [] });
await command.func(page, { 'note-id': 'abc123', limit: 5 });
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
expect(script).toContain('if (afterCount <= beforeCount) break');
});
it('respects the limit for top-level comments', async () => {
const manyComments = Array.from({ length: 10 }, (_, i) => ({
author: `User${i}`,
Expand Down
2 changes: 2 additions & 0 deletions clis/xiaohongshu/creator-note-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ async function captureNoteDetailPayload(page, noteId) {
let captured = 0;
// Try to fetch each API endpoint through the page context (uses the browser's cookies)
for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
await page.wait({ time: 0.5 + Math.random() });
const apiUrl = `${suffix}?note_id=${noteId}`;
try {
const data = await page.evaluate(`
Expand Down Expand Up @@ -325,6 +326,7 @@ cli({
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'note-id', positional: true, type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
],
Expand Down
32 changes: 32 additions & 0 deletions clis/xiaohongshu/creator-note-detail.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,36 @@ describe('xiaohongshu creator-note-detail', () => {
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
]);
});
it('waits between creator detail API fetches to avoid burst traffic', async () => {
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
const domData = {
title: '示例笔记',
infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
sections: [
{
title: '基础数据',
metrics: [
{ label: '曝光数', value: '100', extra: '粉丝占比 10%' },
{ label: '观看数', value: '50', extra: '粉丝占比 20%' },
{ label: '封面点击率', value: '12%', extra: '粉丝 11%' },
{ label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
{ label: '涨粉数', value: '2', extra: '' },
],
},
{
title: '互动数据',
metrics: [
{ label: '点赞数', value: '8', extra: '粉丝占比 25%' },
{ label: '评论数', value: '1', extra: '粉丝占比 0%' },
{ label: '收藏数', value: '3', extra: '粉丝占比 50%' },
{ label: '分享数', value: '0', extra: '粉丝占比 0%' },
],
},
],
};
const page = createPageMock([domData, null, null, null, null]);
await cmd.func(page, { 'note-id': 'demo-note-id' });
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
});
});
4 changes: 4 additions & 0 deletions clis/xiaohongshu/creator-notes-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ cli({
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
],
Expand All @@ -63,6 +64,9 @@ cli({
}
const results = [];
for (const [index, note] of notes.entries()) {
if (index > 0) {
await page.wait({ time: 1 + Math.random() * 2 });
}
if (!note.id) {
results.push({
rank: index + 1,
Expand Down
40 changes: 39 additions & 1 deletion clis/xiaohongshu/creator-notes-summary.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { summarizeCreatorNote } from './creator-notes-summary.js';
import { getRegistry } from '@jackwener/opencli/registry';
import * as creatorNotesModule from './creator-notes.js';
import * as creatorDetailModule from './creator-note-detail.js';
import './creator-notes-summary.js';
describe('xiaohongshu creator-notes-summary', () => {
it('summarizes note list row and detail rows into one compact row', () => {
Expand Down Expand Up @@ -46,4 +49,39 @@ describe('xiaohongshu creator-notes-summary', () => {
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
});
});
it('waits between note detail fetches after the first note', async () => {
const cmd = getRegistry().get('xiaohongshu/creator-notes-summary');
const page = {
wait: vi.fn().mockResolvedValue(undefined),
};
vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([
{
id: 'aaaaaaaaaaaaaaaaaaaaaaaa',
title: 'n1',
date: '2026年03月18日 20:01',
views: 1,
likes: 1,
collects: 1,
comments: 1,
url: 'u1',
},
{
id: 'bbbbbbbbbbbbbbbbbbbbbbbb',
title: 'n2',
date: '2026年03月19日 20:01',
views: 2,
likes: 2,
collects: 2,
comments: 2,
url: 'u2',
},
]);
vi.spyOn(creatorDetailModule, 'fetchCreatorNoteDetailRows').mockResolvedValue([
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
{ section: '基础数据', metric: '观看数', value: '1', extra: '' },
]);
await cmd.func(page, { limit: 2 });
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
expect(page.wait.mock.calls).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions clis/xiaohongshu/creator-notes.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ cli({
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of notes to return' },
],
Expand Down
1 change: 1 addition & 0 deletions clis/xiaohongshu/creator-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ cli({
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [],
columns: ['field', 'value'],
func: async (page, _kwargs) => {
Expand Down
1 change: 1 addition & 0 deletions clis/xiaohongshu/creator-stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ cli({
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{
name: 'period',
Expand Down
Loading