@jackwener/opencli 1.8.0 → 1.8.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.
- package/README.md +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-remove.js +1 -1
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/package.json +1 -1
|
@@ -245,6 +245,86 @@ describe('weread/search regression', () => {
|
|
|
245
245
|
},
|
|
246
246
|
]);
|
|
247
247
|
});
|
|
248
|
+
it('decodes named and astral HTML entities before matching search cards', async () => {
|
|
249
|
+
const command = getRegistry().get('weread/search');
|
|
250
|
+
expect(command?.func).toBeTypeOf('function');
|
|
251
|
+
const fetchMock = vi.fn()
|
|
252
|
+
.mockResolvedValueOnce({
|
|
253
|
+
ok: true,
|
|
254
|
+
json: () => Promise.resolve({
|
|
255
|
+
books: [
|
|
256
|
+
{
|
|
257
|
+
bookInfo: {
|
|
258
|
+
title: 'A <B> 😊',
|
|
259
|
+
author: "O'Neil & Co",
|
|
260
|
+
bookId: 'entity-book',
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
.mockResolvedValueOnce({
|
|
267
|
+
ok: true,
|
|
268
|
+
text: () => Promise.resolve(`
|
|
269
|
+
<ul class="search_bookDetail_list">
|
|
270
|
+
<li class="wr_bookList_item">
|
|
271
|
+
<a class="wr_bookList_item_link" href="/web/reader/entity-reader"></a>
|
|
272
|
+
<p class="wr_bookList_item_title">A <B> 😊</p>
|
|
273
|
+
<p class="wr_bookList_item_author">O'Neil & Co</p>
|
|
274
|
+
</li>
|
|
275
|
+
</ul>
|
|
276
|
+
`),
|
|
277
|
+
});
|
|
278
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
279
|
+
const result = await command.func({ query: 'entities', limit: 5 });
|
|
280
|
+
expect(result).toEqual([
|
|
281
|
+
{
|
|
282
|
+
rank: 1,
|
|
283
|
+
title: 'A <B> 😊',
|
|
284
|
+
author: "O'Neil & Co",
|
|
285
|
+
bookId: 'entity-book',
|
|
286
|
+
url: 'https://weread.qq.com/web/reader/entity-reader',
|
|
287
|
+
},
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
it('leaves invalid numeric HTML entities literal instead of throwing raw RangeError', async () => {
|
|
291
|
+
const command = getRegistry().get('weread/search');
|
|
292
|
+
expect(command?.func).toBeTypeOf('function');
|
|
293
|
+
const fetchMock = vi.fn()
|
|
294
|
+
.mockResolvedValueOnce({
|
|
295
|
+
ok: true,
|
|
296
|
+
json: () => Promise.resolve({
|
|
297
|
+
books: [
|
|
298
|
+
{
|
|
299
|
+
bookInfo: {
|
|
300
|
+
title: 'Bad � Entity',
|
|
301
|
+
author: 'Tester',
|
|
302
|
+
bookId: 'bad-entity-book',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
}),
|
|
307
|
+
})
|
|
308
|
+
.mockResolvedValueOnce({
|
|
309
|
+
ok: true,
|
|
310
|
+
text: () => Promise.resolve(`
|
|
311
|
+
<ul class="search_bookDetail_list">
|
|
312
|
+
<li class="wr_bookList_item">
|
|
313
|
+
<a class="wr_bookList_item_link" href="/web/reader/bad-entity-reader"></a>
|
|
314
|
+
<p class="wr_bookList_item_title">Bad � Entity</p>
|
|
315
|
+
<p class="wr_bookList_item_author">Tester</p>
|
|
316
|
+
</li>
|
|
317
|
+
</ul>
|
|
318
|
+
`),
|
|
319
|
+
});
|
|
320
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
321
|
+
const result = await command.func({ query: 'bad entity', limit: 5 });
|
|
322
|
+
expect(result[0]).toMatchObject({
|
|
323
|
+
title: 'Bad � Entity',
|
|
324
|
+
bookId: 'bad-entity-book',
|
|
325
|
+
url: 'https://weread.qq.com/web/reader/bad-entity-reader',
|
|
326
|
+
});
|
|
327
|
+
});
|
|
248
328
|
it('leaves urls empty when same-title results are ambiguous and html cards have no author', async () => {
|
|
249
329
|
const command = getRegistry().get('weread/search');
|
|
250
330
|
expect(command?.func).toBeTypeOf('function');
|
package/clis/weread/search.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
|
|
4
|
+
function decodeNumericHtmlEntity(raw, radix) {
|
|
5
|
+
const codePoint = parseInt(raw, radix);
|
|
6
|
+
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return String.fromCodePoint(codePoint);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
4
16
|
function decodeHtmlText(value) {
|
|
5
17
|
return value
|
|
6
18
|
.replace(/<[^>]+>/g, '')
|
|
7
|
-
.replace(/&#x([0-9a-fA-F]+);/gi, (
|
|
8
|
-
.replace(/&#(\d+);/g, (
|
|
19
|
+
.replace(/&#x([0-9a-fA-F]+);/gi, (entity, n) => decodeNumericHtmlEntity(n, 16) ?? entity)
|
|
20
|
+
.replace(/&#(\d+);/g, (entity, n) => decodeNumericHtmlEntity(n, 10) ?? entity)
|
|
9
21
|
.replace(/ /g, ' ')
|
|
10
22
|
.replace(/&/g, '&')
|
|
11
23
|
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
12
27
|
.trim();
|
|
13
28
|
}
|
|
14
29
|
function normalizeSearchTitle(value) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
10
10
|
*/
|
|
11
11
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
12
|
-
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
12
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
13
13
|
const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
|
|
14
14
|
const NOTE_DETAIL_METRICS = [
|
|
15
15
|
{ label: '曝光数', section: '基础数据' },
|
|
@@ -247,37 +247,170 @@ const DETAIL_API_ENDPOINTS = [
|
|
|
247
247
|
{ suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
|
|
248
248
|
{ suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
|
|
249
249
|
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
|
|
250
|
-
{ suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
|
|
250
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' },
|
|
251
251
|
];
|
|
252
|
+
const CAPTURE_POLL_ATTEMPTS = 20;
|
|
253
|
+
const CAPTURE_POLL_INTERVAL_S = 0.5;
|
|
254
|
+
function detailApiEndpointForUrl(url) {
|
|
255
|
+
if (!url)
|
|
256
|
+
return null;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new URL(String(url), 'https://creator.xiaohongshu.com');
|
|
259
|
+
return DETAIL_API_ENDPOINTS.find((endpoint) => parsed.pathname === endpoint.suffix) ?? null;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function findCapturedUrl(captureMap, suffix) {
|
|
266
|
+
return Object.keys(captureMap).find((url) => detailApiEndpointForUrl(url)?.suffix === suffix);
|
|
267
|
+
}
|
|
268
|
+
function isPlainObject(value) {
|
|
269
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
270
|
+
}
|
|
271
|
+
function assertOptionalArray(payload, key, suffix) {
|
|
272
|
+
if (key in payload && !Array.isArray(payload[key])) {
|
|
273
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function assertOptionalPlainObject(payload, key, suffix) {
|
|
277
|
+
if (key in payload && !isPlainObject(payload[key])) {
|
|
278
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function validateCapturedPayload(payload, endpoint) {
|
|
282
|
+
const suffix = endpoint.suffix;
|
|
283
|
+
if (!isPlainObject(payload)) {
|
|
284
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a malformed payload`);
|
|
285
|
+
}
|
|
286
|
+
if (endpoint.key === 'noteBase') {
|
|
287
|
+
assertOptionalPlainObject(payload, 'hour', suffix);
|
|
288
|
+
assertOptionalPlainObject(payload, 'day', suffix);
|
|
289
|
+
}
|
|
290
|
+
if (endpoint.key === 'audienceSource') {
|
|
291
|
+
assertOptionalArray(payload, 'source', suffix);
|
|
292
|
+
}
|
|
293
|
+
if (endpoint.key === 'audienceSourceDetail') {
|
|
294
|
+
for (const key of ['gender', 'age', 'city', 'interest']) {
|
|
295
|
+
assertOptionalArray(payload, key, suffix);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return payload;
|
|
299
|
+
}
|
|
300
|
+
function parseCapturedJson(capture, endpoint) {
|
|
301
|
+
const suffix = endpoint.suffix;
|
|
302
|
+
if (!capture || typeof capture !== 'object') {
|
|
303
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: malformed capture for ${suffix}`);
|
|
304
|
+
}
|
|
305
|
+
if (capture.ok !== true) {
|
|
306
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned HTTP ${capture.status ?? 'non-2xx'}`);
|
|
307
|
+
}
|
|
308
|
+
if (typeof capture.body !== 'string') {
|
|
309
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a non-text body`);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const envelope = JSON.parse(capture.body);
|
|
313
|
+
const payload = isPlainObject(envelope) && Object.hasOwn(envelope, 'data') ? envelope.data : envelope;
|
|
314
|
+
return validateCapturedPayload(payload, endpoint);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned invalid JSON or payload shape`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Capture the dashboard's signed datacenter/note responses on window.__xhsCapture
|
|
321
|
+
// since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406.
|
|
322
|
+
async function installXhsFetchCaptureHook(page) {
|
|
323
|
+
await page.evaluate(`(() => {
|
|
324
|
+
const targetPaths = ${JSON.stringify(DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix))};
|
|
325
|
+
const shouldCapture = (url) => {
|
|
326
|
+
try {
|
|
327
|
+
return targetPaths.includes(new URL(String(url), window.location.origin).pathname);
|
|
328
|
+
} catch (_) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
// Reset the buffer every call so stale captures from a previous run on
|
|
333
|
+
// the same tab cannot leak into the current navigation's harvest.
|
|
334
|
+
window.__xhsCapture = {};
|
|
335
|
+
if (window.__xhsCaptureInstalled) return;
|
|
336
|
+
window.__xhsCaptureInstalled = true;
|
|
337
|
+
const origFetch = window.fetch;
|
|
338
|
+
window.fetch = async function(...args) {
|
|
339
|
+
const resp = await origFetch.apply(this, args);
|
|
340
|
+
try {
|
|
341
|
+
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
342
|
+
if (shouldCapture(url)) {
|
|
343
|
+
resp.clone().text().then((body) => {
|
|
344
|
+
try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {}
|
|
345
|
+
}).catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
} catch (_) {}
|
|
348
|
+
return resp;
|
|
349
|
+
};
|
|
350
|
+
const OrigXHR = window.XMLHttpRequest;
|
|
351
|
+
function HookedXHR() {
|
|
352
|
+
const xhr = new OrigXHR();
|
|
353
|
+
const origOpen = xhr.open;
|
|
354
|
+
let capturedUrl = '';
|
|
355
|
+
xhr.open = function(method, url, ...rest) {
|
|
356
|
+
capturedUrl = url;
|
|
357
|
+
return origOpen.call(this, method, url, ...rest);
|
|
358
|
+
};
|
|
359
|
+
xhr.addEventListener('load', () => {
|
|
360
|
+
try {
|
|
361
|
+
if (shouldCapture(capturedUrl)) {
|
|
362
|
+
window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText };
|
|
363
|
+
}
|
|
364
|
+
} catch (_) {}
|
|
365
|
+
});
|
|
366
|
+
return xhr;
|
|
367
|
+
}
|
|
368
|
+
HookedXHR.prototype = OrigXHR.prototype;
|
|
369
|
+
// Preserve readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE)
|
|
370
|
+
// since dashboard code may read XMLHttpRequest.DONE etc against the constructor.
|
|
371
|
+
for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
|
|
372
|
+
if (key in OrigXHR) HookedXHR[key] = OrigXHR[key];
|
|
373
|
+
}
|
|
374
|
+
window.XMLHttpRequest = HookedXHR;
|
|
375
|
+
})()`);
|
|
376
|
+
}
|
|
252
377
|
async function captureNoteDetailPayload(page, noteId) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
378
|
+
await installXhsFetchCaptureHook(page);
|
|
379
|
+
// SPA-navigate inside the dashboard so the React router re-fires the
|
|
380
|
+
// signed datacenter/note/* requests under our hook. A second page.goto
|
|
381
|
+
// would wipe the hook before the first auto-fetch can land.
|
|
382
|
+
await page.evaluate(`(() => {
|
|
383
|
+
const target = '/statistics/note-detail?noteId=' + ${JSON.stringify(noteId)};
|
|
384
|
+
history.pushState({}, '', target);
|
|
385
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
386
|
+
})()`);
|
|
387
|
+
const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix);
|
|
388
|
+
let captureMap = {};
|
|
389
|
+
for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) {
|
|
390
|
+
await page.wait(CAPTURE_POLL_INTERVAL_S);
|
|
391
|
+
let raw;
|
|
259
392
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const json = await resp.json();
|
|
266
|
-
return JSON.stringify(json.data ?? {});
|
|
267
|
-
} catch { return null; }
|
|
393
|
+
raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
|
|
394
|
+
captureMap = typeof raw === 'string' ? JSON.parse(raw) : {};
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
throw new CommandExecutionError('xiaohongshu creator-note-detail: failed to read signed datacenter/note capture buffer');
|
|
268
398
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
payload[key] = JSON.parse(data);
|
|
273
|
-
captured++;
|
|
274
|
-
}
|
|
275
|
-
catch { }
|
|
276
|
-
}
|
|
399
|
+
if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) {
|
|
400
|
+
throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer');
|
|
277
401
|
}
|
|
278
|
-
|
|
402
|
+
const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix));
|
|
403
|
+
if (captured.length === wantedSuffixes.length)
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
const payload = {};
|
|
407
|
+
for (const endpoint of DETAIL_API_ENDPOINTS) {
|
|
408
|
+
const matchUrl = findCapturedUrl(captureMap, endpoint.suffix);
|
|
409
|
+
if (!matchUrl)
|
|
410
|
+
continue;
|
|
411
|
+
payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint);
|
|
279
412
|
}
|
|
280
|
-
return
|
|
413
|
+
return Object.keys(payload).length > 0 ? payload : null;
|
|
281
414
|
}
|
|
282
415
|
async function captureNoteDetailDomData(page) {
|
|
283
416
|
const result = await page.evaluate(`() => {
|
|
@@ -308,14 +441,18 @@ async function captureNoteDetailDomData(page) {
|
|
|
308
441
|
return result;
|
|
309
442
|
}
|
|
310
443
|
export async function fetchCreatorNoteDetailRows(page, noteId) {
|
|
311
|
-
|
|
444
|
+
// Land on the dashboard root first so the React app boots before the
|
|
445
|
+
// note-specific signed APIs fire. captureNoteDetailPayload then installs
|
|
446
|
+
// the fetch+XHR hook and SPA-navigates to /statistics/note-detail under
|
|
447
|
+
// it, which is what surfaces the audience / trend rows.
|
|
448
|
+
await page.goto('https://creator.xiaohongshu.com/statistics');
|
|
449
|
+
const apiPayload = await captureNoteDetailPayload(page, noteId);
|
|
312
450
|
const domData = await captureNoteDetailDomData(page).catch(() => null);
|
|
313
451
|
let rows = parseCreatorNoteDetailDomData(domData, noteId);
|
|
314
452
|
if (rows.length === 0) {
|
|
315
453
|
const bodyText = await page.evaluate('() => document.body.innerText');
|
|
316
454
|
rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
|
|
317
455
|
}
|
|
318
|
-
const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
|
|
319
456
|
appendTrendRows(rows, apiPayload ?? undefined);
|
|
320
457
|
appendAudienceRows(rows, apiPayload ?? undefined);
|
|
321
458
|
return rows;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
4
|
import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js';
|
|
5
5
|
import './creator-note-detail.js';
|
|
@@ -208,40 +208,44 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
208
208
|
it('navigates to the note detail page and returns parsed rows', async () => {
|
|
209
209
|
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
210
210
|
expect(cmd?.func).toBeTypeOf('function');
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
211
|
+
const domData = {
|
|
212
|
+
title: '示例笔记',
|
|
213
|
+
infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
|
|
214
|
+
sections: [
|
|
215
|
+
{
|
|
216
|
+
title: '基础数据',
|
|
217
|
+
metrics: [
|
|
218
|
+
{ label: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
|
219
|
+
{ label: '观看数', value: '50', extra: '粉丝占比 20%' },
|
|
220
|
+
{ label: '封面点击率', value: '12%', extra: '粉丝 11%' },
|
|
221
|
+
{ label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
|
|
222
|
+
{ label: '涨粉数', value: '2', extra: '' },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
title: '互动数据',
|
|
227
|
+
metrics: [
|
|
228
|
+
{ label: '点赞数', value: '8', extra: '粉丝占比 25%' },
|
|
229
|
+
{ label: '评论数', value: '1', extra: '粉丝占比 0%' },
|
|
230
|
+
{ label: '收藏数', value: '3', extra: '粉丝占比 50%' },
|
|
231
|
+
{ label: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
const page = createPageMock(undefined);
|
|
237
|
+
page.evaluate = vi.fn(async (script) => {
|
|
238
|
+
const s = String(script);
|
|
239
|
+
if (s.includes('window.__xhsCapture =')) return undefined;
|
|
240
|
+
if (s.includes('history.pushState')) return undefined;
|
|
241
|
+
if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
|
|
242
|
+
if (s.includes("document.querySelector('.note-title')")) return domData;
|
|
243
|
+
if (s.includes('document.body.innerText')) return '';
|
|
244
|
+
return undefined;
|
|
245
|
+
});
|
|
242
246
|
const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
243
|
-
expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics
|
|
244
|
-
expect(page.evaluate.mock.calls[0][0]).toContain(
|
|
247
|
+
expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics');
|
|
248
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('window.__xhsCapture =');
|
|
245
249
|
expect(result).toEqual([
|
|
246
250
|
{ section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
|
|
247
251
|
{ section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
|
|
@@ -257,7 +261,7 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
257
261
|
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
258
262
|
]);
|
|
259
263
|
});
|
|
260
|
-
it('
|
|
264
|
+
it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => {
|
|
261
265
|
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
262
266
|
const domData = {
|
|
263
267
|
title: '示例笔记',
|
|
@@ -284,10 +288,155 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
284
288
|
},
|
|
285
289
|
],
|
|
286
290
|
};
|
|
287
|
-
const page = createPageMock(
|
|
291
|
+
const page = createPageMock(undefined);
|
|
292
|
+
page.evaluate = vi.fn(async (script) => {
|
|
293
|
+
const s = String(script);
|
|
294
|
+
if (s.includes('window.__xhsCapture =')) return undefined;
|
|
295
|
+
if (s.includes('history.pushState')) return undefined;
|
|
296
|
+
if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
|
|
297
|
+
if (s.includes("document.querySelector('.note-title')")) return domData;
|
|
298
|
+
return undefined;
|
|
299
|
+
});
|
|
288
300
|
await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
289
|
-
|
|
301
|
+
// Capture loop polls until the deadline expires (no hits with empty mock).
|
|
290
302
|
expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
|
|
303
|
+
const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture'));
|
|
304
|
+
expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1);
|
|
305
|
+
});
|
|
306
|
+
it('matches signed API captures by exact pathname so source/detail cannot shadow source', async () => {
|
|
307
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
308
|
+
const domData = {
|
|
309
|
+
title: '示例笔记',
|
|
310
|
+
infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
|
|
311
|
+
sections: [
|
|
312
|
+
{
|
|
313
|
+
title: '基础数据',
|
|
314
|
+
metrics: [
|
|
315
|
+
{ label: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
const detailCapture = [
|
|
321
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=demo-note-id',
|
|
322
|
+
{
|
|
323
|
+
status: 200,
|
|
324
|
+
ok: true,
|
|
325
|
+
body: JSON.stringify({ data: { gender: [{ title: '女性', value: 64 }] } }),
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
const sourceCapture = [
|
|
329
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id',
|
|
330
|
+
{
|
|
331
|
+
status: 200,
|
|
332
|
+
ok: true,
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
data: {
|
|
335
|
+
source: [
|
|
336
|
+
{
|
|
337
|
+
title: '首页推荐',
|
|
338
|
+
value_with_double: 88.8,
|
|
339
|
+
info: { imp_count: 1000, view_count: 400, interaction_count: 20 },
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
const baseCapture = [
|
|
347
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id',
|
|
348
|
+
{
|
|
349
|
+
status: 200,
|
|
350
|
+
ok: true,
|
|
351
|
+
body: JSON.stringify({ data: { hour: { view_list: [{ date: new Date('2026-03-19T12:00:00+08:00').getTime(), count: 7 }] } } }),
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
const trendCapture = [
|
|
355
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=demo-note-id',
|
|
356
|
+
{
|
|
357
|
+
status: 200,
|
|
358
|
+
ok: true,
|
|
359
|
+
body: JSON.stringify({ data: { no_data: false, no_data_tip_msg: '趋势可用' } }),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
for (const orderedCaptures of [
|
|
363
|
+
[detailCapture, sourceCapture, baseCapture, trendCapture],
|
|
364
|
+
[sourceCapture, detailCapture, baseCapture, trendCapture],
|
|
365
|
+
]) {
|
|
366
|
+
const captureMap = Object.fromEntries(orderedCaptures);
|
|
367
|
+
const page = createPageMock(undefined);
|
|
368
|
+
page.evaluate = vi.fn(async (script) => {
|
|
369
|
+
const s = String(script);
|
|
370
|
+
if (s.includes('window.__xhsCapture =')) return undefined;
|
|
371
|
+
if (s.includes('history.pushState')) return undefined;
|
|
372
|
+
if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
|
|
373
|
+
if (s.includes("document.querySelector('.note-title')")) return domData;
|
|
374
|
+
return undefined;
|
|
375
|
+
});
|
|
376
|
+
const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
377
|
+
expect(result).toEqual(expect.arrayContaining([
|
|
378
|
+
{ section: '观看来源', metric: '首页推荐', value: '88.8%', extra: '曝光 1000 · 观看 400 · 互动 20' },
|
|
379
|
+
{ section: '观众画像', metric: '性别/女性', value: '64%', extra: '' },
|
|
380
|
+
{ section: '趋势数据', metric: '按小时/观看数', value: '1 points', extra: '03-19 12:00=7' },
|
|
381
|
+
]));
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
it('throws a typed error when a captured signed API returns non-2xx', async () => {
|
|
385
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
386
|
+
const captureMap = {
|
|
387
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id': {
|
|
388
|
+
status: 406,
|
|
389
|
+
ok: false,
|
|
390
|
+
body: '{"msg":"not acceptable"}',
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
const page = createPageMock(undefined);
|
|
394
|
+
page.evaluate = vi.fn(async (script) => {
|
|
395
|
+
const s = String(script);
|
|
396
|
+
if (s.includes('window.__xhsCapture =')) return undefined;
|
|
397
|
+
if (s.includes('history.pushState')) return undefined;
|
|
398
|
+
if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
|
|
399
|
+
return null;
|
|
400
|
+
});
|
|
401
|
+
await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
402
|
+
});
|
|
403
|
+
it('throws a typed error for wrong-shaped signed API payloads instead of falling back to DOM rows', async () => {
|
|
404
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
405
|
+
const domData = {
|
|
406
|
+
title: '示例笔记',
|
|
407
|
+
infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
|
|
408
|
+
sections: [
|
|
409
|
+
{
|
|
410
|
+
title: '基础数据',
|
|
411
|
+
metrics: [
|
|
412
|
+
{ label: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
};
|
|
417
|
+
for (const body of [
|
|
418
|
+
JSON.stringify({ data: null }),
|
|
419
|
+
JSON.stringify({ data: [] }),
|
|
420
|
+
JSON.stringify({ data: { source: {} } }),
|
|
421
|
+
]) {
|
|
422
|
+
const captureMap = {
|
|
423
|
+
'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id': {
|
|
424
|
+
status: 200,
|
|
425
|
+
ok: true,
|
|
426
|
+
body,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
const page = createPageMock(undefined);
|
|
430
|
+
page.evaluate = vi.fn(async (script) => {
|
|
431
|
+
const s = String(script);
|
|
432
|
+
if (s.includes('window.__xhsCapture =')) return undefined;
|
|
433
|
+
if (s.includes('history.pushState')) return undefined;
|
|
434
|
+
if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
|
|
435
|
+
if (s.includes("document.querySelector('.note-title')")) return domData;
|
|
436
|
+
return null;
|
|
437
|
+
});
|
|
438
|
+
await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
439
|
+
}
|
|
291
440
|
});
|
|
292
441
|
it('throws EmptyResultError when the detail page exposes no metrics', async () => {
|
|
293
442
|
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|