From 72b930a03f84bf454eb0208d202bf6cd402a4674 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 12 Apr 2026 22:09:13 +0800 Subject: [PATCH] fix(xiaohongshu): harden anti-detection flows --- cli-manifest.json | 35 ++++++++++------ clis/xiaohongshu/comments.js | 24 ++++++++--- clis/xiaohongshu/comments.test.js | 36 +++++++++++++++++ clis/xiaohongshu/creator-note-detail.js | 2 + clis/xiaohongshu/creator-note-detail.test.js | 32 +++++++++++++++ clis/xiaohongshu/creator-notes-summary.js | 4 ++ .../xiaohongshu/creator-notes-summary.test.js | 40 ++++++++++++++++++- clis/xiaohongshu/creator-notes.js | 1 + clis/xiaohongshu/creator-profile.js | 1 + clis/xiaohongshu/creator-stats.js | 1 + clis/xiaohongshu/download.js | 12 ++++++ clis/xiaohongshu/download.test.js | 30 ++++++++++++++ clis/xiaohongshu/navigation.test.js | 34 ++++++++++++++++ clis/xiaohongshu/note.js | 19 ++++++--- clis/xiaohongshu/note.test.js | 28 +++++++++++++ clis/xiaohongshu/publish.js | 1 + clis/xiaohongshu/search.js | 1 + clis/xiaohongshu/user.js | 1 + 18 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 clis/xiaohongshu/navigation.test.js diff --git a/cli-manifest.json b/cli-manifest.json index fd8826ad4..4c3293ca1 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -14815,7 +14815,8 @@ ], "type": "js", "modulePath": "xiaohongshu/comments.js", - "sourceFile": "xiaohongshu/comments.js" + "sourceFile": "xiaohongshu/comments.js", + "navigateBefore": false }, { "site": "xiaohongshu", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -14986,7 +14992,8 @@ ], "type": "js", "modulePath": "xiaohongshu/download.js", - "sourceFile": "xiaohongshu/download.js" + "sourceFile": "xiaohongshu/download.js", + "navigateBefore": false }, { "site": "xiaohongshu", @@ -15037,7 +15044,8 @@ ], "type": "js", "modulePath": "xiaohongshu/note.js", - "sourceFile": "xiaohongshu/note.js" + "sourceFile": "xiaohongshu/note.js", + "navigateBefore": false }, { "site": "xiaohongshu", @@ -15121,7 +15129,8 @@ ], "type": "js", "modulePath": "xiaohongshu/publish.js", - "sourceFile": "xiaohongshu/publish.js" + "sourceFile": "xiaohongshu/publish.js", + "navigateBefore": false }, { "site": "xiaohongshu", @@ -15156,7 +15165,8 @@ ], "type": "js", "modulePath": "xiaohongshu/search.js", - "sourceFile": "xiaohongshu/search.js" + "sourceFile": "xiaohongshu/search.js", + "navigateBefore": false }, { "site": "xiaohongshu", @@ -15190,7 +15200,8 @@ ], "type": "js", "modulePath": "xiaohongshu/user.js", - "sourceFile": "xiaohongshu/user.js" + "sourceFile": "xiaohongshu/user.js", + "navigateBefore": false }, { "site": "xiaoyuzhou", @@ -17029,4 +17040,4 @@ "modulePath": "zsxq/topics.js", "sourceFile": "zsxq/topics.js" } -] \ No newline at end of file +] diff --git a/clis/xiaohongshu/comments.js b/clis/xiaohongshu/comments.js index 00080669c..f449f1e3a 100644 --- a/clis/xiaohongshu/comments.js +++ b/clis/xiaohongshu/comments.js @@ -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); @@ -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)' }, @@ -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 } } @@ -72,7 +79,7 @@ cli({ const text = clean(el) el.click() clickedTexts.add(text) - await wait(300) + await wait(200 + Math.random() * 300) } } } @@ -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'); } diff --git a/clis/xiaohongshu/comments.test.js b/clis/xiaohongshu/comments.test.js index 43fb4190c..3ce119e78 100644 --- a/clis/xiaohongshu/comments.test.js +++ b/clis/xiaohongshu/comments.test.js @@ -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}`, diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index 60f1cd99b..b1c7dbbb2 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -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(` @@ -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)' }, ], diff --git a/clis/xiaohongshu/creator-note-detail.test.js b/clis/xiaohongshu/creator-note-detail.test.js index 2ecaa6d3e..22e8ccf89 100644 --- a/clis/xiaohongshu/creator-note-detail.test.js +++ b/clis/xiaohongshu/creator-note-detail.test.js @@ -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); + }); }); diff --git a/clis/xiaohongshu/creator-notes-summary.js b/clis/xiaohongshu/creator-notes-summary.js index 380b9ce35..2517fac2b 100644 --- a/clis/xiaohongshu/creator-notes-summary.js +++ b/clis/xiaohongshu/creator-notes-summary.js @@ -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' }, ], @@ -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, diff --git a/clis/xiaohongshu/creator-notes-summary.test.js b/clis/xiaohongshu/creator-notes-summary.test.js index 7968edf64..3e39eeddc 100644 --- a/clis/xiaohongshu/creator-notes-summary.test.js +++ b/clis/xiaohongshu/creator-notes-summary.test.js @@ -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', () => { @@ -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); + }); }); diff --git a/clis/xiaohongshu/creator-notes.js b/clis/xiaohongshu/creator-notes.js index 79200de1f..69c9dd957 100644 --- a/clis/xiaohongshu/creator-notes.js +++ b/clis/xiaohongshu/creator-notes.js @@ -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' }, ], diff --git a/clis/xiaohongshu/creator-profile.js b/clis/xiaohongshu/creator-profile.js index 56dee2a43..acf8d7adb 100644 --- a/clis/xiaohongshu/creator-profile.js +++ b/clis/xiaohongshu/creator-profile.js @@ -15,6 +15,7 @@ cli({ domain: 'creator.xiaohongshu.com', strategy: Strategy.COOKIE, browser: true, + navigateBefore: false, args: [], columns: ['field', 'value'], func: async (page, _kwargs) => { diff --git a/clis/xiaohongshu/creator-stats.js b/clis/xiaohongshu/creator-stats.js index 6f1f6802a..709501b7d 100644 --- a/clis/xiaohongshu/creator-stats.js +++ b/clis/xiaohongshu/creator-stats.js @@ -15,6 +15,7 @@ cli({ domain: 'creator.xiaohongshu.com', strategy: Strategy.COOKIE, browser: true, + navigateBefore: false, args: [ { name: 'period', diff --git a/clis/xiaohongshu/download.js b/clis/xiaohongshu/download.js index 509a44880..f0cb87e33 100644 --- a/clis/xiaohongshu/download.js +++ b/clis/xiaohongshu/download.js @@ -10,6 +10,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; import { formatCookieHeader } from '@jackwener/opencli/download'; import { downloadMedia } from '@jackwener/opencli/download/media-download'; +import { CliError } from '@jackwener/opencli/errors'; import { buildNoteUrl, parseNoteId } from './note-helpers.js'; cli({ site: 'xiaohongshu', @@ -17,6 +18,7 @@ cli({ description: '下载小红书笔记中的图片和视频', domain: 'www.xiaohongshu.com', strategy: Strategy.COOKIE, + navigateBefore: false, args: [ { name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' }, { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' }, @@ -27,11 +29,16 @@ cli({ const output = kwargs.output; const noteId = parseNoteId(rawInput); await page.goto(buildNoteUrl(rawInput)); + await page.wait({ time: 1 + Math.random() * 2 }); // Extract note info and media URLs const data = await page.evaluate(` (() => { + const bodyText = document.body?.innerText || ''; const result = { noteId: '${noteId}', + pageUrl: location.href, + securityBlock: /安全限制|访问链接异常/.test(bodyText) + || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href), title: '', author: '', media: [] @@ -148,6 +155,11 @@ cli({ return result; })() `); + if (data?.securityBlock) { + throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput) + ? '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 || !data.media || data.media.length === 0) { return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }]; } diff --git a/clis/xiaohongshu/download.test.js b/clis/xiaohongshu/download.test.js index 3b0fefec3..7c36096f6 100644 --- a/clis/xiaohongshu/download.test.js +++ b/clis/xiaohongshu/download.test.js @@ -70,4 +70,34 @@ describe('xiaohongshu download', () => { filenamePrefix: '69bc166f000000001a02069a', })); }); + it('throws SECURITY_BLOCK with bare-id guidance before starting downloads', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017', + securityBlock: true, + noteId: '69bc166f000000001a02069a', + media: [], + }); + await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + hint: expect.stringContaining('xsec_token'), + }); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) })); + }); + it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031', + securityBlock: true, + noteId: '69bc166f000000001a02069a', + media: [], + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search', + output: './out', + })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + hint: expect.stringContaining('Try again later'), + }); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + }); }); diff --git a/clis/xiaohongshu/navigation.test.js b/clis/xiaohongshu/navigation.test.js new file mode 100644 index 000000000..37a075cfe --- /dev/null +++ b/clis/xiaohongshu/navigation.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './note.js'; +import './comments.js'; +import './download.js'; +import './search.js'; +import './user.js'; +import './publish.js'; +import './creator-notes.js'; +import './creator-note-detail.js'; +import './creator-notes-summary.js'; +import './creator-profile.js'; +import './creator-stats.js'; + +describe('xiaohongshu navigateBefore hardening', () => { + const expectedFalse = [ + 'xiaohongshu/note', + 'xiaohongshu/comments', + 'xiaohongshu/download', + 'xiaohongshu/search', + 'xiaohongshu/user', + 'xiaohongshu/publish', + 'xiaohongshu/creator-notes', + 'xiaohongshu/creator-note-detail', + 'xiaohongshu/creator-notes-summary', + 'xiaohongshu/creator-profile', + 'xiaohongshu/creator-stats', + ]; + it.each(expectedFalse)('%s sets navigateBefore=false', (name) => { + const cmd = getRegistry().get(name); + expect(cmd).toBeDefined(); + expect(cmd.navigateBefore).toBe(false); + }); +}); diff --git a/clis/xiaohongshu/note.js b/clis/xiaohongshu/note.js index d90366b04..acf62df79 100644 --- a/clis/xiaohongshu/note.js +++ b/clis/xiaohongshu/note.js @@ -9,7 +9,7 @@ * when the user is logged in via cookies. */ 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'; cli({ site: 'xiaohongshu', @@ -17,6 +17,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)' }, ], @@ -26,11 +27,14 @@ cli({ const noteId = parseNoteId(raw); const url = buildNoteUrl(raw); await page.goto(url); - await page.wait(3); + await page.wait({ time: 2 + Math.random() * 3 }); const data = await page.evaluate(` (() => { - const loginWall = /登录后查看|请登录/.test(document.body.innerText || '') - const notFound = /页面不见了|笔记不存在|无法浏览/.test(document.body.innerText || '') + const bodyText = document.body?.innerText || '' + const loginWall = /登录后查看|请登录/.test(bodyText) + const notFound = /页面不见了|笔记不存在|无法浏览/.test(bodyText) + const securityBlock = /安全限制|访问链接异常/.test(bodyText) + || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href) const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim() @@ -53,12 +57,17 @@ cli({ if (t) tags.push(t) }) - return { loginWall, notFound, title, desc, author, likes, collects, comments, tags } + return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags } })() `); if (!data || typeof data !== 'object') { throw new EmptyResultError('xiaohongshu/note', '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 content requires login'); } diff --git a/clis/xiaohongshu/note.test.js b/clis/xiaohongshu/note.test.js index d3608bbf8..d25032651 100644 --- a/clis/xiaohongshu/note.test.js +++ b/clis/xiaohongshu/note.test.js @@ -106,6 +106,34 @@ describe('xiaohongshu note', () => { const page = createPageMock({ loginWall: true, notFound: false }); await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login'); }); + it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the note page', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017', + securityBlock: true, + loginWall: false, + notFound: false, + }); + await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + message: 'Xiaohongshu security block: the note detail page was blocked by risk control.', + 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 is blocked', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031', + securityBlock: true, + loginWall: false, + notFound: false, + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc', + })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + hint: expect.stringContaining('Try again later'), + }); + }); it('throws EmptyResultError when note is not found', async () => { const page = createPageMock({ loginWall: false, notFound: true }); await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data'); diff --git a/clis/xiaohongshu/publish.js b/clis/xiaohongshu/publish.js index 7b70b57df..14c411afe 100644 --- a/clis/xiaohongshu/publish.js +++ b/clis/xiaohongshu/publish.js @@ -333,6 +333,7 @@ cli({ domain: 'creator.xiaohongshu.com', strategy: Strategy.COOKIE, browser: true, + navigateBefore: false, args: [ { name: 'title', required: true, help: '笔记标题 (最多20字)' }, { name: 'content', required: true, positional: true, help: '笔记正文' }, diff --git a/clis/xiaohongshu/search.js b/clis/xiaohongshu/search.js index 7b3952698..523a8d90e 100644 --- a/clis/xiaohongshu/search.js +++ b/clis/xiaohongshu/search.js @@ -53,6 +53,7 @@ cli({ description: '搜索小红书笔记', domain: 'www.xiaohongshu.com', strategy: Strategy.COOKIE, + navigateBefore: false, args: [ { name: 'query', required: true, positional: true, help: 'Search keyword' }, { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, diff --git a/clis/xiaohongshu/user.js b/clis/xiaohongshu/user.js index f0d1ff865..ec1064b67 100644 --- a/clis/xiaohongshu/user.js +++ b/clis/xiaohongshu/user.js @@ -26,6 +26,7 @@ cli({ domain: 'www.xiaohongshu.com', strategy: Strategy.COOKIE, browser: true, + navigateBefore: false, args: [ { name: 'id', type: 'string', required: true, positional: true, help: 'User id or profile URL' }, { name: 'limit', type: 'int', default: 15, help: 'Number of notes to return' },