From 131c6eeef09e6af62f67e545e3036d8a42f88cf6 Mon Sep 17 00:00:00 2001 From: Ao Liu Date: Fri, 24 Jul 2026 09:43:31 +0800 Subject: [PATCH] fix(xiaohongshu): extract direct comment reply target --- cli-manifest.json | 4 +- clis/rednote/comments.js | 2 +- clis/xiaohongshu/comments.js | 11 +++- clis/xiaohongshu/comments.test.js | 84 ++++++++++++++++++++++++++-- docs/adapters/browser/rednote.md | 1 + docs/adapters/browser/xiaohongshu.md | 1 + 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index 476425046..18141263a 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -30631,7 +30631,7 @@ "type": "boolean", "default": false, "required": false, - "help": "Include nested replies (楼中楼)" + "help": "Include nested replies; reply_to is the direct target shown by the page" } ], "columns": [ @@ -42583,7 +42583,7 @@ "type": "boolean", "default": false, "required": false, - "help": "Include nested replies (楼中楼)" + "help": "Include nested replies; reply_to is the direct target shown by the page" } ], "columns": [ diff --git a/clis/rednote/comments.js b/clis/rednote/comments.js index c046a4c17..3932d3fa8 100644 --- a/clis/rednote/comments.js +++ b/clis/rednote/comments.js @@ -31,7 +31,7 @@ cli({ args: [ { name: 'note-id', required: true, positional: true, help: 'Full rednote note URL with xsec_token' }, { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' }, - { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' }, + { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies; reply_to is the direct target shown by the page' }, ], columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to', 'images'], func: async (page, kwargs) => { diff --git a/clis/xiaohongshu/comments.js b/clis/xiaohongshu/comments.js index 5fd9d2ca3..0e9cefb4a 100644 --- a/clis/xiaohongshu/comments.js +++ b/clis/xiaohongshu/comments.js @@ -262,12 +262,17 @@ export function buildCommentsExtractJs(withReplies, limit = 20) { p.querySelectorAll('.reply-container .comment-item-sub, .sub-comment-list .comment-item').forEach(sub => { const sAuthor = clean(sub.querySelector('.name, .user-name')) const sAuthorHrefRaw = extractAuthorHref(sub) - const sText = clean(sub.querySelector('.content, .note-text')) + const sContent = sub.querySelector('.content, .note-text') + const sText = clean(sContent) + // Xiaohongshu renders the direct target only for reply-to-reply + // rows (回复 Bob : ...). A nested + // row without that marker is a direct reply to the thread root. + const sReplyTo = clean(sContent?.querySelector(':scope > .nickname')) || author const sLikes = parseLikes(sub.querySelector('.count')) const sTime = clean(sub.querySelector('.date, .time')) const sImages = extractImages(sub) if (!sText) return - results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author, images: sImages }) + results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: sReplyTo, images: sImages }) }) } } @@ -287,7 +292,7 @@ export const command = cli({ args: [ { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' }, { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' }, - { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' }, + { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies; reply_to is the direct target shown by the page' }, ], columns: ['rank', 'author', 'userId', 'profileUrl', 'text', 'likes', 'time', 'is_reply', 'reply_to', 'images'], func: async (page, kwargs) => { diff --git a/clis/xiaohongshu/comments.test.js b/clis/xiaohongshu/comments.test.js index 9b9cd4455..c8edd5d42 100644 --- a/clis/xiaohongshu/comments.test.js +++ b/clis/xiaohongshu/comments.test.js @@ -27,20 +27,35 @@ function createPageMock(evaluateResult) { }; } -async function runCommentsExtract(html) { +async function runCommentsExtract(html, withReplies = false) { const dom = new JSDOM(html, { url: 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' }); + const hadDocument = Object.hasOwn(globalThis, 'document'); + const hadLocation = Object.hasOwn(globalThis, 'location'); + const hadWindow = Object.hasOwn(globalThis, 'window'); + const hadHTMLElement = Object.hasOwn(globalThis, 'HTMLElement'); const previousDocument = globalThis.document; const previousLocation = globalThis.location; + const previousWindow = globalThis.window; + const previousHTMLElement = globalThis.HTMLElement; globalThis.document = dom.window.document; globalThis.location = dom.window.location; + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; try { // limit=1 so the scroll-loading loop's initial "already have enough" // check short-circuits instead of burning through stall retries — the // JSDOM fixtures below are fully static, there's nothing more to load. - return await eval(buildCommentsExtractJs(false, 1)); + return await eval(buildCommentsExtractJs(withReplies, 1)); } finally { - globalThis.document = previousDocument; - globalThis.location = previousLocation; + if (hadDocument) globalThis.document = previousDocument; + else delete globalThis.document; + if (hadLocation) globalThis.location = previousLocation; + else delete globalThis.location; + if (hadWindow) globalThis.window = previousWindow; + else delete globalThis.window; + if (hadHTMLElement) globalThis.HTMLElement = previousHTMLElement; + else delete globalThis.HTMLElement; + dom.window.close(); } } @@ -66,6 +81,32 @@ describe('parseXhsLikeCountText', () => { describe('xiaohongshu comments', () => { const command = getRegistry().get('xiaohongshu/comments'); + it('restores JSDOM globals after DOM extraction', async () => { + const keys = ['document', 'location', 'window', 'HTMLElement']; + const before = keys.map(key => ({ + key, + hadOwnProperty: Object.hasOwn(globalThis, key), + value: Reflect.get(globalThis, key), + })); + + await runCommentsExtract(` +
+
+
+ Alice +
Root comment
+
+
+
+ `); + + for (const entry of before) { + expect(Object.hasOwn(globalThis, entry.key)).toBe(entry.hadOwnProperty); + if (entry.hadOwnProperty) { + expect(Reflect.get(globalThis, entry.key)).toBe(entry.value); + } + } + }); it('returns ranked comment rows for signed full URLs', async () => { const page = createPageMock({ loginWall: false, @@ -404,6 +445,41 @@ describe('xiaohongshu comments', () => { expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' }); }); describe('--with-replies', () => { + it('extracts the direct reply target from nested reply DOM', async () => { + const data = await runCommentsExtract(` +
+
+
+
Alice
+
Root comment
+
+
+
+
Bob
+
Direct reply
+
+
+
Carol
+
+ 回复 Bob : + Nested reply +
+
+
+
+
+ `, true); + + expect(data.results).toHaveLength(3); + expect(data.results[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' }); + expect(data.results[1]).toMatchObject({ author: 'Bob', is_reply: true, reply_to: 'Alice' }); + expect(data.results[2]).toMatchObject({ + author: 'Carol', + text: '回复 Bob : Nested reply', + is_reply: true, + reply_to: 'Bob', + }); + }); it('includes reply rows with is_reply=true and reply_to set', async () => { const page = createPageMock({ loginWall: false, diff --git a/docs/adapters/browser/rednote.md b/docs/adapters/browser/rednote.md index 3b052bd76..e8ace3b42 100644 --- a/docs/adapters/browser/rednote.md +++ b/docs/adapters/browser/rednote.md @@ -39,6 +39,7 @@ opencli rednote download "https://www.rednote.com/search_result/?xsec_token= ``` > Note: `note`, `comments`, and `download` require a full signed rednote.com note URL with `xsec_token`. Bare note IDs and xhslink.com short links are not accepted because they cannot prove the rednote host/cookie identity before navigation. +> With `comments --with-replies`, `reply_to` is the direct reply target displayed by the page. Replies without an explicit `回复 ` marker target the enclosing top-level comment. ## Prerequisites diff --git a/docs/adapters/browser/xiaohongshu.md b/docs/adapters/browser/xiaohongshu.md index 2e03c730a..c492f798b 100644 --- a/docs/adapters/browser/xiaohongshu.md +++ b/docs/adapters/browser/xiaohongshu.md @@ -72,6 +72,7 @@ opencli xiaohongshu delete-note 6a08ba0b000000000702a893 --execute ``` > Note: `note` and `comments` now require a full signed note URL with `xsec_token`. `download` accepts either a signed note URL or an `xhslink` short link. Bare note IDs are no longer reliable on xiaohongshu. +> With `comments --with-replies`, `reply_to` is the direct reply target displayed by the page. Replies without an explicit `回复 ` marker target the enclosing top-level comment. > `ask` is separate from ordinary `search`: it submits the question to 点点, returns `answer`, `source_count`, and `sources[]`, and keeps `xsec_token` in JSON when Xiaohongshu returns one. The current 点点 source API may return bare note IDs without `xsec_token`; in that case `url` falls back to `/explore/` and `xsec_token` is an empty string. Each source also carries the engagement and identity metadata 点点 returns: `like_count`, `note_type` (`normal`/`video`), `user_id`, and `published_at` (each omitted when 点点 does not provide it), so citation analysis can read likes and note format without a follow-up `search`/`note` round-trip. > `delete-note` operates in creator center and accepts a 24-character note ID or exact Xiaohongshu note URL; it defaults to dry-run verification and only deletes with `--execute`. > `follow` and `unfollow` are write commands on the public profile page. They verify the browser stayed on the requested `/user/profile/` target before clicking, and verify the visible follow-state button after the action.