@jackwener/opencli 1.5.0 → 1.5.1

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 (79) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/page.d.ts +3 -0
  3. package/dist/browser/page.js +24 -1
  4. package/dist/cli-manifest.json +465 -5
  5. package/dist/cli.js +34 -3
  6. package/dist/clis/bluesky/feeds.yaml +29 -0
  7. package/dist/clis/bluesky/followers.yaml +33 -0
  8. package/dist/clis/bluesky/following.yaml +33 -0
  9. package/dist/clis/bluesky/profile.yaml +27 -0
  10. package/dist/clis/bluesky/search.yaml +34 -0
  11. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  12. package/dist/clis/bluesky/thread.yaml +32 -0
  13. package/dist/clis/bluesky/trending.yaml +27 -0
  14. package/dist/clis/bluesky/user.yaml +34 -0
  15. package/dist/clis/twitter/trending.js +29 -61
  16. package/dist/clis/v2ex/hot.yaml +17 -3
  17. package/dist/clis/xiaohongshu/publish.js +78 -42
  18. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  19. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  20. package/dist/clis/xiaohongshu/search.js +20 -1
  21. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  22. package/dist/clis/xiaohongshu/search.test.js +32 -1
  23. package/dist/discovery.js +40 -28
  24. package/dist/doctor.d.ts +1 -2
  25. package/dist/doctor.js +2 -2
  26. package/dist/engine.test.js +42 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +2 -2
  29. package/dist/execution.js +45 -7
  30. package/dist/execution.test.d.ts +1 -0
  31. package/dist/execution.test.js +40 -0
  32. package/dist/external.js +6 -1
  33. package/dist/main.js +1 -0
  34. package/dist/plugin-scaffold.d.ts +28 -0
  35. package/dist/plugin-scaffold.js +142 -0
  36. package/dist/plugin-scaffold.test.d.ts +4 -0
  37. package/dist/plugin-scaffold.test.js +83 -0
  38. package/dist/plugin.d.ts +55 -17
  39. package/dist/plugin.js +706 -154
  40. package/dist/plugin.test.js +836 -38
  41. package/dist/runtime.d.ts +1 -0
  42. package/dist/runtime.js +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/docs/adapters/browser/bluesky.md +53 -0
  45. package/docs/guide/plugins.md +10 -0
  46. package/package.json +1 -1
  47. package/src/browser/cdp.ts +6 -0
  48. package/src/browser/page.ts +24 -1
  49. package/src/cli.ts +34 -3
  50. package/src/clis/bluesky/feeds.yaml +29 -0
  51. package/src/clis/bluesky/followers.yaml +33 -0
  52. package/src/clis/bluesky/following.yaml +33 -0
  53. package/src/clis/bluesky/profile.yaml +27 -0
  54. package/src/clis/bluesky/search.yaml +34 -0
  55. package/src/clis/bluesky/starter-packs.yaml +34 -0
  56. package/src/clis/bluesky/thread.yaml +32 -0
  57. package/src/clis/bluesky/trending.yaml +27 -0
  58. package/src/clis/bluesky/user.yaml +34 -0
  59. package/src/clis/twitter/trending.ts +29 -77
  60. package/src/clis/v2ex/hot.yaml +17 -3
  61. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  62. package/src/clis/xiaohongshu/publish.ts +93 -52
  63. package/src/clis/xiaohongshu/search.test.ts +39 -1
  64. package/src/clis/xiaohongshu/search.ts +19 -1
  65. package/src/discovery.ts +41 -33
  66. package/src/doctor.ts +2 -3
  67. package/src/engine.test.ts +38 -0
  68. package/src/errors.ts +6 -2
  69. package/src/execution.test.ts +47 -0
  70. package/src/execution.ts +39 -6
  71. package/src/external.ts +6 -1
  72. package/src/main.ts +1 -0
  73. package/src/plugin-scaffold.test.ts +98 -0
  74. package/src/plugin-scaffold.ts +170 -0
  75. package/src/plugin.test.ts +881 -38
  76. package/src/plugin.ts +871 -158
  77. package/src/runtime.ts +2 -2
  78. package/src/types.ts +2 -0
  79. package/tests/e2e/browser-public.test.ts +1 -1
@@ -3,7 +3,7 @@ name: hot
3
3
  description: V2EX 热门话题
4
4
  domain: www.v2ex.com
5
5
  strategy: public
6
- browser: false
6
+ browser: true
7
7
 
8
8
  args:
9
9
  limit:
@@ -12,8 +12,22 @@ args:
12
12
  description: Number of topics
13
13
 
14
14
  pipeline:
15
- - fetch:
16
- url: https://www.v2ex.com/api/topics/hot.json
15
+ - navigate: https://www.v2ex.com/
16
+
17
+ - evaluate: |
18
+ (async () => {
19
+ const response = await fetch('/api/topics/hot.json', {
20
+ credentials: 'include',
21
+ headers: {
22
+ accept: 'application/json, text/plain, */*',
23
+ 'x-requested-with': 'XMLHttpRequest',
24
+ },
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`V2EX hot API request failed: ${response.status}`);
28
+ }
29
+ return await response.json();
30
+ })()
17
31
 
18
32
  - map:
19
33
  rank: ${{ index + 1 }}
@@ -51,9 +51,10 @@ describe('xiaohongshu publish', () => {
51
51
  const page = createPageMock([
52
52
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
53
53
  { ok: true, target: '上传图文', text: '上传图文' },
54
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
54
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
55
55
  { ok: true, count: 1 },
56
56
  false,
57
+ true, // waitForEditForm: editor appeared
57
58
  { ok: true, sel: 'input[maxlength="20"]' },
58
59
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
59
60
  true,
@@ -84,18 +85,23 @@ describe('xiaohongshu publish', () => {
84
85
  const cmd = getRegistry().get('xiaohongshu/publish');
85
86
  expect(cmd?.func).toBeTypeOf('function');
86
87
 
88
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
89
+ const imagePath = path.join(tempDir, 'demo.jpg');
90
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
91
+
87
92
  const page = createPageMock([
88
93
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
89
94
  { ok: false, visibleTexts: ['上传视频', '上传图文'] },
90
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
91
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
92
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
93
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
95
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
96
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
97
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
98
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
94
99
  ]);
95
100
 
96
101
  await expect(cmd!.func!(page, {
97
102
  title: 'DeepSeek别乱问',
98
103
  content: '一篇真实一点的小红书正文',
104
+ images: imagePath,
99
105
  topics: '',
100
106
  draft: false,
101
107
  })).rejects.toThrow('Still on the video publish page after trying to select 图文');
@@ -107,11 +113,18 @@ describe('xiaohongshu publish', () => {
107
113
  const cmd = getRegistry().get('xiaohongshu/publish');
108
114
  expect(cmd?.func).toBeTypeOf('function');
109
115
 
116
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
117
+ const imagePath = path.join(tempDir, 'demo.jpg');
118
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
119
+
110
120
  const page = createPageMock([
111
121
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
112
122
  { ok: true, target: '上传图文', text: '上传图文' },
113
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
114
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
123
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
124
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
125
+ { ok: true, count: 1 }, // injectImages
126
+ false, // waitForUploads: no progress indicator
127
+ true, // waitForEditForm: editor appeared
115
128
  { ok: true, sel: 'input[maxlength="20"]' },
116
129
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
117
130
  true,
@@ -122,6 +135,7 @@ describe('xiaohongshu publish', () => {
122
135
  const result = await cmd!.func!(page, {
123
136
  title: '延迟切换也能过',
124
137
  content: '图文页切换慢一点也继续等',
138
+ images: imagePath,
125
139
  topics: '',
126
140
  draft: false,
127
141
  });
@@ -130,7 +144,7 @@ describe('xiaohongshu publish', () => {
130
144
  expect(result).toEqual([
131
145
  {
132
146
  status: '✅ 发布成功',
133
- detail: '"延迟切换也能过" · 无图 · 发布成功',
147
+ detail: '"延迟切换也能过" · 1张图片 · 发布成功',
134
148
  },
135
149
  ]);
136
150
  });
@@ -27,6 +27,22 @@ const MAX_IMAGES = 9;
27
27
  const MAX_TITLE_LEN = 20;
28
28
  const UPLOAD_SETTLE_MS = 3000;
29
29
 
30
+ /** Selectors for the title field, ordered by priority (new UI first). */
31
+ const TITLE_SELECTORS = [
32
+ // New creator center (2026-03) uses contenteditable for the title field.
33
+ // Placeholder observed: "填写标题会有更多赞哦"
34
+ '[contenteditable="true"][placeholder*="标题"]',
35
+ '[contenteditable="true"][placeholder*="赞"]',
36
+ '[contenteditable="true"][class*="title"]',
37
+ 'input[maxlength="20"]',
38
+ 'input[class*="title"]',
39
+ 'input[placeholder*="标题"]',
40
+ 'input[placeholder*="title" i]',
41
+ '.title-input input',
42
+ '.note-title input',
43
+ 'input[maxlength]',
44
+ ];
45
+
30
46
  type ImagePayload = { name: string; mimeType: string; base64: string };
31
47
 
32
48
  /**
@@ -205,12 +221,19 @@ async function selectImageTextTab(
205
221
  return result;
206
222
  }
207
223
 
208
- async function inspectPublishSurface(
209
- page: IPage,
210
- ): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> {
224
+ type PublishSurfaceState = 'video_surface' | 'image_surface' | 'editor_ready';
225
+
226
+ type PublishSurfaceInspection = {
227
+ state: PublishSurfaceState;
228
+ hasTitleInput: boolean;
229
+ hasImageInput: boolean;
230
+ hasVideoSurface: boolean;
231
+ };
232
+
233
+ async function inspectPublishSurfaceState(page: IPage): Promise<PublishSurfaceInspection> {
211
234
  return page.evaluate(`
212
235
  () => {
213
- const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim();
236
+ const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim();
214
237
  const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => {
215
238
  if (!el || el.offsetParent === null) return false;
216
239
  const placeholder = (el.getAttribute('placeholder') || '').trim();
@@ -234,36 +257,57 @@ async function inspectPublishSurface(
234
257
  accept.includes('.webp')
235
258
  );
236
259
  });
237
- return {
238
- hasTitleInput,
239
- hasImageInput,
240
- hasVideoSurface: text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'),
241
- };
260
+ const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频');
261
+ const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface';
262
+ return { state, hasTitleInput, hasImageInput, hasVideoSurface };
242
263
  }
243
264
  `);
244
265
  }
245
266
 
246
- async function waitForImageTextSurface(
267
+ async function waitForPublishSurfaceState(
247
268
  page: IPage,
248
269
  maxWaitMs = 5_000,
249
- ): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> {
270
+ ): Promise<PublishSurfaceInspection> {
250
271
  const pollMs = 500;
251
272
  const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
252
- let surface = await inspectPublishSurface(page);
273
+ let surface = await inspectPublishSurfaceState(page);
253
274
 
254
275
  for (let i = 0; i < maxAttempts; i++) {
255
- if (surface.hasTitleInput || surface.hasImageInput || !surface.hasVideoSurface) {
276
+ if (surface.state !== 'video_surface') {
256
277
  return surface;
257
278
  }
258
279
  if (i < maxAttempts - 1) {
259
280
  await page.wait({ time: pollMs / 1_000 });
260
- surface = await inspectPublishSurface(page);
281
+ surface = await inspectPublishSurfaceState(page);
261
282
  }
262
283
  }
263
284
 
264
285
  return surface;
265
286
  }
266
287
 
288
+ /**
289
+ * Poll until the title/content editing form appears on the page.
290
+ * The new creator center UI only renders the editor after images are uploaded.
291
+ */
292
+ async function waitForEditForm(page: IPage, maxWaitMs = 10_000): Promise<boolean> {
293
+ const pollMs = 1_000;
294
+ const maxAttempts = Math.ceil(maxWaitMs / pollMs);
295
+ for (let i = 0; i < maxAttempts; i++) {
296
+ const found: boolean = await page.evaluate(`
297
+ (() => {
298
+ const sels = ${JSON.stringify(TITLE_SELECTORS)};
299
+ for (const sel of sels) {
300
+ const el = document.querySelector(sel);
301
+ if (el && el.offsetParent !== null) return true;
302
+ }
303
+ return false;
304
+ })()`);
305
+ if (found) return true;
306
+ if (i < maxAttempts - 1) await page.wait({ time: pollMs / 1_000 });
307
+ }
308
+ return false;
309
+ }
310
+
267
311
  cli({
268
312
  site: 'xiaohongshu',
269
313
  name: 'publish',
@@ -274,7 +318,7 @@ cli({
274
318
  args: [
275
319
  { name: 'title', required: true, help: '笔记标题 (最多20字)' },
276
320
  { name: 'content', required: true, positional: true, help: '笔记正文' },
277
- { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
321
+ { name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
278
322
  { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
279
323
  { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
280
324
  ],
@@ -297,6 +341,8 @@ cli({
297
341
  if (title.length > MAX_TITLE_LEN)
298
342
  throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
299
343
  if (!content) throw new Error('Positional argument <content> is required');
344
+ if (imagePaths.length === 0)
345
+ throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.');
300
346
  if (imagePaths.length > MAX_IMAGES)
301
347
  throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
302
348
 
@@ -318,8 +364,8 @@ cli({
318
364
 
319
365
  // ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
320
366
  const tabResult = await selectImageTextTab(page);
321
- const surface = await waitForImageTextSurface(page, tabResult?.ok ? 5_000 : 2_000);
322
- if (!surface.hasTitleInput && !surface.hasImageInput && surface.hasVideoSurface) {
367
+ const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000);
368
+ if (surface.state === 'video_surface') {
323
369
  await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
324
370
  const detail = tabResult?.ok
325
371
  ? `clicked "${tabResult.text}"`
@@ -331,35 +377,30 @@ cli({
331
377
  }
332
378
 
333
379
  // ── Step 3: Upload images ──────────────────────────────────────────────────
334
- if (imageData.length > 0) {
335
- const upload = await injectImages(page, imageData);
336
- if (!upload.ok) {
337
- await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
338
- throw new Error(
339
- `Image injection failed: ${upload.error ?? 'unknown'}. ` +
340
- 'Debug screenshot: /tmp/xhs_publish_upload_debug.png'
341
- );
342
- }
343
- // Allow XHS to process and upload images to its CDN
344
- await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
345
- await waitForUploads(page);
380
+ const upload = await injectImages(page, imageData);
381
+ if (!upload.ok) {
382
+ await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
383
+ throw new Error(
384
+ `Image injection failed: ${upload.error ?? 'unknown'}. ` +
385
+ 'Debug screenshot: /tmp/xhs_publish_upload_debug.png'
386
+ );
387
+ }
388
+ // Allow XHS to process and upload images to its CDN
389
+ await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
390
+ await waitForUploads(page);
391
+
392
+ // ── Step 3b: Wait for editor form to render ───────────────────────────────
393
+ const formReady = await waitForEditForm(page);
394
+ if (!formReady) {
395
+ await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' });
396
+ throw new Error(
397
+ 'Editing form did not appear after image upload. The page layout may have changed. ' +
398
+ 'Debug screenshot: /tmp/xhs_publish_form_debug.png'
399
+ );
346
400
  }
347
401
 
348
402
  // ── Step 4: Fill title ─────────────────────────────────────────────────────
349
- await fillField(
350
- page,
351
- [
352
- 'input[maxlength="20"]',
353
- 'input[class*="title"]',
354
- 'input[placeholder*="标题"]',
355
- 'input[placeholder*="title" i]',
356
- '.title-input input',
357
- '.note-title input',
358
- 'input[maxlength]',
359
- ],
360
- title,
361
- 'title'
362
- );
403
+ await fillField(page, TITLE_SELECTORS, title, 'title');
363
404
  await page.wait({ time: 0.5 });
364
405
 
365
406
  // ── Step 5: Fill content / body ────────────────────────────────────────────
@@ -374,7 +415,7 @@ cli({
374
415
  '.note-content [contenteditable="true"]',
375
416
  '.editor-content [contenteditable="true"]',
376
417
  // Broad fallback — last resort; filter out any title contenteditable
377
- '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
418
+ '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])',
378
419
  ],
379
420
  content,
380
421
  'content'
@@ -438,14 +479,14 @@ cli({
438
479
  }
439
480
 
440
481
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
441
- const actionLabel = isDraft ? '存草稿' : '发布';
482
+ const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
442
483
  const btnClicked: boolean = await page.evaluate(`
443
- (label => {
484
+ (labels => {
444
485
  const buttons = document.querySelectorAll('button, [role="button"]');
445
486
  for (const btn of buttons) {
446
487
  const text = (btn.innerText || btn.textContent || '').trim();
447
488
  if (
448
- (text === label || text.includes(label) || text === '发布笔记') &&
489
+ labels.some(l => text === l || text.includes(l)) &&
449
490
  btn.offsetParent !== null &&
450
491
  !btn.disabled
451
492
  ) {
@@ -454,13 +495,13 @@ cli({
454
495
  }
455
496
  }
456
497
  return false;
457
- })(${JSON.stringify(actionLabel)})
498
+ })(${JSON.stringify(actionLabels)})
458
499
  `);
459
500
 
460
501
  if (!btnClicked) {
461
502
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
462
503
  throw new Error(
463
- `Could not find "${actionLabel}" button. ` +
504
+ `Could not find "${actionLabels[0]}" button. ` +
464
505
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png'
465
506
  );
466
507
  }
@@ -475,7 +516,7 @@ cli({
475
516
  const text = (el.innerText || '').trim();
476
517
  if (
477
518
  el.children.length === 0 &&
478
- (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
519
+ (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
479
520
  ) return text;
480
521
  }
481
522
  return '';
@@ -484,14 +525,14 @@ cli({
484
525
 
485
526
  const navigatedAway = !finalUrl.includes('/publish/publish');
486
527
  const isSuccess = successMsg.length > 0 || navigatedAway;
487
- const verb = isDraft ? '草稿已保存' : '发布成功';
528
+ const verb = isDraft ? '暂存成功' : '发布成功';
488
529
 
489
530
  return [
490
531
  {
491
532
  status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
492
533
  detail: [
493
534
  `"${title}"`,
494
- imageData.length ? `${imageData.length}张图片` : '无图',
535
+ `${imageData.length}张图片`,
495
536
  topics.length ? `话题: ${topics.join(' ')}` : '',
496
537
  successMsg || finalUrl || '',
497
538
  ]
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import type { IPage } from '../../types.js';
3
3
  import { getRegistry } from '../../registry.js';
4
- import './search.js';
4
+ import { noteIdToDate } from './search.js';
5
5
 
6
6
  function createPageMock(evaluateResults: any[]): IPage {
7
7
  const evaluate = vi.fn();
@@ -86,6 +86,7 @@ describe('xiaohongshu search', () => {
86
86
  title: '某鱼买FSD被坑了4万',
87
87
  author: '随风',
88
88
  likes: '261',
89
+ published_at: '2025-10-10',
89
90
  url: detailUrl,
90
91
  author_url: authorUrl,
91
92
  },
@@ -132,3 +133,40 @@ describe('xiaohongshu search', () => {
132
133
  expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
133
134
  });
134
135
  });
136
+
137
+ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
138
+ it('parses a known note ID to the correct China-timezone date', () => {
139
+ // 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
140
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
141
+ // 0x68e90be8 → 2025-10-10 in UTC+8
142
+ expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
143
+ });
144
+
145
+ it('returns China date when UTC+8 crosses into the next day', () => {
146
+ // 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
147
+ // Without UTC+8 offset this would incorrectly return 2026-03-15
148
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
149
+ });
150
+
151
+ it('handles /note/ path variant', () => {
152
+ expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
153
+ });
154
+
155
+ it('handles URL with query parameters', () => {
156
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
157
+ });
158
+
159
+ it('returns empty string for non-matching URLs', () => {
160
+ expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
161
+ expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
162
+ });
163
+
164
+ it('returns empty string for IDs shorter than 24 hex chars', () => {
165
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
166
+ });
167
+
168
+ it('returns empty string when timestamp is out of range', () => {
169
+ // All zeros → ts = 0
170
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
171
+ });
172
+ });
@@ -9,6 +9,23 @@
9
9
  import { cli, Strategy } from '../../registry.js';
10
10
  import { AuthRequiredError } from '../../errors.js';
11
11
 
12
+ /**
13
+ * Extract approximate publish date from a Xiaohongshu note URL.
14
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
15
+ * characters encode a Unix timestamp (the moment the ID was generated,
16
+ * which closely matches publish time but is not an official API field).
17
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
18
+ */
19
+ export function noteIdToDate(url: string): string {
20
+ const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
21
+ if (!match) return '';
22
+ const hex = match[1].substring(0, 8);
23
+ const ts = parseInt(hex, 16);
24
+ if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000) 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
+ }
28
+
12
29
  cli({
13
30
  site: 'xiaohongshu',
14
31
  name: 'search',
@@ -19,7 +36,7 @@ cli({
19
36
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
20
37
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
21
38
  ],
22
- columns: ['rank', 'title', 'author', 'likes', 'url'],
39
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
23
40
  func: async (page, kwargs) => {
24
41
  const keyword = encodeURIComponent(kwargs.query);
25
42
  await page.goto(
@@ -97,6 +114,7 @@ cli({
97
114
  .map((item: any, i: number) => ({
98
115
  rank: i + 1,
99
116
  ...item,
117
+ published_at: noteIdToDate(item.url),
100
118
  }));
101
119
  },
102
120
  });
package/src/discovery.ts CHANGED
@@ -127,24 +127,20 @@ async function discoverClisFromFs(dir: string): Promise<void> {
127
127
  const site = entry.name;
128
128
  const siteDir = path.join(dir, site);
129
129
  const files = await fs.promises.readdir(siteDir);
130
- const filePromises: Promise<unknown>[] = [];
131
- for (const file of files) {
130
+ await Promise.all(files.map(async (file) => {
132
131
  const filePath = path.join(siteDir, file);
133
132
  if (file.endsWith('.yaml') || file.endsWith('.yml')) {
134
- filePromises.push(registerYamlCli(filePath, site));
133
+ await registerYamlCli(filePath, site);
135
134
  } else if (
136
135
  (file.endsWith('.js') && !file.endsWith('.d.js')) ||
137
136
  (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
138
137
  ) {
139
- if (!(await isCliModule(filePath))) continue;
140
- filePromises.push(
141
- import(pathToFileURL(filePath).href).catch((err) => {
142
- log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
143
- })
144
- );
138
+ if (!(await isCliModule(filePath))) return;
139
+ await import(pathToFileURL(filePath).href).catch((err) => {
140
+ log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
141
+ });
145
142
  }
146
- }
147
- await Promise.all(filePromises);
143
+ }));
148
144
  });
149
145
  await Promise.all(sitePromises);
150
146
  }
@@ -195,10 +191,11 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise<v
195
191
  export async function discoverPlugins(): Promise<void> {
196
192
  try { await fs.promises.access(PLUGINS_DIR); } catch { return; }
197
193
  const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
198
- for (const entry of entries) {
199
- if (!entry.isDirectory()) continue;
200
- await discoverPluginDir(path.join(PLUGINS_DIR, entry.name), entry.name);
201
- }
194
+ await Promise.all(entries.map(async (entry) => {
195
+ const pluginDir = path.join(PLUGINS_DIR, entry.name);
196
+ if (!(await isDiscoverablePluginDir(entry, pluginDir))) return;
197
+ await discoverPluginDir(pluginDir, entry.name);
198
+ }));
202
199
  }
203
200
 
204
201
  /**
@@ -208,33 +205,29 @@ export async function discoverPlugins(): Promise<void> {
208
205
  async function discoverPluginDir(dir: string, site: string): Promise<void> {
209
206
  const files = await fs.promises.readdir(dir);
210
207
  const fileSet = new Set(files);
211
- const promises: Promise<unknown>[] = [];
212
- for (const file of files) {
208
+ await Promise.all(files.map(async (file) => {
213
209
  const filePath = path.join(dir, file);
214
210
  if (file.endsWith('.yaml') || file.endsWith('.yml')) {
215
- promises.push(registerYamlCli(filePath, site));
211
+ await registerYamlCli(filePath, site);
216
212
  } else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
217
- if (!(await isCliModule(filePath))) continue;
218
- promises.push(
219
- import(pathToFileURL(filePath).href).catch((err) => {
220
- log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
221
- })
222
- );
213
+ if (!(await isCliModule(filePath))) return;
214
+ await import(pathToFileURL(filePath).href).catch((err) => {
215
+ log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
216
+ });
223
217
  } else if (
224
218
  file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')
225
219
  ) {
226
- // Skip .ts if a compiled .js sibling exists (production mode can't load .ts)
227
220
  const jsFile = file.replace(/\.ts$/, '.js');
228
- if (fileSet.has(jsFile)) continue;
229
- if (!(await isCliModule(filePath))) continue;
230
- promises.push(
231
- import(pathToFileURL(filePath).href).catch((err) => {
232
- log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
233
- })
221
+ // Prefer compiled .js — skip the .ts source file
222
+ if (fileSet.has(jsFile)) return;
223
+ // No compiled .js found — cannot import raw .ts in production Node.js.
224
+ // This typically means esbuild transpilation failed during plugin install.
225
+ log.warn(
226
+ `Plugin ${site}/${file}: no compiled .js found. ` +
227
+ `Run "opencli plugin update ${site}" to re-transpile, or install esbuild.`
234
228
  );
235
229
  }
236
- }
237
- await Promise.all(promises);
230
+ }));
238
231
  }
239
232
 
240
233
  async function isCliModule(filePath: string): Promise<boolean> {
@@ -246,3 +239,18 @@ async function isCliModule(filePath: string): Promise<boolean> {
246
239
  return false;
247
240
  }
248
241
  }
242
+
243
+ async function isDiscoverablePluginDir(entry: fs.Dirent, pluginDir: string): Promise<boolean> {
244
+ if (entry.isDirectory()) return true;
245
+ if (!entry.isSymbolicLink()) return false;
246
+
247
+ try {
248
+ return (await fs.promises.stat(pluginDir)).isDirectory();
249
+ } catch (err) {
250
+ const code = (err as NodeJS.ErrnoException).code;
251
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
252
+ log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`);
253
+ }
254
+ return false;
255
+ }
256
+ }
package/src/doctor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * opencli doctor — diagnose and fix browser connectivity.
2
+ * opencli doctor — diagnose browser connectivity.
3
3
  *
4
4
  * Simplified for the daemon-based architecture. No more token management,
5
5
  * MCP path discovery, or config file scanning.
@@ -14,7 +14,6 @@ import { getErrorMessage } from './errors.js';
14
14
  import { getRuntimeLabel } from './runtime-detect.js';
15
15
 
16
16
  export type DoctorOptions = {
17
- fix?: boolean;
18
17
  yes?: boolean;
19
18
  live?: boolean;
20
19
  sessions?: boolean;
@@ -87,7 +86,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
87
86
  issues.push(
88
87
  'Daemon is running but the Chrome extension is not connected.\n' +
89
88
  'Please install the opencli Browser Bridge extension:\n' +
90
- ' 1. Download from GitHub Releases\n' +
89
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
91
90
  ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
92
91
  ' 3. Click "Load unpacked" → select the extension folder',
93
92
  );