@jackwener/opencli 1.7.22 → 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 +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -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/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- 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/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -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/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- 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/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -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/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -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/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -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/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- 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/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -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-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- 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 +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -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/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -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 +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- 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/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -9,6 +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 { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
12
13
|
const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
|
|
13
14
|
const NOTE_DETAIL_METRICS = [
|
|
14
15
|
{ label: '曝光数', section: '基础数据' },
|
|
@@ -246,37 +247,170 @@ const DETAIL_API_ENDPOINTS = [
|
|
|
246
247
|
{ suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
|
|
247
248
|
{ suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
|
|
248
249
|
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
|
|
249
|
-
{ suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
|
|
250
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' },
|
|
250
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
|
+
}
|
|
251
377
|
async function captureNoteDetailPayload(page, noteId) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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;
|
|
258
392
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const json = await resp.json();
|
|
265
|
-
return JSON.stringify(json.data ?? {});
|
|
266
|
-
} 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');
|
|
267
398
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
try {
|
|
271
|
-
payload[key] = JSON.parse(data);
|
|
272
|
-
captured++;
|
|
273
|
-
}
|
|
274
|
-
catch { }
|
|
275
|
-
}
|
|
399
|
+
if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) {
|
|
400
|
+
throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer');
|
|
276
401
|
}
|
|
277
|
-
|
|
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);
|
|
278
412
|
}
|
|
279
|
-
return
|
|
413
|
+
return Object.keys(payload).length > 0 ? payload : null;
|
|
280
414
|
}
|
|
281
415
|
async function captureNoteDetailDomData(page) {
|
|
282
416
|
const result = await page.evaluate(`() => {
|
|
@@ -307,14 +441,18 @@ async function captureNoteDetailDomData(page) {
|
|
|
307
441
|
return result;
|
|
308
442
|
}
|
|
309
443
|
export async function fetchCreatorNoteDetailRows(page, noteId) {
|
|
310
|
-
|
|
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);
|
|
311
450
|
const domData = await captureNoteDetailDomData(page).catch(() => null);
|
|
312
451
|
let rows = parseCreatorNoteDetailDomData(domData, noteId);
|
|
313
452
|
if (rows.length === 0) {
|
|
314
453
|
const bodyText = await page.evaluate('() => document.body.innerText');
|
|
315
454
|
rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
|
|
316
455
|
}
|
|
317
|
-
const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
|
|
318
456
|
appendTrendRows(rows, apiPayload ?? undefined);
|
|
319
457
|
appendAudienceRows(rows, apiPayload ?? undefined);
|
|
320
458
|
return rows;
|
|
@@ -337,7 +475,7 @@ cli({
|
|
|
337
475
|
const rows = await fetchCreatorNoteDetailRows(page, noteId);
|
|
338
476
|
const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value);
|
|
339
477
|
if (!hasCoreMetric) {
|
|
340
|
-
throw new
|
|
478
|
+
throw new EmptyResultError('xiaohongshu creator-note-detail', 'No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
|
|
341
479
|
}
|
|
342
480
|
return rows;
|
|
343
481
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
4
|
import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js';
|
|
4
5
|
import './creator-note-detail.js';
|
|
@@ -207,40 +208,44 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
207
208
|
it('navigates to the note detail page and returns parsed rows', async () => {
|
|
208
209
|
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
209
210
|
expect(cmd?.func).toBeTypeOf('function');
|
|
210
|
-
const
|
|
211
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
241
246
|
const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
242
|
-
expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics
|
|
243
|
-
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 =');
|
|
244
249
|
expect(result).toEqual([
|
|
245
250
|
{ section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
|
|
246
251
|
{ section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
|
|
@@ -256,7 +261,7 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
256
261
|
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
257
262
|
]);
|
|
258
263
|
});
|
|
259
|
-
it('
|
|
264
|
+
it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => {
|
|
260
265
|
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
261
266
|
const domData = {
|
|
262
267
|
title: '示例笔记',
|
|
@@ -283,9 +288,164 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
283
288
|
},
|
|
284
289
|
],
|
|
285
290
|
};
|
|
286
|
-
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
|
+
});
|
|
287
300
|
await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
288
|
-
|
|
301
|
+
// Capture loop polls until the deadline expires (no hits with empty mock).
|
|
289
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
|
+
}
|
|
440
|
+
});
|
|
441
|
+
it('throws EmptyResultError when the detail page exposes no metrics', async () => {
|
|
442
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
443
|
+
const page = createPageMock(undefined);
|
|
444
|
+
page.evaluate = vi.fn()
|
|
445
|
+
.mockResolvedValueOnce(null)
|
|
446
|
+
.mockResolvedValueOnce('笔记数据详情\n暂无数据')
|
|
447
|
+
.mockResolvedValue(null);
|
|
448
|
+
|
|
449
|
+
await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
290
450
|
});
|
|
291
451
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* returns one summary row per note, suitable for quick review or downstream JSON use.
|
|
6
6
|
*/
|
|
7
7
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
9
|
import { fetchCreatorNotes } from './creator-notes.js';
|
|
9
10
|
import { fetchCreatorNoteDetailRows } from './creator-note-detail.js';
|
|
10
11
|
function findDetailValue(rows, metric) {
|
|
@@ -61,7 +62,7 @@ cli({
|
|
|
61
62
|
const limit = kwargs.limit || 3;
|
|
62
63
|
const notes = await fetchCreatorNotes(page, limit);
|
|
63
64
|
if (!notes.length) {
|
|
64
|
-
throw new
|
|
65
|
+
throw new EmptyResultError('xiaohongshu creator-notes-summary', 'No notes found. Ensure you are logged into creator.xiaohongshu.com and the account has published notes.');
|
|
65
66
|
}
|
|
66
67
|
const results = [];
|
|
67
68
|
for (const [index, note] of notes.entries()) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { summarizeCreatorNote } from './creator-notes-summary.js';
|
|
3
4
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
5
|
import * as creatorNotesModule from './creator-notes.js';
|
|
@@ -84,4 +85,10 @@ describe('xiaohongshu creator-notes-summary', () => {
|
|
|
84
85
|
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
|
|
85
86
|
expect(page.wait.mock.calls).toHaveLength(1);
|
|
86
87
|
});
|
|
88
|
+
it('throws EmptyResultError when there are no notes to summarize', async () => {
|
|
89
|
+
const cmd = getRegistry().get('xiaohongshu/creator-notes-summary');
|
|
90
|
+
vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([]);
|
|
91
|
+
|
|
92
|
+
await expect(cmd.func({ wait: vi.fn() }, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
93
|
+
});
|
|
87
94
|
});
|