@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
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
210
|
+
async function inspectPublishSurfaceState(page) {
|
|
196
211
|
return page.evaluate(`
|
|
197
212
|
() => {
|
|
198
|
-
const text = (document.body?.innerText || '').replace(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
246
|
+
let surface = await inspectPublishSurfaceState(page);
|
|
234
247
|
for (let i = 0; i < maxAttempts; i++) {
|
|
235
|
-
if (surface.
|
|
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
|
|
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:
|
|
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
|
|
295
|
-
if (
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
429
|
+
const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
|
|
394
430
|
const btnClicked = await page.evaluate(`
|
|
395
|
-
(
|
|
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 ===
|
|
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(
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|