@jackwener/opencli 1.5.0 → 1.5.2

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 (108) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/discover.js +11 -7
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/page.d.ts +4 -0
  6. package/dist/browser/page.js +52 -3
  7. package/dist/browser.test.js +5 -0
  8. package/dist/cli-manifest.json +460 -1
  9. package/dist/cli.js +34 -3
  10. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  11. package/dist/clis/apple-podcasts/top.js +4 -1
  12. package/dist/clis/bluesky/feeds.yaml +29 -0
  13. package/dist/clis/bluesky/followers.yaml +33 -0
  14. package/dist/clis/bluesky/following.yaml +33 -0
  15. package/dist/clis/bluesky/profile.yaml +27 -0
  16. package/dist/clis/bluesky/search.yaml +34 -0
  17. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  18. package/dist/clis/bluesky/thread.yaml +32 -0
  19. package/dist/clis/bluesky/trending.yaml +27 -0
  20. package/dist/clis/bluesky/user.yaml +34 -0
  21. package/dist/clis/twitter/trending.js +29 -61
  22. package/dist/clis/weread/shelf.js +132 -9
  23. package/dist/clis/weread/utils.js +5 -1
  24. package/dist/clis/xiaohongshu/publish.js +78 -42
  25. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  26. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  27. package/dist/clis/xiaohongshu/search.js +20 -1
  28. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  29. package/dist/clis/xiaohongshu/search.test.js +32 -1
  30. package/dist/daemon.js +1 -0
  31. package/dist/discovery.js +40 -28
  32. package/dist/doctor.d.ts +1 -2
  33. package/dist/doctor.js +9 -5
  34. package/dist/engine.test.js +42 -0
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/errors.js +2 -2
  37. package/dist/execution.js +45 -13
  38. package/dist/execution.test.d.ts +1 -0
  39. package/dist/execution.test.js +40 -0
  40. package/dist/extension-manifest-regression.test.d.ts +1 -0
  41. package/dist/extension-manifest-regression.test.js +12 -0
  42. package/dist/external.js +6 -1
  43. package/dist/main.js +1 -0
  44. package/dist/plugin-scaffold.d.ts +28 -0
  45. package/dist/plugin-scaffold.js +142 -0
  46. package/dist/plugin-scaffold.test.d.ts +4 -0
  47. package/dist/plugin-scaffold.test.js +83 -0
  48. package/dist/plugin.d.ts +55 -17
  49. package/dist/plugin.js +706 -154
  50. package/dist/plugin.test.js +836 -38
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.js +1 -1
  53. package/dist/types.d.ts +2 -0
  54. package/dist/weread-private-api-regression.test.js +185 -0
  55. package/docs/adapters/browser/bluesky.md +53 -0
  56. package/docs/guide/plugins.md +10 -0
  57. package/extension/dist/background.js +4 -2
  58. package/extension/manifest.json +4 -1
  59. package/extension/package-lock.json +2 -2
  60. package/extension/package.json +1 -1
  61. package/extension/src/background.ts +2 -1
  62. package/package.json +1 -1
  63. package/src/browser/cdp.ts +6 -0
  64. package/src/browser/discover.ts +10 -7
  65. package/src/browser/index.ts +2 -0
  66. package/src/browser/page.ts +49 -3
  67. package/src/browser.test.ts +6 -0
  68. package/src/cli.ts +34 -3
  69. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  70. package/src/clis/apple-podcasts/top.ts +4 -1
  71. package/src/clis/bluesky/feeds.yaml +29 -0
  72. package/src/clis/bluesky/followers.yaml +33 -0
  73. package/src/clis/bluesky/following.yaml +33 -0
  74. package/src/clis/bluesky/profile.yaml +27 -0
  75. package/src/clis/bluesky/search.yaml +34 -0
  76. package/src/clis/bluesky/starter-packs.yaml +34 -0
  77. package/src/clis/bluesky/thread.yaml +32 -0
  78. package/src/clis/bluesky/trending.yaml +27 -0
  79. package/src/clis/bluesky/user.yaml +34 -0
  80. package/src/clis/twitter/trending.ts +29 -77
  81. package/src/clis/weread/shelf.ts +169 -9
  82. package/src/clis/weread/utils.ts +6 -1
  83. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  84. package/src/clis/xiaohongshu/publish.ts +93 -52
  85. package/src/clis/xiaohongshu/search.test.ts +39 -1
  86. package/src/clis/xiaohongshu/search.ts +19 -1
  87. package/src/daemon.ts +1 -0
  88. package/src/discovery.ts +41 -33
  89. package/src/doctor.ts +11 -8
  90. package/src/engine.test.ts +38 -0
  91. package/src/errors.ts +6 -2
  92. package/src/execution.test.ts +47 -0
  93. package/src/execution.ts +39 -15
  94. package/src/extension-manifest-regression.test.ts +17 -0
  95. package/src/external.ts +6 -1
  96. package/src/main.ts +1 -0
  97. package/src/plugin-scaffold.test.ts +98 -0
  98. package/src/plugin-scaffold.ts +170 -0
  99. package/src/plugin.test.ts +881 -38
  100. package/src/plugin.ts +871 -158
  101. package/src/runtime.ts +2 -2
  102. package/src/types.ts +2 -0
  103. package/src/weread-private-api-regression.test.ts +207 -0
  104. package/tests/e2e/browser-public.test.ts +1 -1
  105. package/tests/e2e/output-formats.test.ts +10 -14
  106. package/tests/e2e/plugin-management.test.ts +4 -1
  107. package/tests/e2e/public-commands.test.ts +12 -1
  108. package/vitest.config.ts +1 -15
@@ -1,5 +1,120 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
2
3
  import { fetchPrivateApi } from './utils.js';
4
+ const WEREAD_DOMAIN = 'weread.qq.com';
5
+ const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
6
+ function normalizeShelfLimit(limit) {
7
+ if (!Number.isFinite(limit))
8
+ return 0;
9
+ return Math.max(0, Math.trunc(limit));
10
+ }
11
+ function normalizePrivateApiRows(data, limit) {
12
+ const books = data?.books ?? [];
13
+ return books.slice(0, limit).map((item) => ({
14
+ title: item.bookInfo?.title ?? item.title ?? '',
15
+ author: item.bookInfo?.author ?? item.author ?? '',
16
+ // TODO: readingProgress field name from community docs, verify with real API response
17
+ progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
18
+ bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
19
+ }));
20
+ }
21
+ function normalizeWebShelfRows(snapshot, limit) {
22
+ if (limit <= 0)
23
+ return [];
24
+ const bookById = new Map();
25
+ for (const book of snapshot.rawBooks) {
26
+ const bookId = String(book?.bookId || '').trim();
27
+ if (!bookId)
28
+ continue;
29
+ bookById.set(bookId, book);
30
+ }
31
+ const orderedBookIds = snapshot.shelfIndexes
32
+ .filter((entry) => String(entry?.role || 'book') === 'book')
33
+ .sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
34
+ .map((entry) => String(entry?.bookId || '').trim())
35
+ .filter(Boolean);
36
+ const fallbackOrder = snapshot.rawBooks
37
+ .map((book) => String(book?.bookId || '').trim())
38
+ .filter(Boolean);
39
+ const orderedUniqueBookIds = Array.from(new Set([
40
+ ...orderedBookIds,
41
+ ...fallbackOrder,
42
+ ]));
43
+ return orderedUniqueBookIds
44
+ .map((bookId) => {
45
+ const book = bookById.get(bookId);
46
+ if (!book)
47
+ return null;
48
+ return {
49
+ title: String(book.title || '').trim(),
50
+ author: String(book.author || '').trim(),
51
+ progress: '-',
52
+ bookId,
53
+ };
54
+ })
55
+ .filter((item) => Boolean(item && (item.title || item.bookId)))
56
+ .slice(0, limit);
57
+ }
58
+ /**
59
+ * Read the structured shelf cache from the web shelf page.
60
+ * The page hydrates localStorage with raw book data plus shelf ordering.
61
+ */
62
+ async function loadWebShelfSnapshot(page) {
63
+ await page.goto(WEREAD_SHELF_URL);
64
+ const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
65
+ const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
66
+ if (!currentVid) {
67
+ return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
68
+ }
69
+ const rawBooksKey = `shelf:rawBooks:${currentVid}`;
70
+ const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
71
+ const result = await page.evaluate(`
72
+ (() => new Promise((resolve) => {
73
+ const deadline = Date.now() + 5000;
74
+ const rawBooksKey = ${JSON.stringify(rawBooksKey)};
75
+ const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
76
+
77
+ const readJson = (raw) => {
78
+ if (typeof raw !== 'string') return null;
79
+ try {
80
+ return JSON.parse(raw);
81
+ } catch {
82
+ return null;
83
+ }
84
+ };
85
+
86
+ const poll = () => {
87
+ const rawBooksRaw = localStorage.getItem(rawBooksKey);
88
+ const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
89
+ const rawBooks = readJson(rawBooksRaw);
90
+ const shelfIndexes = readJson(shelfIndexesRaw);
91
+ const cacheFound = Array.isArray(rawBooks);
92
+
93
+ if (cacheFound || Date.now() >= deadline) {
94
+ resolve({
95
+ cacheFound,
96
+ rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
97
+ shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
98
+ });
99
+ return;
100
+ }
101
+
102
+ setTimeout(poll, 100);
103
+ };
104
+
105
+ poll();
106
+ }))
107
+ `);
108
+ if (!result || typeof result !== 'object') {
109
+ return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
110
+ }
111
+ const snapshot = result;
112
+ return {
113
+ cacheFound: snapshot.cacheFound === true,
114
+ rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
115
+ shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
116
+ };
117
+ }
3
118
  cli({
4
119
  site: 'weread',
5
120
  name: 'shelf',
@@ -11,14 +126,22 @@ cli({
11
126
  ],
12
127
  columns: ['title', 'author', 'progress', 'bookId'],
13
128
  func: async (page, args) => {
14
- const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
15
- const books = data?.books ?? [];
16
- return books.slice(0, Number(args.limit)).map((item) => ({
17
- title: item.bookInfo?.title ?? item.title ?? '',
18
- author: item.bookInfo?.author ?? item.author ?? '',
19
- // TODO: readingProgress field name from community docs, verify with real API response
20
- progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
21
- bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
22
- }));
129
+ const limit = normalizeShelfLimit(Number(args.limit));
130
+ if (limit <= 0)
131
+ return [];
132
+ try {
133
+ const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
134
+ return normalizePrivateApiRows(data, limit);
135
+ }
136
+ catch (error) {
137
+ if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
138
+ throw error;
139
+ }
140
+ const snapshot = await loadWebShelfSnapshot(page);
141
+ if (!snapshot.cacheFound) {
142
+ throw error;
143
+ }
144
+ return normalizeWebShelfRows(snapshot, limit);
145
+ }
23
146
  },
24
147
  });
@@ -9,9 +9,13 @@ import { CliError } from '../../errors.js';
9
9
  const WEB_API = 'https://weread.qq.com/web';
10
10
  const API = 'https://i.weread.qq.com';
11
11
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
12
+ const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
12
13
  function buildCookieHeader(cookies) {
13
14
  return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
14
15
  }
16
+ function isAuthErrorResponse(resp, data) {
17
+ return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
18
+ }
15
19
  /**
16
20
  * Fetch a public WeRead web endpoint (Node.js direct fetch).
17
21
  * Used by search and ranking commands (browser: false).
@@ -69,7 +73,7 @@ export async function fetchPrivateApi(page, path, params) {
69
73
  catch {
70
74
  throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
71
75
  }
72
- if (resp.status === 401 || data?.errcode === -2010) {
76
+ if (isAuthErrorResponse(resp, data)) {
73
77
  throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
74
78
  }
75
79
  if (!resp.ok) {
@@ -22,6 +22,21 @@ 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). */
26
+ const TITLE_SELECTORS = [
27
+ // New creator center (2026-03) uses contenteditable for the title field.
28
+ // Placeholder observed: "填写标题会有更多赞哦"
29
+ '[contenteditable="true"][placeholder*="标题"]',
30
+ '[contenteditable="true"][placeholder*="赞"]',
31
+ '[contenteditable="true"][class*="title"]',
32
+ 'input[maxlength="20"]',
33
+ 'input[class*="title"]',
34
+ 'input[placeholder*="标题"]',
35
+ 'input[placeholder*="title" i]',
36
+ '.title-input input',
37
+ '.note-title input',
38
+ 'input[maxlength]',
39
+ ];
25
40
  /**
26
41
  * Read a local image and return the name, MIME type, and base64 content.
27
42
  * Throws if the file does not exist or the extension is unsupported.
@@ -192,10 +207,10 @@ async function selectImageTextTab(page) {
192
207
  }
193
208
  return result;
194
209
  }
195
- async function inspectPublishSurface(page) {
210
+ async function inspectPublishSurfaceState(page) {
196
211
  return page.evaluate(`
197
212
  () => {
198
- const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim();
213
+ const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim();
199
214
  const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => {
200
215
  if (!el || el.offsetParent === null) return false;
201
216
  const placeholder = (el.getAttribute('placeholder') || '').trim();
@@ -219,29 +234,51 @@ async function inspectPublishSurface(page) {
219
234
  accept.includes('.webp')
220
235
  );
221
236
  });
222
- return {
223
- hasTitleInput,
224
- hasImageInput,
225
- hasVideoSurface: text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'),
226
- };
237
+ const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频');
238
+ const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface';
239
+ return { state, hasTitleInput, hasImageInput, hasVideoSurface };
227
240
  }
228
241
  `);
229
242
  }
230
- async function waitForImageTextSurface(page, maxWaitMs = 5_000) {
243
+ async function waitForPublishSurfaceState(page, maxWaitMs = 5_000) {
231
244
  const pollMs = 500;
232
245
  const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
233
- let surface = await inspectPublishSurface(page);
246
+ let surface = await inspectPublishSurfaceState(page);
234
247
  for (let i = 0; i < maxAttempts; i++) {
235
- if (surface.hasTitleInput || surface.hasImageInput || !surface.hasVideoSurface) {
248
+ if (surface.state !== 'video_surface') {
236
249
  return surface;
237
250
  }
238
251
  if (i < maxAttempts - 1) {
239
252
  await page.wait({ time: pollMs / 1_000 });
240
- surface = await inspectPublishSurface(page);
253
+ surface = await inspectPublishSurfaceState(page);
241
254
  }
242
255
  }
243
256
  return surface;
244
257
  }
258
+ /**
259
+ * Poll until the title/content editing form appears on the page.
260
+ * The new creator center UI only renders the editor after images are uploaded.
261
+ */
262
+ async function waitForEditForm(page, maxWaitMs = 10_000) {
263
+ const pollMs = 1_000;
264
+ const maxAttempts = Math.ceil(maxWaitMs / pollMs);
265
+ for (let i = 0; i < maxAttempts; i++) {
266
+ const found = await page.evaluate(`
267
+ (() => {
268
+ const sels = ${JSON.stringify(TITLE_SELECTORS)};
269
+ for (const sel of sels) {
270
+ const el = document.querySelector(sel);
271
+ if (el && el.offsetParent !== null) return true;
272
+ }
273
+ return false;
274
+ })()`);
275
+ if (found)
276
+ return true;
277
+ if (i < maxAttempts - 1)
278
+ await page.wait({ time: pollMs / 1_000 });
279
+ }
280
+ return false;
281
+ }
245
282
  cli({
246
283
  site: 'xiaohongshu',
247
284
  name: 'publish',
@@ -252,7 +289,7 @@ cli({
252
289
  args: [
253
290
  { name: 'title', required: true, help: '笔记标题 (最多20字)' },
254
291
  { name: 'content', required: true, positional: true, help: '笔记正文' },
255
- { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
292
+ { name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
256
293
  { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
257
294
  { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
258
295
  ],
@@ -276,6 +313,8 @@ cli({
276
313
  throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
277
314
  if (!content)
278
315
  throw new Error('Positional argument <content> is required');
316
+ if (imagePaths.length === 0)
317
+ throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.');
279
318
  if (imagePaths.length > MAX_IMAGES)
280
319
  throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
281
320
  // Read images in Node.js context before navigating (fast-fail on bad paths)
@@ -291,8 +330,8 @@ cli({
291
330
  }
292
331
  // ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
293
332
  const tabResult = await selectImageTextTab(page);
294
- const surface = await waitForImageTextSurface(page, tabResult?.ok ? 5_000 : 2_000);
295
- if (!surface.hasTitleInput && !surface.hasImageInput && surface.hasVideoSurface) {
333
+ const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000);
334
+ if (surface.state === 'video_surface') {
296
335
  await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
297
336
  const detail = tabResult?.ok
298
337
  ? `clicked "${tabResult.text}"`
@@ -301,27 +340,24 @@ cli({
301
340
  `Details: ${detail}. Debug screenshot: /tmp/xhs_publish_tab_debug.png`);
302
341
  }
303
342
  // ── Step 3: Upload images ──────────────────────────────────────────────────
304
- if (imageData.length > 0) {
305
- const upload = await injectImages(page, imageData);
306
- if (!upload.ok) {
307
- await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
308
- throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
309
- 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
310
- }
311
- // Allow XHS to process and upload images to its CDN
312
- await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
313
- await waitForUploads(page);
343
+ const upload = await injectImages(page, imageData);
344
+ if (!upload.ok) {
345
+ await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
346
+ throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
347
+ 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
348
+ }
349
+ // Allow XHS to process and upload images to its CDN
350
+ await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
351
+ await waitForUploads(page);
352
+ // ── Step 3b: Wait for editor form to render ───────────────────────────────
353
+ const formReady = await waitForEditForm(page);
354
+ if (!formReady) {
355
+ await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' });
356
+ throw new Error('Editing form did not appear after image upload. The page layout may have changed. ' +
357
+ 'Debug screenshot: /tmp/xhs_publish_form_debug.png');
314
358
  }
315
359
  // ── Step 4: Fill title ─────────────────────────────────────────────────────
316
- await fillField(page, [
317
- 'input[maxlength="20"]',
318
- 'input[class*="title"]',
319
- 'input[placeholder*="标题"]',
320
- 'input[placeholder*="title" i]',
321
- '.title-input input',
322
- '.note-title input',
323
- 'input[maxlength]',
324
- ], title, 'title');
360
+ await fillField(page, TITLE_SELECTORS, title, 'title');
325
361
  await page.wait({ time: 0.5 });
326
362
  // ── Step 5: Fill content / body ────────────────────────────────────────────
327
363
  await fillField(page, [
@@ -333,7 +369,7 @@ cli({
333
369
  '.note-content [contenteditable="true"]',
334
370
  '.editor-content [contenteditable="true"]',
335
371
  // Broad fallback — last resort; filter out any title contenteditable
336
- '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
372
+ '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])',
337
373
  ], content, 'content');
338
374
  await page.wait({ time: 0.5 });
339
375
  // ── Step 6: Add topic hashtags ─────────────────────────────────────────────
@@ -390,14 +426,14 @@ cli({
390
426
  await page.wait({ time: 0.5 });
391
427
  }
392
428
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
393
- const actionLabel = isDraft ? '存草稿' : '发布';
429
+ const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
394
430
  const btnClicked = await page.evaluate(`
395
- (label => {
431
+ (labels => {
396
432
  const buttons = document.querySelectorAll('button, [role="button"]');
397
433
  for (const btn of buttons) {
398
434
  const text = (btn.innerText || btn.textContent || '').trim();
399
435
  if (
400
- (text === label || text.includes(label) || text === '发布笔记') &&
436
+ labels.some(l => text === l || text.includes(l)) &&
401
437
  btn.offsetParent !== null &&
402
438
  !btn.disabled
403
439
  ) {
@@ -406,11 +442,11 @@ cli({
406
442
  }
407
443
  }
408
444
  return false;
409
- })(${JSON.stringify(actionLabel)})
445
+ })(${JSON.stringify(actionLabels)})
410
446
  `);
411
447
  if (!btnClicked) {
412
448
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
413
- throw new Error(`Could not find "${actionLabel}" button. ` +
449
+ throw new Error(`Could not find "${actionLabels[0]}" button. ` +
414
450
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
415
451
  }
416
452
  // ── Step 8: Verify success ─────────────────────────────────────────────────
@@ -422,7 +458,7 @@ cli({
422
458
  const text = (el.innerText || '').trim();
423
459
  if (
424
460
  el.children.length === 0 &&
425
- (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
461
+ (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
426
462
  ) return text;
427
463
  }
428
464
  return '';
@@ -430,13 +466,13 @@ cli({
430
466
  `);
431
467
  const navigatedAway = !finalUrl.includes('/publish/publish');
432
468
  const isSuccess = successMsg.length > 0 || navigatedAway;
433
- const verb = isDraft ? '草稿已保存' : '发布成功';
469
+ const verb = isDraft ? '暂存成功' : '发布成功';
434
470
  return [
435
471
  {
436
472
  status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
437
473
  detail: [
438
474
  `"${title}"`,
439
- imageData.length ? `${imageData.length}张图片` : '无图',
475
+ `${imageData.length}张图片`,
440
476
  topics.length ? `话题: ${topics.join(' ')}` : '',
441
477
  successMsg || finalUrl || '',
442
478
  ]
@@ -43,9 +43,10 @@ describe('xiaohongshu publish', () => {
43
43
  const page = createPageMock([
44
44
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
45
45
  { ok: true, target: '上传图文', text: '上传图文' },
46
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
46
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
47
47
  { ok: true, count: 1 },
48
48
  false,
49
+ true, // waitForEditForm: editor appeared
49
50
  { ok: true, sel: 'input[maxlength="20"]' },
50
51
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
51
52
  true,
@@ -72,17 +73,21 @@ describe('xiaohongshu publish', () => {
72
73
  it('fails early with a clear error when still on the video page', async () => {
73
74
  const cmd = getRegistry().get('xiaohongshu/publish');
74
75
  expect(cmd?.func).toBeTypeOf('function');
76
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
77
+ const imagePath = path.join(tempDir, 'demo.jpg');
78
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
75
79
  const page = createPageMock([
76
80
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
77
81
  { ok: false, visibleTexts: ['上传视频', '上传图文'] },
78
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
79
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
80
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
81
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
82
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
83
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
84
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
85
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
82
86
  ]);
83
87
  await expect(cmd.func(page, {
84
88
  title: 'DeepSeek别乱问',
85
89
  content: '一篇真实一点的小红书正文',
90
+ images: imagePath,
86
91
  topics: '',
87
92
  draft: false,
88
93
  })).rejects.toThrow('Still on the video publish page after trying to select 图文');
@@ -91,11 +96,17 @@ describe('xiaohongshu publish', () => {
91
96
  it('waits for the image-text surface to appear after clicking the tab', async () => {
92
97
  const cmd = getRegistry().get('xiaohongshu/publish');
93
98
  expect(cmd?.func).toBeTypeOf('function');
99
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
100
+ const imagePath = path.join(tempDir, 'demo.jpg');
101
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
94
102
  const page = createPageMock([
95
103
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
96
104
  { ok: true, target: '上传图文', text: '上传图文' },
97
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
98
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
105
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
106
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
107
+ { ok: true, count: 1 }, // injectImages
108
+ false, // waitForUploads: no progress indicator
109
+ true, // waitForEditForm: editor appeared
99
110
  { ok: true, sel: 'input[maxlength="20"]' },
100
111
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
101
112
  true,
@@ -105,6 +116,7 @@ describe('xiaohongshu publish', () => {
105
116
  const result = await cmd.func(page, {
106
117
  title: '延迟切换也能过',
107
118
  content: '图文页切换慢一点也继续等',
119
+ images: imagePath,
108
120
  topics: '',
109
121
  draft: false,
110
122
  });
@@ -112,7 +124,7 @@ describe('xiaohongshu publish', () => {
112
124
  expect(result).toEqual([
113
125
  {
114
126
  status: '✅ 发布成功',
115
- detail: '"延迟切换也能过" · 无图 · 发布成功',
127
+ detail: '"延迟切换也能过" · 1张图片 · 发布成功',
116
128
  },
117
129
  ]);
118
130
  });
@@ -5,4 +5,11 @@
5
5
  * the search results page and extracts data from rendered DOM elements.
6
6
  * Ref: https://github.com/jackwener/opencli/issues/10
7
7
  */
8
- export {};
8
+ /**
9
+ * Extract approximate publish date from a Xiaohongshu note URL.
10
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
11
+ * characters encode a Unix timestamp (the moment the ID was generated,
12
+ * which closely matches publish time but is not an official API field).
13
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
14
+ */
15
+ export declare function noteIdToDate(url: string): string;
@@ -7,6 +7,24 @@
7
7
  */
8
8
  import { cli, Strategy } from '../../registry.js';
9
9
  import { AuthRequiredError } from '../../errors.js';
10
+ /**
11
+ * Extract approximate publish date from a Xiaohongshu note URL.
12
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
13
+ * characters encode a Unix timestamp (the moment the ID was generated,
14
+ * which closely matches publish time but is not an official API field).
15
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
16
+ */
17
+ export function noteIdToDate(url) {
18
+ const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
19
+ if (!match)
20
+ return '';
21
+ const hex = match[1].substring(0, 8);
22
+ const ts = parseInt(hex, 16);
23
+ if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000)
24
+ return '';
25
+ // Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
26
+ return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
27
+ }
10
28
  cli({
11
29
  site: 'xiaohongshu',
12
30
  name: 'search',
@@ -17,7 +35,7 @@ cli({
17
35
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
18
36
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
19
37
  ],
20
- columns: ['rank', 'title', 'author', 'likes', 'url'],
38
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
21
39
  func: async (page, kwargs) => {
22
40
  const keyword = encodeURIComponent(kwargs.query);
23
41
  await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
@@ -89,6 +107,7 @@ cli({
89
107
  .map((item, i) => ({
90
108
  rank: i + 1,
91
109
  ...item,
110
+ published_at: noteIdToDate(item.url),
92
111
  }));
93
112
  },
94
113
  });
@@ -1 +1 @@
1
- import './search.js';
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '../../registry.js';
3
- import './search.js';
3
+ import { noteIdToDate } from './search.js';
4
4
  function createPageMock(evaluateResults) {
5
5
  const evaluate = vi.fn();
6
6
  for (const result of evaluateResults) {
@@ -70,6 +70,7 @@ describe('xiaohongshu search', () => {
70
70
  title: '某鱼买FSD被坑了4万',
71
71
  author: '随风',
72
72
  likes: '261',
73
+ published_at: '2025-10-10',
73
74
  url: detailUrl,
74
75
  author_url: authorUrl,
75
76
  },
@@ -112,3 +113,33 @@ describe('xiaohongshu search', () => {
112
113
  expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
113
114
  });
114
115
  });
116
+ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
117
+ it('parses a known note ID to the correct China-timezone date', () => {
118
+ // 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
119
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
120
+ // 0x68e90be8 → 2025-10-10 in UTC+8
121
+ expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
122
+ });
123
+ it('returns China date when UTC+8 crosses into the next day', () => {
124
+ // 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
125
+ // Without UTC+8 offset this would incorrectly return 2026-03-15
126
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
127
+ });
128
+ it('handles /note/ path variant', () => {
129
+ expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
130
+ });
131
+ it('handles URL with query parameters', () => {
132
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
133
+ });
134
+ it('returns empty string for non-matching URLs', () => {
135
+ expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
136
+ expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
137
+ });
138
+ it('returns empty string for IDs shorter than 24 hex chars', () => {
139
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
140
+ });
141
+ it('returns empty string when timestamp is out of range', () => {
142
+ // All zeros → ts = 0
143
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
144
+ });
145
+ });
package/dist/daemon.js CHANGED
@@ -175,6 +175,7 @@ const wss = new WebSocketServer({
175
175
  wss.on('connection', (ws) => {
176
176
  console.error('[daemon] Extension connected');
177
177
  extensionWs = ws;
178
+ extensionVersion = null; // cleared until hello message arrives
178
179
  // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
179
180
  let missedPongs = 0;
180
181
  const heartbeatInterval = setInterval(() => {