@jackwener/opencli 1.7.16 → 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.
Files changed (174) hide show
  1. package/README.md +11 -9
  2. package/README.zh-CN.md +10 -8
  3. package/cli-manifest.json +377 -271
  4. package/clis/chatgpt/ask.js +1 -1
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +1 -1
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +1 -1
  12. package/clis/chatgpt/send.js +1 -1
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +208 -16
  15. package/clis/chatgpt/utils.test.js +131 -2
  16. package/clis/claude/ask.js +1 -1
  17. package/clis/claude/detail.js +1 -1
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +1 -1
  20. package/clis/claude/read.js +1 -1
  21. package/clis/claude/send.js +1 -1
  22. package/clis/claude/status.js +1 -1
  23. package/clis/deepseek/ask.js +1 -1
  24. package/clis/deepseek/detail.js +1 -1
  25. package/clis/deepseek/history.js +1 -1
  26. package/clis/deepseek/new.js +1 -1
  27. package/clis/deepseek/read.js +1 -1
  28. package/clis/deepseek/send.js +1 -1
  29. package/clis/deepseek/status.js +1 -1
  30. package/clis/doubao/ask.js +1 -1
  31. package/clis/doubao/detail.js +1 -1
  32. package/clis/doubao/history.js +1 -1
  33. package/clis/doubao/meeting-summary.js +1 -1
  34. package/clis/doubao/meeting-transcript.js +1 -1
  35. package/clis/doubao/new.js +1 -1
  36. package/clis/doubao/read.js +1 -1
  37. package/clis/doubao/send.js +1 -1
  38. package/clis/doubao/status.js +1 -1
  39. package/clis/doubao/utils.js +17 -0
  40. package/clis/doubao/utils.test.js +61 -0
  41. package/clis/gemini/ask.js +1 -1
  42. package/clis/gemini/deep-research-result.js +1 -1
  43. package/clis/gemini/deep-research.js +1 -1
  44. package/clis/gemini/image.js +1 -1
  45. package/clis/gemini/new.js +1 -1
  46. package/clis/grok/ask.js +1 -1
  47. package/clis/grok/detail.js +1 -1
  48. package/clis/grok/history.js +1 -1
  49. package/clis/grok/image.js +1 -1
  50. package/clis/grok/new.js +1 -1
  51. package/clis/grok/read.js +1 -1
  52. package/clis/grok/send.js +1 -1
  53. package/clis/grok/status.js +1 -1
  54. package/clis/notebooklm/current.js +1 -1
  55. package/clis/notebooklm/get.js +1 -1
  56. package/clis/notebooklm/history.js +1 -1
  57. package/clis/notebooklm/note-list.js +1 -1
  58. package/clis/notebooklm/notes-get.js +1 -1
  59. package/clis/notebooklm/open.js +2 -2
  60. package/clis/notebooklm/open.test.js +1 -1
  61. package/clis/notebooklm/source-fulltext.js +1 -1
  62. package/clis/notebooklm/source-get.js +1 -1
  63. package/clis/notebooklm/source-guide.js +1 -1
  64. package/clis/notebooklm/source-list.js +1 -1
  65. package/clis/notebooklm/summary.js +1 -1
  66. package/clis/qwen/ask.js +1 -1
  67. package/clis/qwen/detail.js +1 -1
  68. package/clis/qwen/history.js +1 -1
  69. package/clis/qwen/image.js +1 -1
  70. package/clis/qwen/new.js +1 -1
  71. package/clis/qwen/read.js +1 -1
  72. package/clis/qwen/send.js +1 -1
  73. package/clis/qwen/status.js +1 -1
  74. package/clis/reddit/comment.js +1 -1
  75. package/clis/reddit/frontpage.js +1 -1
  76. package/clis/reddit/popular.js +1 -1
  77. package/clis/reddit/read.js +1 -1
  78. package/clis/reddit/read.test.js +2 -2
  79. package/clis/reddit/reply.js +182 -0
  80. package/clis/reddit/reply.test.js +89 -0
  81. package/clis/reddit/save.js +1 -1
  82. package/clis/reddit/saved.js +1 -1
  83. package/clis/reddit/search.js +1 -1
  84. package/clis/reddit/subreddit.js +1 -1
  85. package/clis/reddit/subscribe.js +1 -1
  86. package/clis/reddit/upvote.js +1 -1
  87. package/clis/reddit/upvoted.js +1 -1
  88. package/clis/reddit/user-comments.js +1 -1
  89. package/clis/reddit/user-posts.js +1 -1
  90. package/clis/reddit/user.js +1 -1
  91. package/clis/rednote/comments.js +76 -0
  92. package/clis/rednote/download.js +59 -0
  93. package/clis/rednote/feed.js +95 -0
  94. package/clis/rednote/navigation.test.js +26 -0
  95. package/clis/rednote/note.js +68 -0
  96. package/clis/rednote/notifications.js +139 -0
  97. package/clis/rednote/rednote.test.js +157 -0
  98. package/clis/rednote/search.js +97 -0
  99. package/clis/rednote/user.js +55 -0
  100. package/clis/twitter/article.js +1 -1
  101. package/clis/twitter/bookmark-folder.js +1 -1
  102. package/clis/twitter/bookmark-folders.js +1 -1
  103. package/clis/twitter/bookmarks.js +1 -1
  104. package/clis/twitter/download.js +1 -1
  105. package/clis/twitter/followers.js +1 -1
  106. package/clis/twitter/following.js +1 -1
  107. package/clis/twitter/likes.js +1 -1
  108. package/clis/twitter/list-tweets.js +1 -1
  109. package/clis/twitter/lists.js +1 -1
  110. package/clis/twitter/notifications.js +1 -1
  111. package/clis/twitter/profile.js +1 -1
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/thread.js +1 -1
  114. package/clis/twitter/timeline.js +1 -1
  115. package/clis/twitter/trending.js +1 -1
  116. package/clis/twitter/tweets.js +1 -1
  117. package/clis/xiaohongshu/comments.js +34 -24
  118. package/clis/xiaohongshu/download.js +32 -23
  119. package/clis/xiaohongshu/feed.js +23 -15
  120. package/clis/xiaohongshu/note-helpers.js +16 -6
  121. package/clis/xiaohongshu/note.js +26 -20
  122. package/clis/xiaohongshu/notifications.js +26 -19
  123. package/clis/xiaohongshu/search.js +37 -28
  124. package/clis/xiaohongshu/user-helpers.js +13 -4
  125. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  126. package/clis/xiaohongshu/user.js +9 -4
  127. package/clis/youtube/transcript.js +28 -3
  128. package/clis/youtube/transcript.test.js +90 -1
  129. package/clis/yuanbao/ask.js +1 -1
  130. package/clis/yuanbao/detail.js +1 -1
  131. package/clis/yuanbao/history.js +1 -1
  132. package/clis/yuanbao/new.js +1 -1
  133. package/clis/yuanbao/read.js +1 -1
  134. package/clis/yuanbao/send.js +1 -1
  135. package/clis/yuanbao/status.js +1 -1
  136. package/dist/src/browser/bridge.d.ts +3 -1
  137. package/dist/src/browser/bridge.js +3 -1
  138. package/dist/src/browser/cdp.d.ts +3 -1
  139. package/dist/src/browser/daemon-client.d.ts +7 -14
  140. package/dist/src/browser/daemon-client.js +2 -6
  141. package/dist/src/browser/network-cache.d.ts +5 -5
  142. package/dist/src/browser/network-cache.js +8 -8
  143. package/dist/src/browser/network-cache.test.js +4 -4
  144. package/dist/src/browser/page.d.ts +8 -7
  145. package/dist/src/browser/page.js +23 -16
  146. package/dist/src/browser/page.test.js +60 -30
  147. package/dist/src/build-manifest.js +1 -1
  148. package/dist/src/cli.js +60 -162
  149. package/dist/src/cli.test.js +184 -198
  150. package/dist/src/commanderAdapter.js +2 -0
  151. package/dist/src/discovery.js +1 -1
  152. package/dist/src/doctor.d.ts +0 -4
  153. package/dist/src/doctor.js +14 -73
  154. package/dist/src/doctor.test.js +28 -97
  155. package/dist/src/execution.d.ts +1 -0
  156. package/dist/src/execution.js +20 -21
  157. package/dist/src/execution.test.js +27 -31
  158. package/dist/src/help.js +7 -1
  159. package/dist/src/main.js +0 -19
  160. package/dist/src/manifest-types.d.ts +2 -4
  161. package/dist/src/observation/artifact.js +1 -1
  162. package/dist/src/observation/artifact.test.js +3 -3
  163. package/dist/src/observation/events.d.ts +1 -1
  164. package/dist/src/observation/manager.js +1 -1
  165. package/dist/src/observation/manager.test.js +3 -3
  166. package/dist/src/registry-api.d.ts +1 -1
  167. package/dist/src/registry.d.ts +3 -12
  168. package/dist/src/registry.js +6 -10
  169. package/dist/src/runtime.d.ts +7 -2
  170. package/dist/src/runtime.js +3 -1
  171. package/dist/src/serialization.d.ts +1 -1
  172. package/dist/src/serialization.js +1 -1
  173. package/dist/src/types.d.ts +0 -15
  174. package/package.json +1 -1
@@ -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
+ });
@@ -11,7 +11,7 @@ cli({
11
11
  domain: 'x.com',
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
14
- browserSession: { reuse: 'site' },
14
+ siteSession: 'persistent',
15
15
  args: [
16
16
  { name: 'tweet-id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
17
17
  ],
@@ -122,7 +122,7 @@ cli({
122
122
  domain: 'x.com',
123
123
  strategy: Strategy.COOKIE,
124
124
  browser: true,
125
- browserSession: { reuse: 'site' },
125
+ siteSession: 'persistent',
126
126
  args: [
127
127
  { name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
128
128
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
@@ -77,7 +77,7 @@ cli({
77
77
  domain: 'x.com',
78
78
  strategy: Strategy.COOKIE,
79
79
  browser: true,
80
- browserSession: { reuse: 'site' },
80
+ siteSession: 'persistent',
81
81
  args: [],
82
82
  columns: ['id', 'name', 'items', 'created_at'],
83
83
  func: async (page) => {
@@ -105,7 +105,7 @@ cli({
105
105
  domain: 'x.com',
106
106
  strategy: Strategy.COOKIE,
107
107
  browser: true,
108
- browserSession: { reuse: 'site' },
108
+ siteSession: 'persistent',
109
109
  args: [
110
110
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
111
111
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
@@ -15,7 +15,7 @@ cli({
15
15
  description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
16
16
  domain: 'x.com',
17
17
  strategy: Strategy.COOKIE,
18
- browserSession: { reuse: 'site' },
18
+ siteSession: 'persistent',
19
19
  args: [
20
20
  { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
21
21
  { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
@@ -83,7 +83,7 @@ cli({
83
83
  domain: 'x.com',
84
84
  strategy: Strategy.UI,
85
85
  browser: true,
86
- browserSession: { reuse: 'site' },
86
+ siteSession: 'persistent',
87
87
  args: [
88
88
  {
89
89
  name: 'user',
@@ -139,7 +139,7 @@ cli({
139
139
  domain: 'x.com',
140
140
  strategy: Strategy.COOKIE,
141
141
  browser: true,
142
- browserSession: { reuse: 'site' },
142
+ siteSession: 'persistent',
143
143
  args: [
144
144
  {
145
145
  name: 'user',
@@ -142,7 +142,7 @@ cli({
142
142
  domain: 'x.com',
143
143
  strategy: Strategy.COOKIE,
144
144
  browser: true,
145
- browserSession: { reuse: 'site' },
145
+ siteSession: 'persistent',
146
146
  args: [
147
147
  { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
148
148
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
@@ -112,7 +112,7 @@ cli({
112
112
  domain: 'x.com',
113
113
  strategy: Strategy.COOKIE,
114
114
  browser: true,
115
- browserSession: { reuse: 'site' },
115
+ siteSession: 'persistent',
116
116
  args: [
117
117
  { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
118
118
  { name: 'limit', type: 'int', default: 50 },
@@ -92,7 +92,7 @@ export const command = cli({
92
92
  domain: 'x.com',
93
93
  strategy: Strategy.COOKIE,
94
94
  browser: true,
95
- browserSession: { reuse: 'site' },
95
+ siteSession: 'persistent',
96
96
  args: [
97
97
  { name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
98
98
  ],
@@ -8,7 +8,7 @@ cli({
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.INTERCEPT,
10
10
  browser: true,
11
- browserSession: { reuse: 'site' },
11
+ siteSession: 'persistent',
12
12
  args: [
13
13
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
14
14
  ],
@@ -11,7 +11,7 @@ cli({
11
11
  domain: 'x.com',
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
14
- browserSession: { reuse: 'site' },
14
+ siteSession: 'persistent',
15
15
  args: [
16
16
  { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
17
17
  ],
@@ -228,7 +228,7 @@ cli({
228
228
  domain: 'x.com',
229
229
  strategy: Strategy.INTERCEPT, // Use intercept strategy
230
230
  browser: true,
231
- browserSession: { reuse: 'site' },
231
+ siteSession: 'persistent',
232
232
  args: [
233
233
  { name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
234
234
  { name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
@@ -100,7 +100,7 @@ cli({
100
100
  domain: 'x.com',
101
101
  strategy: Strategy.COOKIE,
102
102
  browser: true,
103
- browserSession: { reuse: 'site' },
103
+ siteSession: 'persistent',
104
104
  args: [
105
105
  { name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
106
106
  { name: 'limit', type: 'int', default: 50 },
@@ -141,7 +141,7 @@ cli({
141
141
  domain: 'x.com',
142
142
  strategy: Strategy.COOKIE,
143
143
  browser: true,
144
- browserSession: { reuse: 'site' },
144
+ siteSession: 'persistent',
145
145
  args: [
146
146
  {
147
147
  name: 'type',
@@ -17,7 +17,7 @@ cli({
17
17
  domain: 'x.com',
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
- browserSession: { reuse: 'site' },
20
+ siteSession: 'persistent',
21
21
  args: [
22
22
  { name: 'limit', type: 'int', default: 20, help: 'Number of trends to show' },
23
23
  ],
@@ -149,7 +149,7 @@ cli({
149
149
  domain: 'x.com',
150
150
  strategy: Strategy.COOKIE,
151
151
  browser: true,
152
- browserSession: { reuse: 'site' },
152
+ siteSession: 'persistent',
153
153
  args: [
154
154
  { name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
155
155
  { name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
@@ -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
  }