@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -22,10 +22,10 @@ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_l
22
22
  const MAX_IMAGES = 9;
23
23
  const MAX_TITLE_LEN = 20;
24
24
  const UPLOAD_SETTLE_MS = 3000;
25
- /** Selectors for the title field, ordered by priority (new UI first). */
25
+ /** Selectors for the title field, ordered by priority across current UI variants. */
26
26
  const TITLE_SELECTORS = [
27
- // New creator center (2026-03) uses contenteditable for the title field.
28
- // Placeholder observed: "填写标题会有更多赞哦"
27
+ // Some creator-center variants expose the title as contenteditable,
28
+ // others use a normal <input> with the same placeholder.
29
29
  '[contenteditable="true"][placeholder*="标题"]',
30
30
  '[contenteditable="true"][placeholder*="赞"]',
31
31
  '[contenteditable="true"][class*="title"]',
@@ -180,36 +180,157 @@ async function waitForUploads(page, maxWaitMs = 30_000) {
180
180
  * Returns { ok, sel }.
181
181
  */
182
182
  async function fillField(page, selectors, text, fieldName) {
183
- const result = await page.evaluate(`
184
- (function(selectors, text) {
183
+ const located = await page.evaluate(`
184
+ (function(selectors) {
185
+ const __opencli_xhs_fill_phase = "locate";
185
186
  for (const sel of selectors) {
186
187
  const candidates = document.querySelectorAll(sel);
187
188
  for (const el of candidates) {
188
189
  if (!el || el.offsetParent === null) continue;
189
- el.focus();
190
- if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
191
- el.value = '';
192
- document.execCommand('selectAll', false);
193
- document.execCommand('insertText', false, text);
194
- el.dispatchEvent(new Event('input', { bubbles: true }));
195
- el.dispatchEvent(new Event('change', { bubbles: true }));
196
- } else {
197
- // contenteditable
198
- el.textContent = '';
199
- document.execCommand('selectAll', false);
200
- document.execCommand('insertText', false, text);
201
- el.dispatchEvent(new Event('input', { bubbles: true }));
202
- }
203
- return { ok: true, sel };
190
+ const kind = el.isContentEditable
191
+ ? 'contenteditable'
192
+ : (el.tagName === 'TEXTAREA' ? 'textarea' : 'input');
193
+ return { ok: true, sel, kind };
204
194
  }
205
195
  }
206
196
  return { ok: false };
207
- })(${JSON.stringify(selectors)}, ${JSON.stringify(text)})
197
+ })(${JSON.stringify(selectors)})
208
198
  `);
209
- if (!result.ok) {
199
+ if (!located.ok) {
210
200
  await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` });
211
201
  throw new Error(`Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
212
202
  }
203
+ const applyInPage = () => page.evaluate(`
204
+ ((selector, expectedText) => {
205
+ const __opencli_xhs_fill_phase = "apply";
206
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
207
+ const fireBeforeInput = (el, value) => {
208
+ try {
209
+ el.dispatchEvent(new InputEvent('beforeinput', {
210
+ bubbles: true,
211
+ data: value,
212
+ inputType: 'insertText',
213
+ }));
214
+ } catch {
215
+ el.dispatchEvent(new Event('beforeinput', { bubbles: true }));
216
+ }
217
+ };
218
+ const fireInput = (el, value) => {
219
+ try {
220
+ el.dispatchEvent(new InputEvent('input', {
221
+ bubbles: true,
222
+ data: value,
223
+ inputType: 'insertText',
224
+ }));
225
+ } catch {
226
+ el.dispatchEvent(new Event('input', { bubbles: true }));
227
+ }
228
+ };
229
+ const el = Array.from(document.querySelectorAll(selector)).find(node => node && node.offsetParent !== null);
230
+ if (!el) return { ok: false, actual: '' };
231
+ el.focus();
232
+ fireBeforeInput(el, expectedText);
233
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
234
+ const proto = el.tagName === 'TEXTAREA'
235
+ ? HTMLTextAreaElement.prototype
236
+ : HTMLInputElement.prototype;
237
+ const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
238
+ if (nativeSetter) nativeSetter.call(el, expectedText);
239
+ else el.value = expectedText;
240
+ fireInput(el, expectedText);
241
+ el.dispatchEvent(new Event('change', { bubbles: true }));
242
+ el.blur();
243
+ return { ok: el.value === expectedText, actual: el.value || '' };
244
+ }
245
+ el.textContent = '';
246
+ const selection = window.getSelection();
247
+ const range = document.createRange();
248
+ range.selectNodeContents(el);
249
+ range.collapse(false);
250
+ selection?.removeAllRanges();
251
+ selection?.addRange(range);
252
+ const inserted = document.execCommand('insertText', false, expectedText);
253
+ if (!inserted) el.textContent = expectedText;
254
+ fireInput(el, expectedText);
255
+ el.dispatchEvent(new Event('change', { bubbles: true }));
256
+ el.blur();
257
+ const actual = normalize(el.innerText || el.textContent || '');
258
+ return { ok: actual === normalize(expectedText), actual };
259
+ })(${JSON.stringify(located.sel)}, ${JSON.stringify(text)})
260
+ `);
261
+ let result;
262
+ if (located.kind === 'contenteditable' && page.insertText) {
263
+ const prepared = await page.evaluate(`
264
+ ((selector, nextText) => {
265
+ const __opencli_xhs_fill_phase = "prepare";
266
+ const fireBeforeInput = (el, value) => {
267
+ try {
268
+ el.dispatchEvent(new InputEvent('beforeinput', {
269
+ bubbles: true,
270
+ data: value,
271
+ inputType: 'insertText',
272
+ }));
273
+ } catch {
274
+ el.dispatchEvent(new Event('beforeinput', { bubbles: true }));
275
+ }
276
+ };
277
+ const el = Array.from(document.querySelectorAll(selector)).find(node => node && node.offsetParent !== null);
278
+ if (!el) return { ok: false };
279
+ el.focus();
280
+ el.textContent = '';
281
+ const selection = window.getSelection();
282
+ const range = document.createRange();
283
+ range.selectNodeContents(el);
284
+ range.collapse(false);
285
+ selection?.removeAllRanges();
286
+ selection?.addRange(range);
287
+ fireBeforeInput(el, nextText);
288
+ return { ok: true };
289
+ })(${JSON.stringify(located.sel)}, ${JSON.stringify(text)})
290
+ `);
291
+ if (!prepared?.ok) {
292
+ await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` });
293
+ throw new Error(`Could not prepare ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
294
+ }
295
+ try {
296
+ await page.insertText(text);
297
+ result = await page.evaluate(`
298
+ ((selector, expectedText) => {
299
+ const __opencli_xhs_fill_phase = "verify";
300
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
301
+ const fireInput = (el, value) => {
302
+ try {
303
+ el.dispatchEvent(new InputEvent('input', {
304
+ bubbles: true,
305
+ data: value,
306
+ inputType: 'insertText',
307
+ }));
308
+ } catch {
309
+ el.dispatchEvent(new Event('input', { bubbles: true }));
310
+ }
311
+ };
312
+ const el = Array.from(document.querySelectorAll(selector)).find(node => node && node.offsetParent !== null);
313
+ if (!el) return { ok: false, actual: '' };
314
+ fireInput(el, expectedText);
315
+ el.dispatchEvent(new Event('change', { bubbles: true }));
316
+ el.blur();
317
+ const actual = normalize(el.innerText || el.textContent || '');
318
+ return { ok: actual === normalize(expectedText), actual };
319
+ })(${JSON.stringify(located.sel)}, ${JSON.stringify(text)})
320
+ `);
321
+ }
322
+ catch {
323
+ result = await applyInPage();
324
+ }
325
+ }
326
+ else {
327
+ result = await applyInPage();
328
+ }
329
+ if (!result?.ok) {
330
+ await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` });
331
+ const actual = typeof result?.actual === 'string' ? result.actual : '';
332
+ throw new Error(`Failed to set ${fieldName}. Expected "${text}", got "${actual}". Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
333
+ }
213
334
  }
214
335
  async function selectImageTextTab(page) {
215
336
  const result = await page.evaluate(`
@@ -500,17 +621,17 @@ cli({
500
621
  // ── Step 8: Verify success ─────────────────────────────────────────────────
501
622
  await page.wait({ time: 4 });
502
623
  const finalUrl = await page.evaluate('() => location.href');
624
+ const successMarkers = isDraft
625
+ ? ['草稿已保存', '暂存成功', '保存成功', '上传成功']
626
+ : ['发布成功', '上传成功'];
503
627
  const successMsg = await page.evaluate(`
504
- () => {
628
+ (markers => {
505
629
  for (const el of document.querySelectorAll('*')) {
506
630
  const text = (el.innerText || '').trim();
507
- if (
508
- el.children.length === 0 &&
509
- (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
510
- ) return text;
631
+ if (el.children.length === 0 && markers.some(marker => text.includes(marker))) return text;
511
632
  }
512
633
  return '';
513
- }
634
+ })(${JSON.stringify(successMarkers)})
514
635
  `);
515
636
  const navigatedAway = !finalUrl.includes('/publish/publish');
516
637
  const isSuccess = successMsg.length > 0 || navigatedAway;
@@ -33,7 +33,214 @@ function createPageMock(evaluateResults, overrides = {}) {
33
33
  ...overrides,
34
34
  };
35
35
  }
36
+ function createConditionalPageMock(evaluateImpl, overrides = {}) {
37
+ const page = createPageMock([], overrides);
38
+ page.evaluate.mockImplementation(async (js) => evaluateImpl(String(js)));
39
+ return page;
40
+ }
36
41
  describe('xiaohongshu publish', () => {
42
+ it('uses native insertText for contenteditable title fields when available', async () => {
43
+ const cmd = getRegistry().get('xiaohongshu/publish');
44
+ expect(cmd?.func).toBeTypeOf('function');
45
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
46
+ const imagePath = path.join(tempDir, 'demo.jpg');
47
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
48
+ const insertText = vi.fn().mockResolvedValue(undefined);
49
+ const page = createConditionalPageMock((code) => {
50
+ if (code.includes('location.href'))
51
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
52
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
53
+ return { ok: true, target: '上传图文', text: '上传图文' };
54
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
55
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
56
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
57
+ return { ok: true, count: 1 };
58
+ if (code.includes('[class*="upload"][class*="progress"]'))
59
+ return false;
60
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
61
+ return true;
62
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"')) {
63
+ return code.includes('[contenteditable="true"][placeholder*="标题"]')
64
+ ? { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable' }
65
+ : { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' };
66
+ }
67
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"prepare"'))
68
+ return { ok: true };
69
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"verify"')) {
70
+ return code.includes('[contenteditable="true"][placeholder*="标题"]')
71
+ ? { ok: true, actual: '标题走原生输入' }
72
+ : { ok: true, actual: '正文也走原生输入' };
73
+ }
74
+ if (code.includes('(function(selectors, text)')) {
75
+ return code.includes('[contenteditable="true"][placeholder*="标题"]')
76
+ ? { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '标题走原生输入' }
77
+ : { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable', actual: '正文也走原生输入' };
78
+ }
79
+ if (code.includes('labels.some'))
80
+ return true;
81
+ if (code.includes('for (const el of document.querySelectorAll'))
82
+ return '发布成功';
83
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
84
+ }, {
85
+ insertText,
86
+ });
87
+ const result = await cmd.func(page, {
88
+ title: '标题走原生输入',
89
+ content: '正文也走原生输入',
90
+ images: imagePath,
91
+ topics: '',
92
+ draft: false,
93
+ });
94
+ expect(insertText).toHaveBeenNthCalledWith(1, '标题走原生输入');
95
+ expect(insertText).toHaveBeenNthCalledWith(2, '正文也走原生输入');
96
+ expect(result).toEqual([
97
+ {
98
+ status: '✅ 发布成功',
99
+ detail: '"标题走原生输入" · 1张图片 · 发布成功',
100
+ },
101
+ ]);
102
+ });
103
+ it('aborts when the title does not stick after filling', async () => {
104
+ const cmd = getRegistry().get('xiaohongshu/publish');
105
+ expect(cmd?.func).toBeTypeOf('function');
106
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
107
+ const imagePath = path.join(tempDir, 'demo.jpg');
108
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
109
+ const insertText = vi.fn().mockResolvedValue(undefined);
110
+ const page = createConditionalPageMock((code) => {
111
+ if (code.includes('location.href'))
112
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
113
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
114
+ return { ok: true, target: '上传图文', text: '上传图文' };
115
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
116
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
117
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
118
+ return { ok: true, count: 1 };
119
+ if (code.includes('[class*="upload"][class*="progress"]'))
120
+ return false;
121
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
122
+ return true;
123
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"'))
124
+ return { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable' };
125
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"prepare"'))
126
+ return { ok: true };
127
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"verify"'))
128
+ return { ok: false, actual: '' };
129
+ if (code.includes('(function(selectors, text)'))
130
+ return { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '' };
131
+ if (code.includes('labels.some'))
132
+ return true;
133
+ if (code.includes('for (const el of document.querySelectorAll'))
134
+ return '发布成功';
135
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
136
+ }, {
137
+ insertText,
138
+ });
139
+ await expect(cmd.func(page, {
140
+ title: '标题没写进去',
141
+ content: '正文',
142
+ images: imagePath,
143
+ topics: '',
144
+ draft: false,
145
+ })).rejects.toThrow('Failed to set title');
146
+ expect(insertText).toHaveBeenCalledWith('标题没写进去');
147
+ });
148
+ it('falls back to in-page insertion when contenteditable native insertText fails', async () => {
149
+ const cmd = getRegistry().get('xiaohongshu/publish');
150
+ expect(cmd?.func).toBeTypeOf('function');
151
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
152
+ const imagePath = path.join(tempDir, 'demo.jpg');
153
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
154
+ const insertText = vi.fn().mockRejectedValue(new Error('insertText returned no inserted flag'));
155
+ const page = createConditionalPageMock((code) => {
156
+ if (code.includes('location.href'))
157
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
158
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
159
+ return { ok: true, target: '上传图文', text: '上传图文' };
160
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
161
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
162
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
163
+ return { ok: true, count: 1 };
164
+ if (code.includes('[class*="upload"][class*="progress"]'))
165
+ return false;
166
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
167
+ return true;
168
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"')) {
169
+ return code.includes('[contenteditable="true"][placeholder*="标题"]')
170
+ ? { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable' }
171
+ : { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' };
172
+ }
173
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"prepare"'))
174
+ return { ok: true };
175
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"apply"')) {
176
+ return code.includes('[contenteditable="true"][placeholder*="标题"]')
177
+ ? { ok: true, actual: '原生失败后回退' }
178
+ : { ok: true, actual: '正文也回退' };
179
+ }
180
+ if (code.includes('labels.some'))
181
+ return true;
182
+ if (code.includes('for (const el of document.querySelectorAll'))
183
+ return '发布成功';
184
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
185
+ }, {
186
+ insertText,
187
+ });
188
+ const result = await cmd.func(page, {
189
+ title: '原生失败后回退',
190
+ content: '正文也回退',
191
+ images: imagePath,
192
+ topics: '',
193
+ draft: false,
194
+ });
195
+ expect(insertText).toHaveBeenCalledWith('原生失败后回退');
196
+ expect(result).toEqual([
197
+ {
198
+ status: '✅ 发布成功',
199
+ detail: '"原生失败后回退" · 1张图片 · 发布成功',
200
+ },
201
+ ]);
202
+ });
203
+ it('aborts when an input title does not stick after filling', async () => {
204
+ const cmd = getRegistry().get('xiaohongshu/publish');
205
+ expect(cmd?.func).toBeTypeOf('function');
206
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
207
+ const imagePath = path.join(tempDir, 'demo.jpg');
208
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
209
+ const page = createConditionalPageMock((code) => {
210
+ if (code.includes('location.href'))
211
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left';
212
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
213
+ return { ok: true, target: '上传图文', text: '上传图文' };
214
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
215
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
216
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
217
+ return { ok: true, count: 1 };
218
+ if (code.includes('[class*="upload"][class*="progress"]'))
219
+ return false;
220
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
221
+ return true;
222
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"'))
223
+ return code.includes('input[maxlength')
224
+ ? { ok: true, sel: 'input[maxlength="20"]', kind: 'input' }
225
+ : { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' };
226
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"apply"'))
227
+ return code.includes('input[maxlength')
228
+ ? { ok: false, actual: '' }
229
+ : { ok: true, actual: '正文' };
230
+ if (code.includes('labels.some'))
231
+ return true;
232
+ if (code.includes('for (const el of document.querySelectorAll'))
233
+ return '发布成功';
234
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
235
+ });
236
+ await expect(cmd.func(page, {
237
+ title: '输入框标题没写进去',
238
+ content: '正文',
239
+ images: imagePath,
240
+ topics: '',
241
+ draft: false,
242
+ })).rejects.toThrow('Failed to set title');
243
+ });
37
244
  it('prefers CDP setFileInput upload when the page supports it', async () => {
38
245
  const cmd = getRegistry().get('xiaohongshu/publish');
39
246
  expect(cmd?.func).toBeTypeOf('function');
@@ -48,8 +255,10 @@ describe('xiaohongshu publish', () => {
48
255
  'input[type="file"][accept*="image"],input[type="file"][accept*=".jpg"],input[type="file"][accept*=".jpeg"],input[type="file"][accept*=".png"],input[type="file"][accept*=".gif"],input[type="file"][accept*=".webp"]',
49
256
  false,
50
257
  true,
51
- { ok: true, sel: 'input[maxlength="20"]' },
52
- { ok: true, sel: '[contenteditable="true"][class*="content"]' },
258
+ { ok: true, sel: 'input[maxlength="20"]', kind: 'input' },
259
+ { ok: true, actual: 'CDP上传优先' },
260
+ { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
261
+ { ok: true, actual: '优先走 setFileInput 主路径' },
53
262
  true,
54
263
  'https://creator.xiaohongshu.com/publish/success',
55
264
  '发布成功',
@@ -111,8 +320,10 @@ describe('xiaohongshu publish', () => {
111
320
  { ok: true, count: 1 },
112
321
  false,
113
322
  true, // waitForEditForm: editor appeared
114
- { ok: true, sel: 'input[maxlength="20"]' },
115
- { ok: true, sel: '[contenteditable="true"][class*="content"]' },
323
+ { ok: true, sel: 'input[maxlength="20"]', kind: 'input' },
324
+ { ok: true, actual: 'DeepSeek别乱问' },
325
+ { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
326
+ { ok: true, actual: '一篇真实一点的小红书正文' },
116
327
  true,
117
328
  'https://creator.xiaohongshu.com/publish/success',
118
329
  '发布成功',
@@ -171,8 +382,10 @@ describe('xiaohongshu publish', () => {
171
382
  { ok: true, count: 1 }, // injectImages
172
383
  false, // waitForUploads: no progress indicator
173
384
  true, // waitForEditForm: editor appeared
174
- { ok: true, sel: 'input[maxlength="20"]' },
175
- { ok: true, sel: '[contenteditable="true"][class*="content"]' },
385
+ { ok: true, sel: 'input[maxlength="20"]', kind: 'input' },
386
+ { ok: true, actual: '延迟切换也能过' },
387
+ { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
388
+ { ok: true, actual: '图文页切换慢一点也继续等' },
176
389
  true,
177
390
  'https://creator.xiaohongshu.com/publish/success',
178
391
  '发布成功',
@@ -192,4 +405,104 @@ describe('xiaohongshu publish', () => {
192
405
  },
193
406
  ]);
194
407
  });
408
+ it('treats 保存成功 on the draft list as a successful draft save', async () => {
409
+ const cmd = getRegistry().get('xiaohongshu/publish');
410
+ expect(cmd?.func).toBeTypeOf('function');
411
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
412
+ const imagePath = path.join(tempDir, 'demo.jpg');
413
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
414
+ const page = createConditionalPageMock((code) => {
415
+ if (code.includes('location.href'))
416
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left&target=image';
417
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
418
+ return { ok: true, target: '上传图文', text: '上传图文' };
419
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
420
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
421
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
422
+ return { ok: true, count: 1 };
423
+ if (code.includes('[class*="upload"][class*="progress"]'))
424
+ return false;
425
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
426
+ return true;
427
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"')) {
428
+ return code.includes('[contenteditable="true"][class*="content"]')
429
+ ? { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' }
430
+ : { ok: true, sel: 'input[placeholder*="标题"]', kind: 'input' };
431
+ }
432
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"apply"')) {
433
+ return code.includes('[contenteditable="true"][class*="content"]')
434
+ ? { ok: true, actual: '停留在发布页也算成功' }
435
+ : { ok: true, actual: '草稿成功提示' };
436
+ }
437
+ if (code.includes('labels.some'))
438
+ return true;
439
+ if (code.includes('for (const el of document.querySelectorAll')) {
440
+ return code.includes('保存成功') ? '保存成功' : '';
441
+ }
442
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
443
+ });
444
+ const result = await cmd.func(page, {
445
+ title: '草稿成功提示',
446
+ content: '停留在发布页也算成功',
447
+ images: imagePath,
448
+ topics: '',
449
+ draft: true,
450
+ });
451
+ expect(result).toEqual([
452
+ {
453
+ status: '✅ 暂存成功',
454
+ detail: '"草稿成功提示" · 1张图片 · 保存成功',
455
+ },
456
+ ]);
457
+ });
458
+ it('does not treat 保存成功 alone as a publish success signal', async () => {
459
+ const cmd = getRegistry().get('xiaohongshu/publish');
460
+ expect(cmd?.func).toBeTypeOf('function');
461
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
462
+ const imagePath = path.join(tempDir, 'demo.jpg');
463
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
464
+ const page = createConditionalPageMock((code) => {
465
+ if (code.includes('location.href'))
466
+ return 'https://creator.xiaohongshu.com/publish/publish?from=menu_left&target=image';
467
+ if (code.includes("const targets = ['上传图文', '图文', '图片']"))
468
+ return { ok: true, target: '上传图文', text: '上传图文' };
469
+ if (code.includes('hasTitleInput') && code.includes('hasVideoSurface'))
470
+ return { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false };
471
+ if (code.includes('const images =') && code.includes('dt.items.add(new File'))
472
+ return { ok: true, count: 1 };
473
+ if (code.includes('[class*="upload"][class*="progress"]'))
474
+ return false;
475
+ if (code.includes('const sels =') && code.includes('for (const sel of sels)'))
476
+ return true;
477
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"locate"')) {
478
+ return code.includes('[contenteditable="true"][class*="content"]')
479
+ ? { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' }
480
+ : { ok: true, sel: 'input[placeholder*="标题"]', kind: 'input' };
481
+ }
482
+ if (code.includes('__opencli_xhs_fill_phase') && code.includes('"apply"')) {
483
+ return code.includes('[contenteditable="true"][class*="content"]')
484
+ ? { ok: true, actual: '发布提示不该复用草稿成功' }
485
+ : { ok: true, actual: '发布成功提示' };
486
+ }
487
+ if (code.includes('labels.some'))
488
+ return true;
489
+ if (code.includes('for (const el of document.querySelectorAll')) {
490
+ return code.includes('保存成功') ? '保存成功' : '';
491
+ }
492
+ throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
493
+ });
494
+ const result = await cmd.func(page, {
495
+ title: '发布成功提示',
496
+ content: '发布提示不该复用草稿成功',
497
+ images: imagePath,
498
+ topics: '',
499
+ draft: false,
500
+ });
501
+ expect(result).toEqual([
502
+ {
503
+ status: '⚠️ 操作完成,请在浏览器中确认',
504
+ detail: '"发布成功提示" · 1张图片 · https://creator.xiaohongshu.com/publish/publish?from=menu_left&target=image',
505
+ },
506
+ ]);
507
+ });
195
508
  });