Skip to content
Open
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
4 changes: 2 additions & 2 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion clis/rednote/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
11 changes: 8 additions & 3 deletions clis/xiaohongshu/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (回复 <span class="nickname">Bob</span> : ...). 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 })
})
}
}
Expand All @@ -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) => {
Expand Down
84 changes: 80 additions & 4 deletions clis/xiaohongshu/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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(`
<main>
<section class="parent-comment">
<div class="comment-item">
<span class="name">Alice</span>
<div class="content">Root comment</div>
</div>
</section>
</main>
`);

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,
Expand Down Expand Up @@ -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(`
<main>
<section class="parent-comment">
<div id="comment-root" class="comment-item">
<div class="author-wrapper"><span class="name">Alice</span></div>
<div class="content">Root comment</div>
</div>
<div class="reply-container">
<div id="comment-direct" class="comment-item-sub">
<div class="author-wrapper"><span class="name">Bob</span></div>
<div class="content"><span class="note-text">Direct reply</span></div>
</div>
<div id="comment-nested" class="comment-item-sub">
<div class="author-wrapper"><span class="name">Carol</span></div>
<div class="content">
<span>回复 </span><span class="nickname">Bob</span> :
<span class="note-text">Nested reply</span>
</div>
</div>
</div>
</section>
</main>
`, 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,
Expand Down
1 change: 1 addition & 0 deletions docs/adapters/browser/rednote.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ opencli rednote download "https://www.rednote.com/search_result/<id>?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 `回复 <name>` marker target the enclosing top-level comment.

## Prerequisites

Expand Down
1 change: 1 addition & 0 deletions docs/adapters/browser/xiaohongshu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `回复 <name>` 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/<note_id>` 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/<id>` target before clicking, and verify the visible follow-state button after the action.
Expand Down