@jackwener/opencli 1.7.17 → 1.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockDownloadMedia, mockFormatCookieHeader } = vi.hoisted(() => ({
4
+ mockDownloadMedia: vi.fn(),
5
+ mockFormatCookieHeader: vi.fn(() => 'sid=secret'),
6
+ }));
7
+
8
+ vi.mock('@jackwener/opencli/download/media-download', () => ({
9
+ downloadMedia: mockDownloadMedia,
10
+ }));
11
+
12
+ vi.mock('@jackwener/opencli/download', () => ({
13
+ formatCookieHeader: mockFormatCookieHeader,
14
+ }));
15
+
16
+ import { getRegistry } from '@jackwener/opencli/registry';
17
+ import './comments.js';
18
+ import './download.js';
19
+ import './feed.js';
20
+ import './notifications.js';
21
+ import './note.js';
22
+ import './search.js';
23
+ import './user.js';
24
+
25
+ function createPageMock(evaluateResult) {
26
+ return {
27
+ goto: vi.fn().mockResolvedValue(undefined),
28
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
29
+ wait: vi.fn().mockResolvedValue(undefined),
30
+ autoScroll: vi.fn().mockResolvedValue(undefined),
31
+ getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: 'www.rednote.com' }]),
32
+ };
33
+ }
34
+
35
+ describe('rednote note URL identity', () => {
36
+ const download = getRegistry().get('rednote/download');
37
+ const comments = getRegistry().get('rednote/comments');
38
+
39
+ beforeEach(() => {
40
+ mockDownloadMedia.mockReset();
41
+ mockDownloadMedia.mockResolvedValue([{ index: 1, type: 'image', status: 'success', size: '1 KB' }]);
42
+ mockFormatCookieHeader.mockClear();
43
+ });
44
+
45
+ it('rejects xhslink short links before browser navigation', async () => {
46
+ const page = createPageMock({ media: [] });
47
+ await expect(download.func(page, {
48
+ 'note-id': 'https://xhslink.com/o/4MKEjsZnhCz',
49
+ output: './out',
50
+ })).rejects.toMatchObject({
51
+ code: 'ARGUMENT',
52
+ message: expect.stringContaining('signed URL'),
53
+ hint: expect.stringContaining('rednote.com'),
54
+ });
55
+ expect(page.goto).not.toHaveBeenCalled();
56
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('rejects signed xiaohongshu URLs before browser navigation', async () => {
60
+ const page = createPageMock({ media: [] });
61
+ await expect(comments.func(page, {
62
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc',
63
+ limit: 20,
64
+ })).rejects.toMatchObject({
65
+ code: 'ARGUMENT',
66
+ message: expect.stringContaining('signed URL'),
67
+ hint: expect.stringContaining('rednote.com'),
68
+ });
69
+ expect(page.goto).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('uses URL-scoped rednote cookies when downloading media', async () => {
73
+ const page = createPageMock({
74
+ noteId: '69bc166f000000001a02069a',
75
+ media: [{ type: 'image', url: 'https://ci.rednote.com/example.jpg' }],
76
+ });
77
+ await download.func(page, {
78
+ 'note-id': 'https://www.rednote.com/search_result/69bc166f000000001a02069a?xsec_token=abc',
79
+ output: './out',
80
+ });
81
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://www.rednote.com' });
82
+ expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.rednote.com/example.jpg' }], expect.objectContaining({
83
+ cookies: 'sid=secret',
84
+ subdir: '69bc166f000000001a02069a',
85
+ }));
86
+ });
87
+
88
+ it('throws empty-result instead of returning a failed success row when no media exists', async () => {
89
+ const page = createPageMock({ noteId: '69bc166f000000001a02069a', media: [] });
90
+ let caught;
91
+ try {
92
+ await download.func(page, {
93
+ 'note-id': 'https://www.rednote.com/search_result/69bc166f000000001a02069a?xsec_token=abc',
94
+ output: './out',
95
+ });
96
+ }
97
+ catch (error) {
98
+ caught = error;
99
+ }
100
+ expect(caught).toMatchObject({ code: 'EMPTY_RESULT' });
101
+ expect(caught?.hint).toContain('No downloadable media');
102
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
103
+ });
104
+ });
105
+
106
+ describe('rednote argument validation', () => {
107
+ const comments = getRegistry().get('rednote/comments');
108
+ const feed = getRegistry().get('rednote/feed');
109
+ const notifications = getRegistry().get('rednote/notifications');
110
+ const user = getRegistry().get('rednote/user');
111
+
112
+ it.each([
113
+ ['rednote/comments', comments, { 'note-id': 'https://www.rednote.com/search_result/69aadbcb000000002202f131?xsec_token=abc', limit: 0 }],
114
+ ['rednote/feed', feed, { limit: 0 }],
115
+ ['rednote/notifications', notifications, { limit: 0 }],
116
+ ['rednote/user', user, { id: 'user123', limit: 0 }],
117
+ ])('%s rejects invalid --limit before browser navigation', async (_name, command, kwargs) => {
118
+ const page = createPageMock({});
119
+ await expect(command.func(page, kwargs)).rejects.toMatchObject({ code: 'ARGUMENT' });
120
+ expect(page.goto).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('rejects unknown notification types before browser navigation', async () => {
124
+ const page = createPageMock({});
125
+ await expect(notifications.func(page, { type: 'all', limit: 20 })).rejects.toMatchObject({
126
+ code: 'ARGUMENT',
127
+ message: expect.stringContaining('--type'),
128
+ });
129
+ expect(page.goto).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('rednote Pinia store failures', () => {
134
+ it('maps feed store read failure to CommandExecutionError', async () => {
135
+ const command = getRegistry().get('rednote/feed');
136
+ const page = createPageMock({ error: 'no_pinia' });
137
+ await expect(command.func(page, { limit: 20 })).rejects.toMatchObject({
138
+ code: 'COMMAND_EXEC',
139
+ message: expect.stringContaining('no_pinia'),
140
+ });
141
+ });
142
+
143
+ it('maps notification action failure to CommandExecutionError', async () => {
144
+ const command = getRegistry().get('rednote/notifications');
145
+ const page = createPageMock({ error: 'action_failed', detail: 'blocked' });
146
+ await expect(command.func(page, { type: 'mentions', limit: 20 })).rejects.toMatchObject({
147
+ code: 'COMMAND_EXEC',
148
+ message: expect.stringContaining('action_failed'),
149
+ });
150
+ });
151
+
152
+ it('allows an empty notification list after a successful store read', async () => {
153
+ const command = getRegistry().get('rednote/notifications');
154
+ const page = createPageMock({ items: [] });
155
+ await expect(command.func(page, { type: 'mentions', limit: 20 })).resolves.toEqual([]);
156
+ });
157
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Rednote search — international mirror of xiaohongshu/search.
3
+ *
4
+ * Reuses the DOM-extraction IIFE from `../xiaohongshu/search.js`; only the
5
+ * web host and the login-gate detection differ. See issue #1136 for the
6
+ * 1:1 comparison between the two frontends.
7
+ */
8
+ import { cli, Strategy } from '@jackwener/opencli/registry';
9
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
10
+ import { buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
11
+
12
+ function parseLimit(raw) {
13
+ const parsed = Number(raw);
14
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
15
+ throw new ArgumentError(`--limit must be an integer between 1 and 100, got ${JSON.stringify(raw)}`);
16
+ }
17
+ if (parsed < 1 || parsed > 100) {
18
+ throw new ArgumentError(`--limit must be between 1 and 100, got ${parsed}`);
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ /**
24
+ * Wait for search results or login wall using MutationObserver (max 5s).
25
+ *
26
+ * Differs from xiaohongshu by detecting a full-screen login modal instead
27
+ * of (and as a fallback, alongside) the inline `登录后查看搜索结果` text.
28
+ * The modal detector filters hidden / zero-area elements to avoid false
29
+ * positives on background dialogs.
30
+ */
31
+ const WAIT_FOR_CONTENT_JS = `
32
+ new Promise((resolve) => {
33
+ const hasLoginModal = () => {
34
+ const candidates = document.querySelectorAll(
35
+ '[class*="login-modal"], [class*="LoginModal"], [class*="login-container"], [class*="LoginContainer"], dialog[role="dialog"]'
36
+ );
37
+ for (const el of candidates) {
38
+ if (!(el instanceof HTMLElement)) continue;
39
+ const rect = el.getBoundingClientRect();
40
+ if (rect.width <= 0 || rect.height <= 0) continue;
41
+ const style = getComputedStyle(el);
42
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
43
+ return true;
44
+ }
45
+ return false;
46
+ };
47
+ const detect = () => {
48
+ if (document.querySelector('section.note-item')) return 'content';
49
+ if (/登录后查看搜索结果|请登录/.test(document.body?.innerText || '')) return 'login_wall';
50
+ if (hasLoginModal()) return 'login_wall';
51
+ return null;
52
+ };
53
+ const found = detect();
54
+ if (found) return resolve(found);
55
+ const observer = new MutationObserver(() => {
56
+ const result = detect();
57
+ if (result) { observer.disconnect(); resolve(result); }
58
+ });
59
+ observer.observe(document.body, { childList: true, subtree: true });
60
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 5000);
61
+ })
62
+ `;
63
+
64
+ cli({
65
+ site: 'rednote',
66
+ name: 'search',
67
+ access: 'read',
68
+ description: 'Search rednote notes',
69
+ domain: 'www.rednote.com',
70
+ strategy: Strategy.COOKIE,
71
+ navigateBefore: false,
72
+ args: [
73
+ { name: 'query', required: true, positional: true, help: 'Search keyword' },
74
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
75
+ ],
76
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url', 'author_url'],
77
+ func: async (page, kwargs) => {
78
+ const limit = parseLimit(kwargs.limit ?? 20);
79
+ const keyword = encodeURIComponent(kwargs.query);
80
+ await page.goto(`https://www.rednote.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
81
+ const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
82
+ if (waitResult === 'login_wall') {
83
+ throw new AuthRequiredError('www.rednote.com', 'Rednote search results are blocked behind a login wall');
84
+ }
85
+ await page.autoScroll({ times: 2 });
86
+ const payload = await page.evaluate(buildSearchExtractJs('www.rednote.com'));
87
+ const data = Array.isArray(payload) ? payload : [];
88
+ return data
89
+ .filter((item) => item.title)
90
+ .slice(0, limit)
91
+ .map((item, i) => ({
92
+ rank: i + 1,
93
+ ...item,
94
+ published_at: noteIdToDate(item.url),
95
+ }));
96
+ },
97
+ });
@@ -0,0 +1,55 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { USER_SNAPSHOT_JS } from '../xiaohongshu/user.js';
4
+ import { extractXhsUserNotes, normalizeXhsUserId } from '../xiaohongshu/user-helpers.js';
5
+
6
+ const WEB_HOST = 'www.rednote.com';
7
+
8
+ function parseLimit(raw) {
9
+ const parsed = Number(raw ?? 15);
10
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
11
+ throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`);
12
+ }
13
+ if (parsed < 1) {
14
+ throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`);
15
+ }
16
+ return parsed;
17
+ }
18
+
19
+ export const command = cli({
20
+ site: 'rednote',
21
+ name: 'user',
22
+ access: 'read',
23
+ description: 'Get public notes from a rednote user profile',
24
+ domain: WEB_HOST,
25
+ strategy: Strategy.COOKIE,
26
+ browser: true,
27
+ navigateBefore: false,
28
+ args: [
29
+ { name: 'id', type: 'str', required: true, positional: true, help: 'User id or profile URL' },
30
+ { name: 'limit', type: 'int', default: 15, help: 'Number of notes to return' },
31
+ ],
32
+ columns: ['id', 'title', 'type', 'likes', 'url'],
33
+ func: async (page, kwargs) => {
34
+ const userId = normalizeXhsUserId(String(kwargs.id));
35
+ const limit = parseLimit(kwargs.limit);
36
+ await page.goto(`https://${WEB_HOST}/user/profile/${userId}`);
37
+ let snapshot = await page.evaluate(USER_SNAPSHOT_JS);
38
+ let results = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
39
+ let previousCount = results.length;
40
+ for (let i = 0; results.length < limit && i < 4; i += 1) {
41
+ await page.autoScroll({ times: 1, delayMs: 1500 });
42
+ await page.wait({ time: 1 });
43
+ snapshot = await page.evaluate(USER_SNAPSHOT_JS);
44
+ const nextResults = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
45
+ if (nextResults.length <= previousCount)
46
+ break;
47
+ results = nextResults;
48
+ previousCount = nextResults.length;
49
+ }
50
+ if (results.length === 0) {
51
+ throw new EmptyResultError('rednote/user', 'No public notes found for this rednote user.');
52
+ }
53
+ return results.slice(0, limit);
54
+ },
55
+ });
@@ -8,34 +8,19 @@
8
8
  import { cli, Strategy } from '@jackwener/opencli/registry';
9
9
  import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
10
10
  import { parseNoteId, buildNoteUrl } from './note-helpers.js';
11
- function parseCommentLimit(raw, fallback = 20) {
11
+ export function parseCommentLimit(raw, fallback = 20) {
12
12
  const n = Number(raw);
13
13
  if (!Number.isFinite(n))
14
14
  return fallback;
15
15
  return Math.max(1, Math.min(Math.floor(n), 50));
16
16
  }
17
- cli({
18
- site: 'xiaohongshu',
19
- name: 'comments',
20
- access: 'read',
21
- description: '获取小红书笔记评论(支持楼中楼子回复)',
22
- domain: 'www.xiaohongshu.com',
23
- strategy: Strategy.COOKIE,
24
- navigateBefore: false,
25
- args: [
26
- { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
27
- { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
28
- { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
29
- ],
30
- columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
31
- func: async (page, kwargs) => {
32
- const limit = parseCommentLimit(kwargs.limit);
33
- const withReplies = Boolean(kwargs['with-replies']);
34
- const raw = String(kwargs['note-id']);
35
- const noteId = parseNoteId(raw);
36
- await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' }));
37
- await page.wait({ time: 2 + Math.random() * 3 });
38
- const data = await page.evaluate(`
17
+ /**
18
+ * Host-agnostic IIFE that scrolls a note's comment list and extracts
19
+ * top-level comments (and optionally nested 楼中楼 replies). Exported so
20
+ * the rednote adapter can reuse the exact same selector chain.
21
+ */
22
+ export function buildCommentsExtractJs(withReplies) {
23
+ return `
39
24
  (async () => {
40
25
  const wait = (ms) => new Promise(r => setTimeout(r, ms))
41
26
  const withReplies = ${withReplies}
@@ -115,7 +100,30 @@ cli({
115
100
 
116
101
  return { pageUrl: location.href, securityBlock, loginWall, results }
117
102
  })()
118
- `);
103
+ `;
104
+ }
105
+ export const command = cli({
106
+ site: 'xiaohongshu',
107
+ name: 'comments',
108
+ access: 'read',
109
+ description: '获取小红书笔记评论(支持楼中楼子回复)',
110
+ domain: 'www.xiaohongshu.com',
111
+ strategy: Strategy.COOKIE,
112
+ navigateBefore: false,
113
+ args: [
114
+ { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
115
+ { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
116
+ { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
117
+ ],
118
+ columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
119
+ func: async (page, kwargs) => {
120
+ const limit = parseCommentLimit(kwargs.limit);
121
+ const withReplies = Boolean(kwargs['with-replies']);
122
+ const raw = String(kwargs['note-id']);
123
+ const noteId = parseNoteId(raw);
124
+ await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' }));
125
+ await page.wait({ time: 2 + Math.random() * 3 });
126
+ const data = await page.evaluate(buildCommentsExtractJs(withReplies));
119
127
  if (!data || typeof data !== 'object') {
120
128
  throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response');
121
129
  }
@@ -127,6 +135,8 @@ cli({
127
135
  if (data.loginWall) {
128
136
  throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
129
137
  }
138
+ // noteId currently unused after parsing — kept for symmetry with the note command
139
+ void noteId;
130
140
  const all = data.results ?? [];
131
141
  // When limiting, count only top-level comments; their replies are included for free
132
142
  if (withReplies) {
@@ -11,27 +11,15 @@ import { formatCookieHeader } from '@jackwener/opencli/download';
11
11
  import { downloadMedia } from '@jackwener/opencli/download/media-download';
12
12
  import { CliError } from '@jackwener/opencli/errors';
13
13
  import { buildNoteUrl, parseNoteId } from './note-helpers.js';
14
- cli({
15
- site: 'xiaohongshu',
16
- name: 'download',
17
- access: 'read',
18
- description: '下载小红书笔记中的图片和视频',
19
- domain: 'www.xiaohongshu.com',
20
- strategy: Strategy.COOKIE,
21
- navigateBefore: false,
22
- args: [
23
- { name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
24
- { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
25
- ],
26
- columns: ['index', 'type', 'status', 'size'],
27
- func: async (page, kwargs) => {
28
- const rawInput = String(kwargs['note-id']);
29
- const output = kwargs.output;
30
- const noteId = parseNoteId(rawInput);
31
- await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
32
- await page.wait({ time: 1 + Math.random() * 2 });
33
- // Extract note info and media URLs
34
- const data = await page.evaluate(`
14
+ /**
15
+ * Build the media-extraction IIFE. The note id is interpolated as a default
16
+ * since the IIFE may also resolve it from `location.pathname`. The CDN
17
+ * substring allowlist includes `rednote` so the rednote adapter can reuse
18
+ * this script unchanged — image / video URLs on both sites are served from
19
+ * the same xhscdn family per #1136.
20
+ */
21
+ export function buildDownloadExtractJs(noteId) {
22
+ return `
35
23
  (() => {
36
24
  const bodyText = document.body?.innerText || '';
37
25
  const result = {
@@ -79,7 +67,7 @@ cli({
79
67
  for (const selector of imageSelectors) {
80
68
  document.querySelectorAll(selector).forEach(img => {
81
69
  let src = img.src || img.getAttribute('data-src') || '';
82
- if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
70
+ if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
83
71
  src = src.split('?')[0];
84
72
  src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
85
73
  imageUrls.add(src);
@@ -154,7 +142,28 @@ cli({
154
142
 
155
143
  return result;
156
144
  })()
157
- `);
145
+ `;
146
+ }
147
+ export const command = cli({
148
+ site: 'xiaohongshu',
149
+ name: 'download',
150
+ access: 'read',
151
+ description: '下载小红书笔记中的图片和视频',
152
+ domain: 'www.xiaohongshu.com',
153
+ strategy: Strategy.COOKIE,
154
+ navigateBefore: false,
155
+ args: [
156
+ { name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
157
+ { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
158
+ ],
159
+ columns: ['index', 'type', 'status', 'size'],
160
+ func: async (page, kwargs) => {
161
+ const rawInput = String(kwargs['note-id']);
162
+ const output = kwargs.output;
163
+ const noteId = parseNoteId(rawInput);
164
+ await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
165
+ await page.wait({ time: 1 + Math.random() * 2 });
166
+ const data = await page.evaluate(buildDownloadExtractJs(noteId));
158
167
  if (data?.securityBlock) {
159
168
  throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
160
169
  ? 'The page may be temporarily restricted. Try again later or from a different session.'
@@ -1,18 +1,12 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- cli({
3
- site: 'xiaohongshu',
4
- name: 'feed',
5
- access: 'read',
6
- description: '小红书首页推荐 Feed (via Pinia Store Action)',
7
- domain: 'www.xiaohongshu.com',
8
- strategy: Strategy.INTERCEPT,
9
- browser: true,
10
- args: [
11
- { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
12
- ],
13
- columns: ['id', 'title', 'author', 'likes', 'type', 'url'],
14
- pipeline: [
15
- { navigate: 'https://www.xiaohongshu.com/explore' },
2
+ /**
3
+ * Build the home-feed pipeline for the given web host. Exported so the
4
+ * rednote adapter can register the same pipeline against www.rednote.com
5
+ * without duplicating the tap/map/limit steps.
6
+ */
7
+ export function buildFeedPipeline(webHost) {
8
+ return [
9
+ { navigate: `https://${webHost}/explore` },
16
10
  { tap: {
17
11
  store: 'feed',
18
12
  action: 'fetchFeeds',
@@ -26,8 +20,22 @@ cli({
26
20
  type: '${{ item.note_card.type }}',
27
21
  author: '${{ item.note_card.user.nickname }}',
28
22
  likes: '${{ item.note_card.interact_info.liked_count }}',
29
- url: 'https://www.xiaohongshu.com/explore/${{ item.id }}',
23
+ url: `https://${webHost}/explore/\${{ item.id }}`,
30
24
  } },
31
25
  { limit: '${{ args.limit | default(20) }}' },
26
+ ];
27
+ }
28
+ export const command = cli({
29
+ site: 'xiaohongshu',
30
+ name: 'feed',
31
+ access: 'read',
32
+ description: '小红书首页推荐 Feed (via Pinia Store Action)',
33
+ domain: 'www.xiaohongshu.com',
34
+ strategy: Strategy.INTERCEPT,
35
+ browser: true,
36
+ args: [
37
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
32
38
  ],
39
+ columns: ['id', 'title', 'author', 'likes', 'type', 'url'],
40
+ pipeline: buildFeedPipeline('www.xiaohongshu.com'),
33
41
  });
@@ -14,9 +14,9 @@ function isShortLink(input) {
14
14
  return /^https?:\/\/xhslink\.com\//i.test(input);
15
15
  }
16
16
 
17
- function isXiaohongshuHost(hostname) {
17
+ function isHostMatch(hostname, cookieRoot) {
18
18
  const normalized = hostname.toLowerCase();
19
- return normalized === 'xiaohongshu.com' || normalized.endsWith('.xiaohongshu.com');
19
+ return normalized === cookieRoot || normalized.endsWith('.' + cookieRoot);
20
20
  }
21
21
 
22
22
  function isSupportedNotePath(pathname) {
@@ -30,14 +30,24 @@ function isSupportedNotePath(pathname) {
30
30
  * XHS note detail pages now require a valid signed URL for reliable access.
31
31
  * Bare note IDs no longer resolve deterministically, so callers must provide
32
32
  * a full note URL with xsec_token or, for downloads only, an xhslink short link.
33
+ *
34
+ * `options.cookieRoot` overrides the default `xiaohongshu.com` cookie root —
35
+ * the rednote adapter passes `'rednote.com'` so the same validator accepts
36
+ * `www.rednote.com` URLs without duplicating this function.
37
+ * `options.signedUrlHint` overrides the default hint surfaced on rejection.
33
38
  */
34
39
  export function buildNoteUrl(input, options = {}) {
35
- const { allowShortLink = false, commandName = 'xiaohongshu note' } = options;
40
+ const {
41
+ allowShortLink = false,
42
+ commandName = 'xiaohongshu note',
43
+ cookieRoot = 'xiaohongshu.com',
44
+ signedUrlHint = XHS_SIGNED_URL_HINT,
45
+ } = options;
36
46
  const trimmed = input.trim();
37
47
  const message = `${commandName} now requires a full signed URL`;
38
48
  const hint = allowShortLink
39
- ? `${XHS_SIGNED_URL_HINT} For downloads, xhslink short links are also supported.`
40
- : XHS_SIGNED_URL_HINT;
49
+ ? `${signedUrlHint} For downloads, xhslink short links are also supported.`
50
+ : signedUrlHint;
41
51
 
42
52
  if (/^https?:\/\//.test(trimmed)) {
43
53
  if (isShortLink(trimmed)) {
@@ -48,7 +58,7 @@ export function buildNoteUrl(input, options = {}) {
48
58
  try {
49
59
  const url = new URL(trimmed);
50
60
  const xsecToken = url.searchParams.get('xsec_token')?.trim();
51
- if (isXiaohongshuHost(url.hostname) && isSupportedNotePath(url.pathname) && xsecToken) {
61
+ if (isHostMatch(url.hostname, cookieRoot) && isSupportedNotePath(url.pathname) && xsecToken) {
52
62
  return trimmed;
53
63
  }
54
64
  }
@@ -9,25 +9,12 @@
9
9
  import { cli, Strategy } from '@jackwener/opencli/registry';
10
10
  import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
11
11
  import { parseNoteId, buildNoteUrl } from './note-helpers.js';
12
- cli({
13
- site: 'xiaohongshu',
14
- name: 'note',
15
- access: 'read',
16
- description: '获取小红书笔记正文和互动数据',
17
- domain: 'www.xiaohongshu.com',
18
- strategy: Strategy.COOKIE,
19
- navigateBefore: false,
20
- args: [
21
- { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
22
- ],
23
- columns: ['field', 'value'],
24
- func: async (page, kwargs) => {
25
- const raw = String(kwargs['note-id']);
26
- const noteId = parseNoteId(raw);
27
- const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
28
- await page.goto(url);
29
- await page.wait({ time: 2 + Math.random() * 3 });
30
- const data = await page.evaluate(`
12
+ /**
13
+ * Host-agnostic IIFE that scrapes note title / author / counts / tags from a
14
+ * rendered note detail page. Exported so the rednote adapter can reuse the
15
+ * exact same selector set without copying it.
16
+ */
17
+ export const NOTE_EXTRACT_JS = `
31
18
  (() => {
32
19
  const bodyText = document.body?.innerText || ''
33
20
  const loginWall = /登录后查看|请登录/.test(bodyText)
@@ -58,7 +45,26 @@ cli({
58
45
 
59
46
  return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
60
47
  })()
61
- `);
48
+ `;
49
+ export const command = cli({
50
+ site: 'xiaohongshu',
51
+ name: 'note',
52
+ access: 'read',
53
+ description: '获取小红书笔记正文和互动数据',
54
+ domain: 'www.xiaohongshu.com',
55
+ strategy: Strategy.COOKIE,
56
+ navigateBefore: false,
57
+ args: [
58
+ { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
59
+ ],
60
+ columns: ['field', 'value'],
61
+ func: async (page, kwargs) => {
62
+ const raw = String(kwargs['note-id']);
63
+ const noteId = parseNoteId(raw);
64
+ const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
65
+ await page.goto(url);
66
+ await page.wait({ time: 2 + Math.random() * 3 });
67
+ const data = await page.evaluate(NOTE_EXTRACT_JS);
62
68
  if (!data || typeof data !== 'object') {
63
69
  throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
64
70
  }