@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.
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +52 -3
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +460 -1
- package/dist/cli.js +34 -3
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/daemon.js +1 -0
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +9 -5
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -13
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +49 -3
- package/src/browser.test.ts +6 -0
- package/src/cli.ts +34 -3
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/daemon.ts +1 -0
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +11 -8
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -15
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
package/src/clis/weread/utils.ts
CHANGED
|
@@ -12,11 +12,16 @@ import type { BrowserCookie, IPage } from '../../types.js';
|
|
|
12
12
|
const WEB_API = 'https://weread.qq.com/web';
|
|
13
13
|
const API = 'https://i.weread.qq.com';
|
|
14
14
|
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';
|
|
15
|
+
const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
|
|
15
16
|
|
|
16
17
|
function buildCookieHeader(cookies: BrowserCookie[]): string {
|
|
17
18
|
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function isAuthErrorResponse(resp: Response, data: any): boolean {
|
|
22
|
+
return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
/**
|
|
21
26
|
* Fetch a public WeRead web endpoint (Node.js direct fetch).
|
|
22
27
|
* Used by search and ranking commands (browser: false).
|
|
@@ -78,7 +83,7 @@ export async function fetchPrivateApi(page: IPage, path: string, params?: Record
|
|
|
78
83
|
throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
if (resp
|
|
86
|
+
if (isAuthErrorResponse(resp, data)) {
|
|
82
87
|
throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
|
|
83
88
|
}
|
|
84
89
|
if (!resp.ok) {
|
|
@@ -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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
267
|
+
async function waitForPublishSurfaceState(
|
|
247
268
|
page: IPage,
|
|
248
269
|
maxWaitMs = 5_000,
|
|
249
|
-
): Promise<
|
|
270
|
+
): Promise<PublishSurfaceInspection> {
|
|
250
271
|
const pollMs = 500;
|
|
251
272
|
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
|
|
252
|
-
let surface = await
|
|
273
|
+
let surface = await inspectPublishSurfaceState(page);
|
|
253
274
|
|
|
254
275
|
for (let i = 0; i < maxAttempts; i++) {
|
|
255
|
-
if (surface.
|
|
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
|
|
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:
|
|
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
|
|
322
|
-
if (
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
482
|
+
const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
|
|
442
483
|
const btnClicked: boolean = await page.evaluate(`
|
|
443
|
-
(
|
|
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 ===
|
|
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(
|
|
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 "${
|
|
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
|
-
|
|
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/daemon.ts
CHANGED
|
@@ -198,6 +198,7 @@ const wss = new WebSocketServer({
|
|
|
198
198
|
wss.on('connection', (ws: WebSocket) => {
|
|
199
199
|
console.error('[daemon] Extension connected');
|
|
200
200
|
extensionWs = ws;
|
|
201
|
+
extensionVersion = null; // cleared until hello message arrives
|
|
201
202
|
|
|
202
203
|
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
203
204
|
let missedPongs = 0;
|
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
|
-
|
|
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
|
-
|
|
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)))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
+
await registerYamlCli(filePath, site);
|
|
216
212
|
} else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
|
|
217
|
-
if (!(await isCliModule(filePath)))
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
);
|
|
@@ -96,11 +95,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
96
95
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
if (status.extensionVersion && opts.cliVersion
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
if (status.extensionVersion && opts.cliVersion) {
|
|
99
|
+
const extMajor = status.extensionVersion.split('.')[0];
|
|
100
|
+
const cliMajor = opts.cliVersion.split('.')[0];
|
|
101
|
+
if (extMajor !== cliMajor) {
|
|
102
|
+
issues.push(
|
|
103
|
+
`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
104
|
+
' Download the latest extension from: https://github.com/jackwener/opencli/releases',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
return {
|