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(`
+
+
+
+ `);
+
+ 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(`
+
+
+
+ `, 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.