@jackwener/opencli 1.3.1 → 1.3.3

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 (241) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +48 -9
  3. package/README.zh-CN.md +48 -9
  4. package/SKILL.md +317 -6
  5. package/TESTING.md +4 -4
  6. package/dist/browser/cdp.js +10 -1
  7. package/dist/browser/daemon-client.js +2 -1
  8. package/dist/browser/discover.js +2 -1
  9. package/dist/browser/errors.d.ts +2 -1
  10. package/dist/browser/errors.js +10 -10
  11. package/dist/browser/index.d.ts +1 -0
  12. package/dist/browser/index.js +1 -0
  13. package/dist/browser/page.js +12 -0
  14. package/dist/browser/stealth.d.ts +18 -0
  15. package/dist/browser/stealth.js +140 -0
  16. package/dist/browser.test.js +47 -1
  17. package/dist/build-manifest.js +1 -3
  18. package/dist/cli-manifest.json +2573 -989
  19. package/dist/cli.js +42 -2
  20. package/dist/clis/bilibili/download.js +20 -65
  21. package/dist/clis/bilibili/utils.js +2 -1
  22. package/dist/clis/chaoxing/assignments.js +2 -1
  23. package/dist/clis/doubao/ask.d.ts +1 -0
  24. package/dist/clis/doubao/ask.js +35 -0
  25. package/dist/clis/doubao/common.d.ts +23 -0
  26. package/dist/clis/doubao/common.js +564 -0
  27. package/dist/clis/doubao/new.d.ts +1 -0
  28. package/dist/clis/doubao/new.js +20 -0
  29. package/dist/clis/doubao/read.d.ts +1 -0
  30. package/dist/clis/doubao/read.js +19 -0
  31. package/dist/clis/doubao/send.d.ts +1 -0
  32. package/dist/clis/doubao/send.js +22 -0
  33. package/dist/clis/doubao/status.d.ts +1 -0
  34. package/dist/clis/doubao/status.js +24 -0
  35. package/dist/clis/doubao-app/ask.d.ts +1 -0
  36. package/dist/clis/doubao-app/ask.js +53 -0
  37. package/dist/clis/doubao-app/common.d.ts +37 -0
  38. package/dist/clis/doubao-app/common.js +110 -0
  39. package/dist/clis/doubao-app/dump.d.ts +1 -0
  40. package/dist/clis/doubao-app/dump.js +24 -0
  41. package/dist/clis/doubao-app/new.d.ts +1 -0
  42. package/dist/clis/doubao-app/new.js +20 -0
  43. package/dist/clis/doubao-app/read.d.ts +1 -0
  44. package/dist/clis/doubao-app/read.js +18 -0
  45. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  46. package/dist/clis/doubao-app/screenshot.js +18 -0
  47. package/dist/clis/doubao-app/send.d.ts +1 -0
  48. package/dist/clis/doubao-app/send.js +27 -0
  49. package/dist/clis/doubao-app/status.d.ts +1 -0
  50. package/dist/clis/doubao-app/status.js +16 -0
  51. package/dist/clis/hackernews/ask.yaml +38 -0
  52. package/dist/clis/hackernews/best.yaml +38 -0
  53. package/dist/clis/hackernews/jobs.yaml +36 -0
  54. package/dist/clis/hackernews/new.yaml +38 -0
  55. package/dist/clis/hackernews/search.yaml +44 -0
  56. package/dist/clis/hackernews/show.yaml +38 -0
  57. package/dist/clis/hackernews/top.yaml +3 -1
  58. package/dist/clis/hackernews/user.yaml +25 -0
  59. package/dist/clis/twitter/download.js +13 -97
  60. package/dist/clis/twitter/thread.js +2 -1
  61. package/dist/clis/v2ex/member.yaml +29 -0
  62. package/dist/clis/v2ex/node.yaml +34 -0
  63. package/dist/clis/v2ex/nodes.yaml +31 -0
  64. package/dist/clis/v2ex/replies.yaml +32 -0
  65. package/dist/clis/v2ex/user.yaml +34 -0
  66. package/dist/clis/weibo/search.d.ts +1 -0
  67. package/dist/clis/weibo/search.js +73 -0
  68. package/dist/clis/weixin/download.d.ts +12 -0
  69. package/dist/clis/weixin/download.js +183 -0
  70. package/dist/clis/xiaohongshu/download.js +12 -60
  71. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  72. package/dist/clis/xiaohongshu/publish.js +352 -0
  73. package/dist/clis/xiaohongshu/search.js +47 -15
  74. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  75. package/dist/clis/xiaohongshu/search.test.js +114 -0
  76. package/dist/clis/yollomi/background.d.ts +4 -0
  77. package/dist/clis/yollomi/background.js +45 -0
  78. package/dist/clis/yollomi/edit.d.ts +5 -0
  79. package/dist/clis/yollomi/edit.js +56 -0
  80. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  81. package/dist/clis/yollomi/face-swap.js +43 -0
  82. package/dist/clis/yollomi/generate.d.ts +9 -0
  83. package/dist/clis/yollomi/generate.js +100 -0
  84. package/dist/clis/yollomi/models.d.ts +1 -0
  85. package/dist/clis/yollomi/models.js +33 -0
  86. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  87. package/dist/clis/yollomi/object-remover.js +42 -0
  88. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  89. package/dist/clis/yollomi/remove-bg.js +38 -0
  90. package/dist/clis/yollomi/restore.d.ts +4 -0
  91. package/dist/clis/yollomi/restore.js +38 -0
  92. package/dist/clis/yollomi/try-on.d.ts +4 -0
  93. package/dist/clis/yollomi/try-on.js +46 -0
  94. package/dist/clis/yollomi/upload.d.ts +7 -0
  95. package/dist/clis/yollomi/upload.js +71 -0
  96. package/dist/clis/yollomi/upscale.d.ts +4 -0
  97. package/dist/clis/yollomi/upscale.js +53 -0
  98. package/dist/clis/yollomi/utils.d.ts +45 -0
  99. package/dist/clis/yollomi/utils.js +180 -0
  100. package/dist/clis/yollomi/video.d.ts +5 -0
  101. package/dist/clis/yollomi/video.js +56 -0
  102. package/dist/clis/zhihu/download.d.ts +1 -5
  103. package/dist/clis/zhihu/download.js +20 -126
  104. package/dist/clis/zhihu/download.test.js +7 -5
  105. package/dist/clis/zhihu/question.js +2 -1
  106. package/dist/commanderAdapter.js +4 -6
  107. package/dist/constants.d.ts +2 -0
  108. package/dist/constants.js +2 -0
  109. package/dist/daemon.js +7 -3
  110. package/dist/discovery.js +10 -10
  111. package/dist/doctor.js +2 -1
  112. package/dist/download/article-download.d.ts +59 -0
  113. package/dist/download/article-download.js +178 -0
  114. package/dist/download/media-download.d.ts +49 -0
  115. package/dist/download/media-download.js +112 -0
  116. package/dist/errors.d.ts +23 -2
  117. package/dist/errors.js +58 -2
  118. package/dist/errors.test.d.ts +1 -0
  119. package/dist/errors.test.js +59 -0
  120. package/dist/execution.js +9 -10
  121. package/dist/explore.js +4 -2
  122. package/dist/external.d.ts +15 -0
  123. package/dist/external.js +48 -2
  124. package/dist/external.test.d.ts +1 -0
  125. package/dist/external.test.js +64 -0
  126. package/dist/main.js +10 -0
  127. package/dist/plugin.d.ts +4 -0
  128. package/dist/plugin.js +45 -23
  129. package/dist/plugin.test.js +6 -1
  130. package/dist/record.d.ts +47 -0
  131. package/dist/record.js +545 -0
  132. package/dist/registry.d.ts +7 -2
  133. package/dist/registry.js +2 -6
  134. package/dist/runtime.d.ts +3 -1
  135. package/dist/runtime.js +10 -3
  136. package/dist/validate.js +1 -3
  137. package/docs/.vitepress/config.mts +1 -0
  138. package/docs/adapters/browser/douban.md +18 -8
  139. package/docs/adapters/browser/doubao.md +35 -0
  140. package/docs/adapters/browser/hackernews.md +20 -4
  141. package/docs/adapters/browser/tiktok.md +1 -1
  142. package/docs/adapters/browser/v2ex.md +31 -10
  143. package/docs/adapters/browser/weibo.md +4 -0
  144. package/docs/adapters/browser/weixin.md +33 -0
  145. package/docs/adapters/browser/wikipedia.md +0 -9
  146. package/docs/adapters/browser/xiaohongshu.md +8 -6
  147. package/docs/adapters/browser/yollomi.md +69 -0
  148. package/docs/adapters/desktop/antigravity.md +0 -3
  149. package/docs/adapters/desktop/doubao-app.md +35 -0
  150. package/docs/adapters/index.md +19 -8
  151. package/docs/advanced/download.md +4 -0
  152. package/package.json +3 -1
  153. package/src/browser/cdp.ts +9 -1
  154. package/src/browser/daemon-client.ts +4 -3
  155. package/src/browser/discover.ts +2 -1
  156. package/src/browser/errors.ts +18 -11
  157. package/src/browser/index.ts +1 -0
  158. package/src/browser/page.ts +11 -0
  159. package/src/browser/stealth.ts +142 -0
  160. package/src/browser.test.ts +51 -1
  161. package/src/build-manifest.ts +1 -3
  162. package/src/cli.ts +45 -2
  163. package/src/clis/bilibili/download.ts +25 -83
  164. package/src/clis/bilibili/utils.ts +2 -1
  165. package/src/clis/chaoxing/assignments.ts +2 -1
  166. package/src/clis/doubao/ask.ts +40 -0
  167. package/src/clis/doubao/common.ts +619 -0
  168. package/src/clis/doubao/new.ts +22 -0
  169. package/src/clis/doubao/read.ts +20 -0
  170. package/src/clis/doubao/send.ts +25 -0
  171. package/src/clis/doubao/status.ts +27 -0
  172. package/src/clis/doubao-app/ask.ts +60 -0
  173. package/src/clis/doubao-app/common.ts +116 -0
  174. package/src/clis/doubao-app/dump.ts +28 -0
  175. package/src/clis/doubao-app/new.ts +21 -0
  176. package/src/clis/doubao-app/read.ts +21 -0
  177. package/src/clis/doubao-app/screenshot.ts +19 -0
  178. package/src/clis/doubao-app/send.ts +30 -0
  179. package/src/clis/doubao-app/status.ts +17 -0
  180. package/src/clis/hackernews/ask.yaml +38 -0
  181. package/src/clis/hackernews/best.yaml +38 -0
  182. package/src/clis/hackernews/jobs.yaml +36 -0
  183. package/src/clis/hackernews/new.yaml +38 -0
  184. package/src/clis/hackernews/search.yaml +44 -0
  185. package/src/clis/hackernews/show.yaml +38 -0
  186. package/src/clis/hackernews/top.yaml +3 -1
  187. package/src/clis/hackernews/user.yaml +25 -0
  188. package/src/clis/twitter/download.ts +13 -111
  189. package/src/clis/twitter/thread.ts +2 -1
  190. package/src/clis/v2ex/member.yaml +29 -0
  191. package/src/clis/v2ex/node.yaml +34 -0
  192. package/src/clis/v2ex/nodes.yaml +31 -0
  193. package/src/clis/v2ex/replies.yaml +32 -0
  194. package/src/clis/v2ex/user.yaml +34 -0
  195. package/src/clis/weibo/search.ts +78 -0
  196. package/src/clis/weixin/download.ts +199 -0
  197. package/src/clis/xiaohongshu/download.ts +12 -71
  198. package/src/clis/xiaohongshu/publish.ts +392 -0
  199. package/src/clis/xiaohongshu/search.test.ts +134 -0
  200. package/src/clis/xiaohongshu/search.ts +49 -15
  201. package/src/clis/yollomi/background.ts +48 -0
  202. package/src/clis/yollomi/edit.ts +58 -0
  203. package/src/clis/yollomi/face-swap.ts +45 -0
  204. package/src/clis/yollomi/generate.ts +95 -0
  205. package/src/clis/yollomi/models.ts +38 -0
  206. package/src/clis/yollomi/object-remover.ts +44 -0
  207. package/src/clis/yollomi/remove-bg.ts +40 -0
  208. package/src/clis/yollomi/restore.ts +40 -0
  209. package/src/clis/yollomi/try-on.ts +48 -0
  210. package/src/clis/yollomi/upload.ts +78 -0
  211. package/src/clis/yollomi/upscale.ts +49 -0
  212. package/src/clis/yollomi/utils.ts +202 -0
  213. package/src/clis/yollomi/video.ts +61 -0
  214. package/src/clis/zhihu/download.test.ts +7 -5
  215. package/src/clis/zhihu/download.ts +23 -158
  216. package/src/clis/zhihu/question.ts +2 -1
  217. package/src/commanderAdapter.ts +4 -7
  218. package/src/constants.ts +3 -0
  219. package/src/daemon.ts +7 -3
  220. package/src/discovery.ts +26 -26
  221. package/src/doctor.ts +2 -1
  222. package/src/download/article-download.ts +272 -0
  223. package/src/download/media-download.ts +178 -0
  224. package/src/errors.test.ts +79 -0
  225. package/src/errors.ts +92 -2
  226. package/src/execution.ts +14 -10
  227. package/src/explore.ts +4 -2
  228. package/src/external.test.ts +88 -0
  229. package/src/external.ts +56 -2
  230. package/src/generate.ts +2 -1
  231. package/src/main.ts +10 -0
  232. package/src/plugin.test.ts +7 -1
  233. package/src/plugin.ts +49 -25
  234. package/src/record.ts +617 -0
  235. package/src/registry.ts +9 -5
  236. package/src/runtime.ts +16 -4
  237. package/src/validate.ts +1 -3
  238. package/tests/e2e/browser-auth.test.ts +10 -1
  239. package/tests/e2e/browser-public.test.ts +13 -8
  240. package/tests/e2e/public-commands.test.ts +209 -21
  241. package/tests/smoke/api-health.test.ts +65 -6
@@ -4,11 +4,9 @@
4
4
  * Usage:
5
5
  * opencli xiaohongshu download --note_id abc123 --output ./xhs
6
6
  */
7
- import * as fs from 'node:fs';
8
- import * as path from 'node:path';
9
7
  import { cli, Strategy } from '../../registry.js';
10
- import { httpDownload, formatCookieHeader, } from '../../download/index.js';
11
- import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
8
+ import { formatCookieHeader } from '../../download/index.js';
9
+ import { downloadMedia } from '../../download/media-download.js';
12
10
  cli({
13
11
  site: 'xiaohongshu',
14
12
  name: 'download',
@@ -50,7 +48,7 @@ cli({
50
48
  '.note-slider img',
51
49
  '.note-image img',
52
50
  '.image-wrapper img',
53
- '#noteContainer img[src*="xhscdn"]',
51
+ '#noteContainer .media-container img[src*="xhscdn"]',
54
52
  'img[src*="ci.xiaohongshu.com"]'
55
53
  ];
56
54
 
@@ -61,7 +59,6 @@ cli({
61
59
  if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
62
60
  // Convert to high quality URL (remove resize parameters)
63
61
  src = src.split('?')[0];
64
- // Try to get original size
65
62
  src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
66
63
  imageUrls.add(src);
67
64
  }
@@ -80,20 +77,14 @@ cli({
80
77
  document.querySelectorAll(selector).forEach(v => {
81
78
  const src = v.src || v.getAttribute('src') || '';
82
79
  if (src) {
83
- result.media.push({
84
- type: 'video',
85
- url: src
86
- });
80
+ result.media.push({ type: 'video', url: src });
87
81
  }
88
82
  });
89
83
  }
90
84
 
91
85
  // Add images to media
92
86
  imageUrls.forEach(url => {
93
- result.media.push({
94
- type: 'image',
95
- url: url
96
- });
87
+ result.media.push({ type: 'image', url: url });
97
88
  });
98
89
 
99
90
  return result;
@@ -104,51 +95,12 @@ cli({
104
95
  }
105
96
  // Extract cookies for authenticated downloads
106
97
  const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
107
- // Create output directory
108
- const outputDir = path.join(output, noteId);
109
- fs.mkdirSync(outputDir, { recursive: true });
110
- // Download all media files
111
- const tracker = new DownloadProgressTracker(data.media.length, true);
112
- const results = [];
113
- for (let i = 0; i < data.media.length; i++) {
114
- const media = data.media[i];
115
- const ext = media.type === 'video' ? 'mp4' : 'jpg';
116
- const filename = `${noteId}_${i + 1}.${ext}`;
117
- const destPath = path.join(outputDir, filename);
118
- const progressBar = tracker.onFileStart(filename, i);
119
- try {
120
- const result = await httpDownload(media.url, destPath, {
121
- cookies,
122
- timeout: 60000,
123
- onProgress: (received, total) => {
124
- if (progressBar)
125
- progressBar.update(received, total);
126
- },
127
- });
128
- if (progressBar) {
129
- progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
130
- }
131
- tracker.onFileComplete(result.success);
132
- results.push({
133
- index: i + 1,
134
- type: media.type,
135
- status: result.success ? 'success' : 'failed',
136
- size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
137
- });
138
- }
139
- catch (err) {
140
- if (progressBar)
141
- progressBar.fail(err.message);
142
- tracker.onFileComplete(false);
143
- results.push({
144
- index: i + 1,
145
- type: media.type,
146
- status: 'failed',
147
- size: err.message,
148
- });
149
- }
150
- }
151
- tracker.finish();
152
- return results;
98
+ return downloadMedia(data.media, {
99
+ output,
100
+ subdir: noteId,
101
+ cookies,
102
+ filenamePrefix: noteId,
103
+ timeout: 60000,
104
+ });
153
105
  },
154
106
  });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Xiaohongshu 图文笔记 publisher — creator center UI automation.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to creator publish page
6
+ * 2. Upload images via DataTransfer injection into the file input
7
+ * 3. Fill title and body text
8
+ * 4. Add topic hashtags
9
+ * 5. Publish (or save as draft)
10
+ *
11
+ * Requires: logged into creator.xiaohongshu.com in Chrome.
12
+ *
13
+ * Usage:
14
+ * opencli xiaohongshu publish --title "标题" "正文内容" \
15
+ * --images /path/a.jpg,/path/b.jpg \
16
+ * --topics 生活,旅行
17
+ */
18
+ export {};
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Xiaohongshu 图文笔记 publisher — creator center UI automation.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to creator publish page
6
+ * 2. Upload images via DataTransfer injection into the file input
7
+ * 3. Fill title and body text
8
+ * 4. Add topic hashtags
9
+ * 5. Publish (or save as draft)
10
+ *
11
+ * Requires: logged into creator.xiaohongshu.com in Chrome.
12
+ *
13
+ * Usage:
14
+ * opencli xiaohongshu publish --title "标题" "正文内容" \
15
+ * --images /path/a.jpg,/path/b.jpg \
16
+ * --topics 生活,旅行
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { cli, Strategy } from '../../registry.js';
21
+ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
22
+ const MAX_IMAGES = 9;
23
+ const MAX_TITLE_LEN = 20;
24
+ const UPLOAD_SETTLE_MS = 3000;
25
+ /**
26
+ * Read a local image and return the name, MIME type, and base64 content.
27
+ * Throws if the file does not exist or the extension is unsupported.
28
+ */
29
+ function readImageFile(filePath) {
30
+ const absPath = path.resolve(filePath);
31
+ if (!fs.existsSync(absPath))
32
+ throw new Error(`Image file not found: ${absPath}`);
33
+ const ext = path.extname(absPath).toLowerCase();
34
+ const mimeMap = {
35
+ '.jpg': 'image/jpeg',
36
+ '.jpeg': 'image/jpeg',
37
+ '.png': 'image/png',
38
+ '.gif': 'image/gif',
39
+ '.webp': 'image/webp',
40
+ };
41
+ const mimeType = mimeMap[ext];
42
+ if (!mimeType)
43
+ throw new Error(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
44
+ const base64 = fs.readFileSync(absPath).toString('base64');
45
+ return { name: path.basename(absPath), mimeType, base64 };
46
+ }
47
+ /**
48
+ * Inject images into the page's file input using DataTransfer.
49
+ * Converts base64 payloads to File objects in the browser context, then dispatches
50
+ * a synthetic 'change' event on the input element.
51
+ *
52
+ * Returns { ok, count, error }.
53
+ */
54
+ async function injectImages(page, images) {
55
+ const payload = JSON.stringify(images);
56
+ return page.evaluate(`
57
+ (async () => {
58
+ const images = ${payload};
59
+
60
+ // Prefer image/* file inputs; fall back to the first available input.
61
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
62
+ const input = inputs.find(el => {
63
+ const accept = el.getAttribute('accept') || '';
64
+ return accept.includes('image') || accept.includes('.jpg') || accept.includes('.png');
65
+ }) || inputs[0];
66
+
67
+ if (!input) return { ok: false, count: 0, error: 'No file input found on page' };
68
+
69
+ const dt = new DataTransfer();
70
+ for (const img of images) {
71
+ try {
72
+ const binary = atob(img.base64);
73
+ const bytes = new Uint8Array(binary.length);
74
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
75
+ const blob = new Blob([bytes], { type: img.mimeType });
76
+ dt.items.add(new File([blob], img.name, { type: img.mimeType }));
77
+ } catch (e) {
78
+ return { ok: false, count: 0, error: 'Failed to create File: ' + e.message };
79
+ }
80
+ }
81
+
82
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false });
83
+ input.dispatchEvent(new Event('change', { bubbles: true }));
84
+ input.dispatchEvent(new Event('input', { bubbles: true }));
85
+
86
+ return { ok: true, count: dt.files.length };
87
+ })()
88
+ `);
89
+ }
90
+ /**
91
+ * Wait until all upload progress indicators have disappeared (up to maxWaitMs).
92
+ */
93
+ async function waitForUploads(page, maxWaitMs = 30_000) {
94
+ const pollMs = 2_000;
95
+ const maxAttempts = Math.ceil(maxWaitMs / pollMs);
96
+ for (let i = 0; i < maxAttempts; i++) {
97
+ const uploading = await page.evaluate(`
98
+ () => !!document.querySelector(
99
+ '[class*="upload"][class*="progress"], [class*="uploading"], [class*="loading"][class*="image"]'
100
+ )
101
+ `);
102
+ if (!uploading)
103
+ return;
104
+ await page.wait({ time: pollMs / 1_000 });
105
+ }
106
+ }
107
+ /**
108
+ * Fill a visible text input or contenteditable with the given text.
109
+ * Tries multiple selectors in priority order.
110
+ * Returns { ok, sel }.
111
+ */
112
+ async function fillField(page, selectors, text, fieldName) {
113
+ const result = await page.evaluate(`
114
+ (function(selectors, text) {
115
+ for (const sel of selectors) {
116
+ const candidates = document.querySelectorAll(sel);
117
+ for (const el of candidates) {
118
+ if (!el || el.offsetParent === null) continue;
119
+ el.focus();
120
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
121
+ el.value = '';
122
+ document.execCommand('selectAll', false);
123
+ document.execCommand('insertText', false, text);
124
+ el.dispatchEvent(new Event('input', { bubbles: true }));
125
+ el.dispatchEvent(new Event('change', { bubbles: true }));
126
+ } else {
127
+ // contenteditable
128
+ el.textContent = '';
129
+ document.execCommand('selectAll', false);
130
+ document.execCommand('insertText', false, text);
131
+ el.dispatchEvent(new Event('input', { bubbles: true }));
132
+ }
133
+ return { ok: true, sel };
134
+ }
135
+ }
136
+ return { ok: false };
137
+ })(${JSON.stringify(selectors)}, ${JSON.stringify(text)})
138
+ `);
139
+ if (!result.ok) {
140
+ await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` });
141
+ throw new Error(`Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
142
+ }
143
+ }
144
+ cli({
145
+ site: 'xiaohongshu',
146
+ name: 'publish',
147
+ description: '小红书发布图文笔记 (creator center UI automation)',
148
+ domain: 'creator.xiaohongshu.com',
149
+ strategy: Strategy.COOKIE,
150
+ browser: true,
151
+ args: [
152
+ { name: 'title', required: true, help: '笔记标题 (最多20字)' },
153
+ { name: 'content', required: true, positional: true, help: '笔记正文' },
154
+ { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
155
+ { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
156
+ { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
157
+ ],
158
+ columns: ['status', 'detail'],
159
+ func: async (page, kwargs) => {
160
+ if (!page)
161
+ throw new Error('Browser page required');
162
+ const title = String(kwargs.title ?? '').trim();
163
+ const content = String(kwargs.content ?? '').trim();
164
+ const imagePaths = kwargs.images
165
+ ? String(kwargs.images).split(',').map((s) => s.trim()).filter(Boolean)
166
+ : [];
167
+ const topics = kwargs.topics
168
+ ? String(kwargs.topics).split(',').map((s) => s.trim()).filter(Boolean)
169
+ : [];
170
+ const isDraft = Boolean(kwargs.draft);
171
+ // ── Validate inputs ────────────────────────────────────────────────────────
172
+ if (!title)
173
+ throw new Error('--title is required');
174
+ if (title.length > MAX_TITLE_LEN)
175
+ throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
176
+ if (!content)
177
+ throw new Error('Positional argument <content> is required');
178
+ if (imagePaths.length > MAX_IMAGES)
179
+ throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
180
+ // Read images in Node.js context before navigating (fast-fail on bad paths)
181
+ const imageData = imagePaths.map(readImageFile);
182
+ // ── Step 1: Navigate to publish page ──────────────────────────────────────
183
+ await page.goto(PUBLISH_URL);
184
+ await page.wait({ time: 3 });
185
+ // Verify we landed on the creator site (not redirected to login)
186
+ const pageUrl = await page.evaluate('() => location.href');
187
+ if (!pageUrl.includes('creator.xiaohongshu.com')) {
188
+ throw new Error('Redirected away from creator center — session may have expired. ' +
189
+ 'Re-capture browser login via: opencli xiaohongshu creator-profile');
190
+ }
191
+ // ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
192
+ const tabClicked = await page.evaluate(`
193
+ () => {
194
+ const allEls = document.querySelectorAll('[class*="tab"], [class*="note-type"], [class*="type-item"]');
195
+ for (const el of allEls) {
196
+ const text = el.innerText || el.textContent || '';
197
+ if ((text.includes('图文') || text.includes('图片')) && el.offsetParent !== null) {
198
+ el.click();
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+ `);
205
+ if (tabClicked)
206
+ await page.wait({ time: 1 });
207
+ // ── Step 3: Upload images ──────────────────────────────────────────────────
208
+ if (imageData.length > 0) {
209
+ const upload = await injectImages(page, imageData);
210
+ if (!upload.ok) {
211
+ await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
212
+ throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
213
+ 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
214
+ }
215
+ // Allow XHS to process and upload images to its CDN
216
+ await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
217
+ await waitForUploads(page);
218
+ }
219
+ // ── Step 4: Fill title ─────────────────────────────────────────────────────
220
+ await fillField(page, [
221
+ 'input[maxlength="20"]',
222
+ 'input[class*="title"]',
223
+ 'input[placeholder*="标题"]',
224
+ 'input[placeholder*="title" i]',
225
+ '.title-input input',
226
+ '.note-title input',
227
+ 'input[maxlength]',
228
+ ], title, 'title');
229
+ await page.wait({ time: 0.5 });
230
+ // ── Step 5: Fill content / body ────────────────────────────────────────────
231
+ await fillField(page, [
232
+ '[contenteditable="true"][class*="content"]',
233
+ '[contenteditable="true"][class*="editor"]',
234
+ '[contenteditable="true"][placeholder*="描述"]',
235
+ '[contenteditable="true"][placeholder*="正文"]',
236
+ '[contenteditable="true"][placeholder*="内容"]',
237
+ '.note-content [contenteditable="true"]',
238
+ '.editor-content [contenteditable="true"]',
239
+ // Broad fallback — last resort; filter out any title contenteditable
240
+ '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
241
+ ], content, 'content');
242
+ await page.wait({ time: 0.5 });
243
+ // ── Step 6: Add topic hashtags ─────────────────────────────────────────────
244
+ for (const topic of topics) {
245
+ // Click the "添加话题" button
246
+ const btnClicked = await page.evaluate(`
247
+ () => {
248
+ const candidates = document.querySelectorAll('*');
249
+ for (const el of candidates) {
250
+ const text = (el.innerText || el.textContent || '').trim();
251
+ if (
252
+ (text === '添加话题' || text === '# 话题' || text.startsWith('添加话题')) &&
253
+ el.offsetParent !== null &&
254
+ el.children.length === 0
255
+ ) {
256
+ el.click();
257
+ return true;
258
+ }
259
+ }
260
+ // fallback: look for a hashtag icon button
261
+ const hashBtn = document.querySelector('[class*="topic"][class*="btn"], [class*="hashtag"][class*="btn"]');
262
+ if (hashBtn) { hashBtn.click(); return true; }
263
+ return false;
264
+ }
265
+ `);
266
+ if (!btnClicked)
267
+ continue; // Skip topic if UI not found — non-fatal
268
+ await page.wait({ time: 1 });
269
+ // Type into the topic search input
270
+ const typed = await page.evaluate(`
271
+ (topicName => {
272
+ const input = document.querySelector(
273
+ '[class*="topic"] input, [class*="hashtag"] input, input[placeholder*="搜索话题"]'
274
+ );
275
+ if (!input || input.offsetParent === null) return false;
276
+ input.focus();
277
+ document.execCommand('insertText', false, topicName);
278
+ input.dispatchEvent(new Event('input', { bubbles: true }));
279
+ return true;
280
+ })(${JSON.stringify(topic)})
281
+ `);
282
+ if (!typed)
283
+ continue;
284
+ await page.wait({ time: 1.5 }); // Wait for autocomplete suggestions
285
+ // Click the first suggestion
286
+ await page.evaluate(`
287
+ () => {
288
+ const item = document.querySelector(
289
+ '[class*="topic-item"], [class*="hashtag-item"], [class*="suggest-item"], [class*="suggestion"] li'
290
+ );
291
+ if (item) item.click();
292
+ }
293
+ `);
294
+ await page.wait({ time: 0.5 });
295
+ }
296
+ // ── Step 7: Publish or save draft ─────────────────────────────────────────
297
+ const actionLabel = isDraft ? '存草稿' : '发布';
298
+ const btnClicked = await page.evaluate(`
299
+ (label => {
300
+ const buttons = document.querySelectorAll('button, [role="button"]');
301
+ for (const btn of buttons) {
302
+ const text = (btn.innerText || btn.textContent || '').trim();
303
+ if (
304
+ (text === label || text.includes(label) || text === '发布笔记') &&
305
+ btn.offsetParent !== null &&
306
+ !btn.disabled
307
+ ) {
308
+ btn.click();
309
+ return true;
310
+ }
311
+ }
312
+ return false;
313
+ })(${JSON.stringify(actionLabel)})
314
+ `);
315
+ if (!btnClicked) {
316
+ await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
317
+ throw new Error(`Could not find "${actionLabel}" button. ` +
318
+ 'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
319
+ }
320
+ // ── Step 8: Verify success ─────────────────────────────────────────────────
321
+ await page.wait({ time: 4 });
322
+ const finalUrl = await page.evaluate('() => location.href');
323
+ const successMsg = await page.evaluate(`
324
+ () => {
325
+ for (const el of document.querySelectorAll('*')) {
326
+ const text = (el.innerText || '').trim();
327
+ if (
328
+ el.children.length === 0 &&
329
+ (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
330
+ ) return text;
331
+ }
332
+ return '';
333
+ }
334
+ `);
335
+ const navigatedAway = !finalUrl.includes('/publish/publish');
336
+ const isSuccess = successMsg.length > 0 || navigatedAway;
337
+ const verb = isDraft ? '草稿已保存' : '发布成功';
338
+ return [
339
+ {
340
+ status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
341
+ detail: [
342
+ `"${title}"`,
343
+ imageData.length ? `${imageData.length}张图片` : '无图',
344
+ topics.length ? `话题: ${topics.join(' ')}` : '',
345
+ successMsg || finalUrl || '',
346
+ ]
347
+ .filter(Boolean)
348
+ .join(' · '),
349
+ },
350
+ ];
351
+ },
352
+ });
@@ -6,6 +6,7 @@
6
6
  * Ref: https://github.com/jackwener/opencli/issues/10
7
7
  */
8
8
  import { cli, Strategy } from '../../registry.js';
9
+ import { AuthRequiredError } from '../../errors.js';
9
10
  cli({
10
11
  site: 'xiaohongshu',
11
12
  name: 'search',
@@ -16,41 +17,72 @@ cli({
16
17
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
17
18
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
18
19
  ],
19
- columns: ['rank', 'title', 'author', 'likes'],
20
+ columns: ['rank', 'title', 'author', 'likes', 'url'],
20
21
  func: async (page, kwargs) => {
21
22
  const keyword = encodeURIComponent(kwargs.query);
22
23
  await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
23
24
  await page.wait(3);
24
25
  // Scroll a couple of times to load more results
25
26
  await page.autoScroll({ times: 2 });
26
- const data = await page.evaluate(`
27
+ const payload = await page.evaluate(`
27
28
  (() => {
28
- const notes = document.querySelectorAll('section.note-item');
29
+ const loginWall = /登录后查看搜索结果/.test(document.body.innerText || '');
30
+
31
+ const normalizeUrl = (href) => {
32
+ if (!href) return '';
33
+ if (href.startsWith('http://') || href.startsWith('https://')) return href;
34
+ if (href.startsWith('/')) return 'https://www.xiaohongshu.com' + href;
35
+ return '';
36
+ };
37
+
38
+ const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
39
+
29
40
  const results = [];
30
- notes.forEach(el => {
41
+ const seen = new Set();
42
+
43
+ document.querySelectorAll('section.note-item').forEach(el => {
31
44
  // Skip "related searches" sections
32
45
  if (el.classList.contains('query-note-item')) return;
33
46
 
34
- const titleEl = el.querySelector('.title, .note-title, a.title');
35
- const nameEl = el.querySelector('.name, .author-name, .nick-name');
47
+ const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
48
+ const nameEl = el.querySelector('a.author .name, .name, .author-name, .nick-name, a.author');
36
49
  const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
37
- const linkEl = el.querySelector('a[href*="/explore/"], a[href*="/search_result/"], a[href*="/note/"]');
50
+ // Prefer search_result link (preserves xsec_token) over generic /explore/ link
51
+ const detailLinkEl =
52
+ el.querySelector('a.cover.mask') ||
53
+ el.querySelector('a[href*="/search_result/"]') ||
54
+ el.querySelector('a[href*="/explore/"]') ||
55
+ el.querySelector('a[href*="/note/"]');
56
+ const authorLinkEl = el.querySelector('a.author, a[href*="/user/profile/"]');
57
+
58
+ const url = normalizeUrl(detailLinkEl?.getAttribute('href') || '');
59
+ if (!url) return;
38
60
 
39
- const href = linkEl?.getAttribute('href') || '';
40
- const noteId = href.match(/\\/(?:explore|note)\\/([a-zA-Z0-9]+)/)?.[1] || '';
61
+ const key = url;
62
+ if (seen.has(key)) return;
63
+ seen.add(key);
41
64
 
42
65
  results.push({
43
- title: (titleEl?.textContent || '').trim(),
44
- author: (nameEl?.textContent || '').trim(),
45
- likes: (likesEl?.textContent || '0').trim(),
46
- url: noteId ? 'https://www.xiaohongshu.com/explore/' + noteId : '',
66
+ title: cleanText(titleEl?.textContent || ''),
67
+ author: cleanText(nameEl?.textContent || ''),
68
+ likes: cleanText(likesEl?.textContent || '0'),
69
+ url,
70
+ author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
47
71
  });
48
72
  });
49
- return results;
73
+
74
+ return {
75
+ loginWall,
76
+ results,
77
+ };
50
78
  })()
51
79
  `);
52
- if (!Array.isArray(data))
80
+ if (!payload || typeof payload !== 'object')
53
81
  return [];
82
+ if (payload.loginWall) {
83
+ throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
84
+ }
85
+ const data = Array.isArray(payload.results) ? payload.results : [];
54
86
  return data
55
87
  .filter((item) => item.title)
56
88
  .slice(0, kwargs.limit)
@@ -0,0 +1 @@
1
+ import './search.js';