@jackwener/opencli 1.7.22 → 1.8.0
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 +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- 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/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/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- 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/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/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/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/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/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -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/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- 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/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 +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -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/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- 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/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -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/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/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- 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 +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- 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 +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- 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/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 +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -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/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- 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/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
|
@@ -76,8 +76,10 @@ describe('xiaohongshu publish', () => {
|
|
|
76
76
|
? { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '标题走原生输入' }
|
|
77
77
|
: { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable', actual: '正文也走原生输入' };
|
|
78
78
|
}
|
|
79
|
+
if (code.includes('xhs-publish-btn'))
|
|
80
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
79
81
|
if (code.includes('labels.some'))
|
|
80
|
-
return
|
|
82
|
+
return false;
|
|
81
83
|
if (code.includes('for (const el of document.querySelectorAll'))
|
|
82
84
|
return '发布成功';
|
|
83
85
|
throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
|
|
@@ -128,8 +130,10 @@ describe('xiaohongshu publish', () => {
|
|
|
128
130
|
return { ok: false, actual: '' };
|
|
129
131
|
if (code.includes('(function(selectors, text)'))
|
|
130
132
|
return { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '' };
|
|
133
|
+
if (code.includes('xhs-publish-btn'))
|
|
134
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
131
135
|
if (code.includes('labels.some'))
|
|
132
|
-
return
|
|
136
|
+
return false;
|
|
133
137
|
if (code.includes('for (const el of document.querySelectorAll'))
|
|
134
138
|
return '发布成功';
|
|
135
139
|
throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
|
|
@@ -177,8 +181,10 @@ describe('xiaohongshu publish', () => {
|
|
|
177
181
|
? { ok: true, actual: '原生失败后回退' }
|
|
178
182
|
: { ok: true, actual: '正文也回退' };
|
|
179
183
|
}
|
|
184
|
+
if (code.includes('xhs-publish-btn'))
|
|
185
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
180
186
|
if (code.includes('labels.some'))
|
|
181
|
-
return
|
|
187
|
+
return false;
|
|
182
188
|
if (code.includes('for (const el of document.querySelectorAll'))
|
|
183
189
|
return '发布成功';
|
|
184
190
|
throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
|
|
@@ -227,8 +233,10 @@ describe('xiaohongshu publish', () => {
|
|
|
227
233
|
return code.includes('input[maxlength')
|
|
228
234
|
? { ok: false, actual: '' }
|
|
229
235
|
: { ok: true, actual: '正文' };
|
|
236
|
+
if (code.includes('xhs-publish-btn'))
|
|
237
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
230
238
|
if (code.includes('labels.some'))
|
|
231
|
-
return
|
|
239
|
+
return false;
|
|
232
240
|
if (code.includes('for (const el of document.querySelectorAll'))
|
|
233
241
|
return '发布成功';
|
|
234
242
|
throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
|
|
@@ -259,7 +267,7 @@ describe('xiaohongshu publish', () => {
|
|
|
259
267
|
{ ok: true, actual: 'CDP上传优先' },
|
|
260
268
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
|
|
261
269
|
{ ok: true, actual: '优先走 setFileInput 主路径' },
|
|
262
|
-
true,
|
|
270
|
+
{ ok: true, via: 'click', text: '发布' },
|
|
263
271
|
'https://creator.xiaohongshu.com/publish/success',
|
|
264
272
|
'发布成功',
|
|
265
273
|
], {
|
|
@@ -301,7 +309,7 @@ describe('xiaohongshu publish', () => {
|
|
|
301
309
|
{ ok: true, actual: 'CDP被拒后回退' },
|
|
302
310
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
|
|
303
311
|
{ ok: true, actual: 'DataTransfer fallback path' },
|
|
304
|
-
true,
|
|
312
|
+
{ ok: true, via: 'click', text: '发布' },
|
|
305
313
|
'https://creator.xiaohongshu.com/publish/success',
|
|
306
314
|
'发布成功',
|
|
307
315
|
], {
|
|
@@ -366,7 +374,7 @@ describe('xiaohongshu publish', () => {
|
|
|
366
374
|
{ ok: true, actual: 'DeepSeek别乱问' },
|
|
367
375
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
|
|
368
376
|
{ ok: true, actual: '一篇真实一点的小红书正文' },
|
|
369
|
-
true,
|
|
377
|
+
{ ok: true, via: 'click', text: '发布' },
|
|
370
378
|
'https://creator.xiaohongshu.com/publish/success',
|
|
371
379
|
'发布成功',
|
|
372
380
|
]);
|
|
@@ -390,6 +398,49 @@ describe('xiaohongshu publish', () => {
|
|
|
390
398
|
},
|
|
391
399
|
]);
|
|
392
400
|
});
|
|
401
|
+
it('uses the shadow-DOM method-invoke path when xhs-publish-btn handler succeeds', async () => {
|
|
402
|
+
// Mirrors the previous "selects the image-text tab and publishes successfully"
|
|
403
|
+
// mock sequence but returns `via: 'method', name: '_onPublish'` for the publish
|
|
404
|
+
// trigger evaluate, exercising the shadow-DOM web-component handler path
|
|
405
|
+
// (the primary #1606 fix). Without this case the fix's main path is uncovered.
|
|
406
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
407
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
408
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
409
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
410
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
411
|
+
const page = createPageMock([
|
|
412
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
413
|
+
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
414
|
+
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
415
|
+
{ ok: true, count: 1 },
|
|
416
|
+
false,
|
|
417
|
+
true, // waitForEditForm: editor appeared
|
|
418
|
+
{ ok: true, sel: 'input[maxlength="20"]', kind: 'input' },
|
|
419
|
+
{ ok: true, actual: 'shadow-dom-test' },
|
|
420
|
+
{ ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
|
|
421
|
+
{ ok: true, actual: '走 method-invoke 路径' },
|
|
422
|
+
{ ok: true, via: 'method', name: '_onPublish' }, // shadow-DOM handler success
|
|
423
|
+
'https://creator.xiaohongshu.com/publish/success',
|
|
424
|
+
'发布成功',
|
|
425
|
+
]);
|
|
426
|
+
const result = await cmd.func(page, {
|
|
427
|
+
title: 'shadow-dom-test',
|
|
428
|
+
content: '走 method-invoke 路径',
|
|
429
|
+
images: imagePath,
|
|
430
|
+
topics: '',
|
|
431
|
+
draft: false,
|
|
432
|
+
});
|
|
433
|
+
expect(result).toEqual([
|
|
434
|
+
{
|
|
435
|
+
status: '✅ 发布成功',
|
|
436
|
+
detail: '"shadow-dom-test" · 1张图片 · 发布成功',
|
|
437
|
+
},
|
|
438
|
+
]);
|
|
439
|
+
// The publish-trigger evaluate must have been the shadow-DOM probe (contains
|
|
440
|
+
// 'xhs-publish-btn'), not the legacy `button.click()` fallback alone.
|
|
441
|
+
const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0]));
|
|
442
|
+
expect(evaluateCalls.some((code) => code.includes('xhs-publish-btn'))).toBe(true);
|
|
443
|
+
});
|
|
393
444
|
it('fails early with a clear error when still on the video page', async () => {
|
|
394
445
|
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
395
446
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -431,7 +482,7 @@ describe('xiaohongshu publish', () => {
|
|
|
431
482
|
{ ok: true, actual: '延迟切换也能过' },
|
|
432
483
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
|
|
433
484
|
{ ok: true, actual: '图文页切换慢一点也继续等' },
|
|
434
|
-
true,
|
|
485
|
+
{ ok: true, via: 'click', text: '发布' },
|
|
435
486
|
'https://creator.xiaohongshu.com/publish/success',
|
|
436
487
|
'发布成功',
|
|
437
488
|
]);
|
|
@@ -479,8 +530,10 @@ describe('xiaohongshu publish', () => {
|
|
|
479
530
|
? { ok: true, actual: '停留在发布页也算成功' }
|
|
480
531
|
: { ok: true, actual: '草稿成功提示' };
|
|
481
532
|
}
|
|
533
|
+
if (code.includes('xhs-publish-btn'))
|
|
534
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
482
535
|
if (code.includes('labels.some'))
|
|
483
|
-
return
|
|
536
|
+
return false;
|
|
484
537
|
if (code.includes('for (const el of document.querySelectorAll')) {
|
|
485
538
|
return code.includes('保存成功') ? '保存成功' : '';
|
|
486
539
|
}
|
|
@@ -529,8 +582,10 @@ describe('xiaohongshu publish', () => {
|
|
|
529
582
|
? { ok: true, actual: '发布提示不该复用草稿成功' }
|
|
530
583
|
: { ok: true, actual: '发布成功提示' };
|
|
531
584
|
}
|
|
585
|
+
if (code.includes('xhs-publish-btn'))
|
|
586
|
+
return { ok: true, via: 'click', text: '发布' };
|
|
532
587
|
if (code.includes('labels.some'))
|
|
533
|
-
return
|
|
588
|
+
return false;
|
|
534
589
|
if (code.includes('for (const el of document.querySelectorAll')) {
|
|
535
590
|
return code.includes('保存成功') ? '保存成功' : '';
|
|
536
591
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { assertReadableUserSnapshot } from './user.js';
|
|
2
4
|
import { buildXhsNoteUrl, extractXhsUserNotes, flattenXhsNoteGroups, normalizeXhsUserId, } from './user-helpers.js';
|
|
3
5
|
describe('normalizeXhsUserId', () => {
|
|
4
6
|
it('extracts the profile id from a full Xiaohongshu URL', () => {
|
|
@@ -117,3 +119,42 @@ describe('extractXhsUserNotes', () => {
|
|
|
117
119
|
expect(rows[0]?.url).toBe('https://www.rednote.com/user/profile/user-red/note-red?xsec_token=tok&xsec_source=pc_user');
|
|
118
120
|
});
|
|
119
121
|
});
|
|
122
|
+
|
|
123
|
+
describe('assertReadableUserSnapshot', () => {
|
|
124
|
+
it('accepts an explicit empty notes array from a readable user store', () => {
|
|
125
|
+
expect(() => assertReadableUserSnapshot({
|
|
126
|
+
storePresent: true,
|
|
127
|
+
notesPresent: true,
|
|
128
|
+
pageDataPresent: false,
|
|
129
|
+
noteGroups: [],
|
|
130
|
+
pageData: {},
|
|
131
|
+
})).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
it('fails typed when the user store is missing instead of treating parser drift as empty', () => {
|
|
134
|
+
expect(() => assertReadableUserSnapshot({
|
|
135
|
+
storePresent: false,
|
|
136
|
+
notesPresent: false,
|
|
137
|
+
pageDataPresent: false,
|
|
138
|
+
noteGroups: [],
|
|
139
|
+
pageData: {},
|
|
140
|
+
})).toThrow(CommandExecutionError);
|
|
141
|
+
});
|
|
142
|
+
it('fails typed when profile metadata exists but the notes array is missing', () => {
|
|
143
|
+
expect(() => assertReadableUserSnapshot({
|
|
144
|
+
storePresent: true,
|
|
145
|
+
notesPresent: false,
|
|
146
|
+
pageDataPresent: true,
|
|
147
|
+
noteGroups: [],
|
|
148
|
+
pageData: { user: { nickname: 'Alice' } },
|
|
149
|
+
})).toThrow(CommandExecutionError);
|
|
150
|
+
});
|
|
151
|
+
it('fails typed when notesPresent metadata and cloned noteGroups disagree', () => {
|
|
152
|
+
expect(() => assertReadableUserSnapshot({
|
|
153
|
+
storePresent: true,
|
|
154
|
+
notesPresent: true,
|
|
155
|
+
pageDataPresent: false,
|
|
156
|
+
noteGroups: null,
|
|
157
|
+
pageData: {},
|
|
158
|
+
})).toThrow(CommandExecutionError);
|
|
159
|
+
});
|
|
160
|
+
});
|
package/clis/xiaohongshu/user.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js';
|
|
3
4
|
/**
|
|
4
5
|
* Host-agnostic IIFE that snapshots the user profile's Pinia store. Exported
|
|
@@ -14,16 +15,33 @@ export const USER_SNAPSHOT_JS = `
|
|
|
14
15
|
}
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
const userStore = window.__INITIAL_STATE__?.user
|
|
18
|
+
const userStore = window.__INITIAL_STATE__?.user;
|
|
19
|
+
const hasUserStore = Boolean(userStore && typeof userStore === 'object');
|
|
20
|
+
const rawNotes = hasUserStore ? (userStore.notes?._value || userStore.notes) : undefined;
|
|
21
|
+
const rawPageData = hasUserStore ? (userStore.userPageData?._value || userStore.userPageData) : undefined;
|
|
18
22
|
return {
|
|
19
|
-
noteGroups: safeClone(
|
|
20
|
-
pageData: safeClone(
|
|
23
|
+
noteGroups: safeClone(rawNotes || []),
|
|
24
|
+
pageData: safeClone(rawPageData || {}),
|
|
25
|
+
storePresent: hasUserStore,
|
|
26
|
+
notesPresent: Array.isArray(rawNotes),
|
|
27
|
+
pageDataPresent: Boolean(rawPageData && typeof rawPageData === 'object' && Object.keys(rawPageData).length > 0),
|
|
21
28
|
};
|
|
22
29
|
})()
|
|
23
30
|
`;
|
|
24
31
|
async function readUserSnapshot(page) {
|
|
25
32
|
return await page.evaluate(USER_SNAPSHOT_JS);
|
|
26
33
|
}
|
|
34
|
+
export function assertReadableUserSnapshot(snapshot) {
|
|
35
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
36
|
+
throw new CommandExecutionError('Malformed Xiaohongshu user snapshot');
|
|
37
|
+
}
|
|
38
|
+
if (snapshot.storePresent !== true) {
|
|
39
|
+
throw new CommandExecutionError('Malformed Xiaohongshu user snapshot: user store was not found');
|
|
40
|
+
}
|
|
41
|
+
if (snapshot.notesPresent !== true || !Array.isArray(snapshot.noteGroups)) {
|
|
42
|
+
throw new CommandExecutionError('Malformed Xiaohongshu user snapshot: notes array was not found');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
27
45
|
export const command = cli({
|
|
28
46
|
site: 'xiaohongshu',
|
|
29
47
|
name: 'user',
|
|
@@ -43,12 +61,14 @@ export const command = cli({
|
|
|
43
61
|
const limit = Math.max(1, Number(kwargs.limit ?? 15));
|
|
44
62
|
await page.goto(`https://www.xiaohongshu.com/user/profile/${userId}`);
|
|
45
63
|
let snapshot = await readUserSnapshot(page);
|
|
64
|
+
assertReadableUserSnapshot(snapshot);
|
|
46
65
|
let results = extractXhsUserNotes(snapshot ?? {}, userId);
|
|
47
66
|
let previousCount = results.length;
|
|
48
67
|
for (let i = 0; results.length < limit && i < 4; i += 1) {
|
|
49
68
|
await page.autoScroll({ times: 1, delayMs: 1500 });
|
|
50
69
|
await page.wait(1);
|
|
51
70
|
snapshot = await readUserSnapshot(page);
|
|
71
|
+
assertReadableUserSnapshot(snapshot);
|
|
52
72
|
const nextResults = extractXhsUserNotes(snapshot ?? {}, userId);
|
|
53
73
|
if (nextResults.length <= previousCount)
|
|
54
74
|
break;
|
|
@@ -56,7 +76,10 @@ export const command = cli({
|
|
|
56
76
|
previousCount = nextResults.length;
|
|
57
77
|
}
|
|
58
78
|
if (results.length === 0) {
|
|
59
|
-
|
|
79
|
+
// 与 bilibili subtitle 同模式:作者无公开内容是合法 empty 数据条件
|
|
80
|
+
// (销号 / 私密号 / 全删笔记),不是 fetch 失败。下游应识别 code
|
|
81
|
+
// EMPTY_RESULT 跳过 rate-limit 启发式、不计入 softFail 阈值。
|
|
82
|
+
throw new EmptyResultError('xiaohongshu user', '该用户没有公开笔记(可能销号 / 私密 / 全部删除)。');
|
|
60
83
|
}
|
|
61
84
|
return results.slice(0, limit);
|
|
62
85
|
},
|
|
@@ -45,7 +45,7 @@ cli({
|
|
|
45
45
|
});
|
|
46
46
|
return [{
|
|
47
47
|
title,
|
|
48
|
-
podcast: ep.podcast?.title || '
|
|
48
|
+
podcast: ep.podcast?.title || '',
|
|
49
49
|
status: result.success ? 'success' : 'failed',
|
|
50
50
|
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
|
51
51
|
file: result.success ? destPath : '-',
|
|
@@ -67,7 +67,7 @@ cli({
|
|
|
67
67
|
}
|
|
68
68
|
return [{
|
|
69
69
|
title: episode.title || 'episode',
|
|
70
|
-
podcast: episode.podcast?.title || '
|
|
70
|
+
podcast: episode.podcast?.title || '',
|
|
71
71
|
status: 'success',
|
|
72
72
|
segments: kwargs.text === false ? '-' : String(segmentCount),
|
|
73
73
|
json_file: kwargs.json === false ? '-' : jsonPath,
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const ALLOWED_HOSTS = new Set([
|
|
5
|
+
'share.note.youdao.com',
|
|
6
|
+
'note.youdao.com',
|
|
7
|
+
'share.note.youdao.cn',
|
|
8
|
+
'note.youdao.cn',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function unwrapEvaluateResult(payload) {
|
|
12
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
13
|
+
return payload.data;
|
|
14
|
+
}
|
|
15
|
+
return payload;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeShareUrl(raw) {
|
|
19
|
+
const value = String(raw ?? '').trim();
|
|
20
|
+
if (!value) {
|
|
21
|
+
throw new ArgumentError('youdao note url cannot be empty', 'Pass a full public share URL from Youdao Notes.');
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = new URL(value);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new ArgumentError('Invalid Youdao Note URL', 'Example: https://share.note.youdao.com/ynoteshare/index.html?id=...&type=note');
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
30
|
+
throw new ArgumentError('Youdao Note URL must use http or https');
|
|
31
|
+
}
|
|
32
|
+
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
|
|
33
|
+
throw new ArgumentError('Youdao Note URL must be under note.youdao.com or note.youdao.cn');
|
|
34
|
+
}
|
|
35
|
+
if (!parsed.searchParams.get('id')) {
|
|
36
|
+
throw new ArgumentError('Youdao Note URL must include an id query parameter');
|
|
37
|
+
}
|
|
38
|
+
const type = parsed.searchParams.get('type');
|
|
39
|
+
if (type && type !== 'note') {
|
|
40
|
+
throw new ArgumentError('youdao note only accepts shared note URLs', 'Shared notebooks are not implemented yet.');
|
|
41
|
+
}
|
|
42
|
+
return parsed.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatYoudaoTimestamp(value) {
|
|
46
|
+
if (value == null || value === '') return '';
|
|
47
|
+
const numeric = Number(value);
|
|
48
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return String(value);
|
|
49
|
+
const millis = numeric < 10_000_000_000 ? numeric * 1000 : numeric;
|
|
50
|
+
const date = new Date(millis);
|
|
51
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
52
|
+
return date.toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildExtractorJs() {
|
|
56
|
+
const walkTextFn = `
|
|
57
|
+
function walkText(node, out) {
|
|
58
|
+
if (!node || typeof node !== 'object') return;
|
|
59
|
+
if (Array.isArray(node)) {
|
|
60
|
+
for (var i = 0; i < node.length; i += 1) walkText(node[i], out);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (typeof node[8] === 'string') {
|
|
64
|
+
var text = node[8].replace(/\\s+/g, ' ').trim();
|
|
65
|
+
if (text) out.push(text);
|
|
66
|
+
}
|
|
67
|
+
var keys = Object.keys(node);
|
|
68
|
+
for (var j = 0; j < keys.length; j += 1) {
|
|
69
|
+
var value = node[keys[j]];
|
|
70
|
+
if (value && typeof value === 'object') walkText(value, out);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
return `
|
|
75
|
+
(function() {
|
|
76
|
+
${walkTextFn}
|
|
77
|
+
function cleanText(value) {
|
|
78
|
+
return String(value || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
|
|
79
|
+
}
|
|
80
|
+
function pageText() {
|
|
81
|
+
return cleanText((document.body && (document.body.innerText || document.body.textContent)) || '').slice(0, 1000);
|
|
82
|
+
}
|
|
83
|
+
function classifyBodyText(text) {
|
|
84
|
+
if (/登录|登陆|请先登录|无权|权限|访问受限|验证码|安全验证|login|forbidden|permission/i.test(text)) return 'auth';
|
|
85
|
+
if (/分享已取消|分享不存在|文件不存在|笔记不存在|页面不存在|已过期|不存在|not found|404/i.test(text)) return 'not_found';
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
function findStoreState(value, depth, seen) {
|
|
89
|
+
if (!value || typeof value !== 'object' || depth > 10) return null;
|
|
90
|
+
if (seen.indexOf(value) !== -1) return null;
|
|
91
|
+
seen.push(value);
|
|
92
|
+
if (value.storeState && typeof value.storeState === 'object') return value.storeState;
|
|
93
|
+
if (value.content && value.content.data && typeof value.content.data === 'object') return value;
|
|
94
|
+
var keys = Object.keys(value);
|
|
95
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
96
|
+
var found = findStoreState(value[keys[i]], depth + 1, seen);
|
|
97
|
+
if (found) return found;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function findStoreFromFiber(fiber) {
|
|
102
|
+
var cursor = fiber;
|
|
103
|
+
var stack = [];
|
|
104
|
+
while (cursor || stack.length) {
|
|
105
|
+
if (!cursor) {
|
|
106
|
+
cursor = stack.pop();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
var fromState = findStoreState(cursor.memoizedState, 0, []);
|
|
110
|
+
if (fromState) return fromState;
|
|
111
|
+
var fromProps = findStoreState(cursor.memoizedProps, 0, []);
|
|
112
|
+
if (fromProps) return fromProps;
|
|
113
|
+
if (cursor.sibling) stack.push(cursor.sibling);
|
|
114
|
+
cursor = cursor.child;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
var root = document.querySelector('#root');
|
|
119
|
+
var body = pageText();
|
|
120
|
+
var bodyKind = classifyBodyText(body);
|
|
121
|
+
if (!root) {
|
|
122
|
+
return [false, bodyKind || 'root_missing', body];
|
|
123
|
+
}
|
|
124
|
+
var reactKey = Object.keys(root).find(function(key) { return key.indexOf('__reactContainer$') === 0; });
|
|
125
|
+
var fiber = (root._reactRootContainer && root._reactRootContainer._internalRoot && root._reactRootContainer._internalRoot.current)
|
|
126
|
+
|| (reactKey ? root[reactKey] : null);
|
|
127
|
+
if (!fiber) {
|
|
128
|
+
return [false, bodyKind || 'react_root_missing', body];
|
|
129
|
+
}
|
|
130
|
+
var store = findStoreFromFiber(fiber);
|
|
131
|
+
if (!store) {
|
|
132
|
+
return [false, bodyKind || 'store_missing', body];
|
|
133
|
+
}
|
|
134
|
+
var contentData = store.content && store.content.data;
|
|
135
|
+
if (!contentData || typeof contentData !== 'object') {
|
|
136
|
+
return [false, bodyKind || 'content_data_missing', body];
|
|
137
|
+
}
|
|
138
|
+
var title = cleanText(contentData.tl || document.querySelector('.file-name')?.textContent || document.title || '');
|
|
139
|
+
var hasContentField = Object.prototype.hasOwnProperty.call(contentData, 'content');
|
|
140
|
+
var rawContent = hasContentField ? String(contentData.content || '') : '';
|
|
141
|
+
var content = '';
|
|
142
|
+
if (rawContent) {
|
|
143
|
+
try {
|
|
144
|
+
var parsed = JSON.parse(rawContent);
|
|
145
|
+
var parts = [];
|
|
146
|
+
walkText(parsed, parts);
|
|
147
|
+
content = cleanText(parts.join('\\n'));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
content = cleanText(rawContent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
var summary = '';
|
|
153
|
+
var keywords = [];
|
|
154
|
+
var ai = store.aiSummary;
|
|
155
|
+
if (ai && ai.aiSummary) {
|
|
156
|
+
try {
|
|
157
|
+
var aiPayload = JSON.parse(ai.aiSummary);
|
|
158
|
+
summary = cleanText(aiPayload.description || '');
|
|
159
|
+
if (Array.isArray(aiPayload.keywords)) {
|
|
160
|
+
for (var i = 0; i < aiPayload.keywords.length; i += 1) {
|
|
161
|
+
var keyword = aiPayload.keywords[i];
|
|
162
|
+
if (keyword && keyword.title) keywords.push(cleanText(((keyword.emoji || '') + ' ' + keyword.title).trim()));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
return [true, title, content, summary, keywords.join(' | '), contentData.ct || null, contentData.sz || null, hasContentField, rawContent.length, window.location.href];
|
|
168
|
+
})()
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeExtractionResult(payload, sourceUrl) {
|
|
173
|
+
const data = unwrapEvaluateResult(payload);
|
|
174
|
+
if (!Array.isArray(data)) {
|
|
175
|
+
throw new CommandExecutionError('Youdao note extractor returned a malformed payload');
|
|
176
|
+
}
|
|
177
|
+
const ok = data[0] === true;
|
|
178
|
+
if (!ok) {
|
|
179
|
+
const reason = typeof data[1] === 'string' && data[1].trim() ? data[1].trim() : 'unknown_parser_failure';
|
|
180
|
+
if (reason === 'auth') {
|
|
181
|
+
throw new AuthRequiredError('note.youdao.com', 'Youdao shared note requires login or additional permission');
|
|
182
|
+
}
|
|
183
|
+
if (reason === 'not_found') {
|
|
184
|
+
throw new EmptyResultError('youdao note', 'The shared note is missing, expired, cancelled, or inaccessible.');
|
|
185
|
+
}
|
|
186
|
+
throw new CommandExecutionError(`Youdao note parser failed: ${reason}`);
|
|
187
|
+
}
|
|
188
|
+
const title = String(data[1] ?? '');
|
|
189
|
+
const content = String(data[2] ?? '');
|
|
190
|
+
const summary = String(data[3] ?? '');
|
|
191
|
+
const keywords = String(data[4] ?? '');
|
|
192
|
+
const createTime = data[5];
|
|
193
|
+
const fileSize = data[6];
|
|
194
|
+
const hasContentField = data[7] === true;
|
|
195
|
+
const rawContentLength = Number(data[8] ?? 0);
|
|
196
|
+
const finalUrl = String(data[9] || sourceUrl);
|
|
197
|
+
if (!title) {
|
|
198
|
+
throw new CommandExecutionError('Youdao note parser did not extract a title');
|
|
199
|
+
}
|
|
200
|
+
if (!hasContentField) {
|
|
201
|
+
throw new CommandExecutionError('Youdao note parser did not find full note content in the page store');
|
|
202
|
+
}
|
|
203
|
+
if (rawContentLength > 0 && !content) {
|
|
204
|
+
throw new CommandExecutionError('Youdao note parser found note content but extracted no readable text');
|
|
205
|
+
}
|
|
206
|
+
const row = {};
|
|
207
|
+
row.title = title;
|
|
208
|
+
row.content = content;
|
|
209
|
+
row.summary = summary;
|
|
210
|
+
row.keywords = keywords;
|
|
211
|
+
row.created_at = formatYoudaoTimestamp(createTime);
|
|
212
|
+
row.file_size = fileSize == null ? '' : String(fileSize);
|
|
213
|
+
row.url = finalUrl;
|
|
214
|
+
return row;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var command = cli({
|
|
218
|
+
site: 'youdao',
|
|
219
|
+
name: 'note',
|
|
220
|
+
access: 'read',
|
|
221
|
+
description: 'Read a public shared Youdao Note',
|
|
222
|
+
domain: 'share.note.youdao.com',
|
|
223
|
+
strategy: Strategy.PUBLIC,
|
|
224
|
+
browser: true,
|
|
225
|
+
args: [
|
|
226
|
+
{ name: 'url', positional: true, required: true, help: 'Full share URL of the Youdao Note' },
|
|
227
|
+
],
|
|
228
|
+
columns: ['title', 'content', 'summary', 'keywords', 'created_at', 'file_size', 'url'],
|
|
229
|
+
func: async function(page, kwargs) {
|
|
230
|
+
const url = normalizeShareUrl(kwargs.url);
|
|
231
|
+
try {
|
|
232
|
+
await page.goto(url);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new CommandExecutionError(`Failed to open Youdao Note URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await page.wait({ selector: '#root, .file-name, body', timeout: 10 });
|
|
238
|
+
} catch {
|
|
239
|
+
await page.wait(3).catch(function() {});
|
|
240
|
+
}
|
|
241
|
+
await page.wait(2).catch(function() {});
|
|
242
|
+
let payload;
|
|
243
|
+
try {
|
|
244
|
+
payload = await page.evaluate(buildExtractorJs());
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new CommandExecutionError(`Youdao note extractor failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
247
|
+
}
|
|
248
|
+
return [normalizeExtractionResult(payload, url)];
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
export var __test__ = {
|
|
253
|
+
buildExtractorJs: buildExtractorJs,
|
|
254
|
+
command: command,
|
|
255
|
+
formatYoudaoTimestamp: formatYoudaoTimestamp,
|
|
256
|
+
normalizeExtractionResult: normalizeExtractionResult,
|
|
257
|
+
normalizeShareUrl: normalizeShareUrl,
|
|
258
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const { __test__ } = await import('./note.js');
|
|
5
|
+
const command = __test__.command;
|
|
6
|
+
|
|
7
|
+
function makePage(evaluatePayload) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: vi.fn().mockResolvedValue(evaluatePayload),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('youdao note', () => {
|
|
16
|
+
it('registers as a public browser read command', () => {
|
|
17
|
+
expect(command).toBeDefined();
|
|
18
|
+
expect(command.site).toBe('youdao');
|
|
19
|
+
expect(command.name).toBe('note');
|
|
20
|
+
expect(command.access).toBe('read');
|
|
21
|
+
expect(command.browser).toBe(true);
|
|
22
|
+
expect(command.strategy).toBe('public');
|
|
23
|
+
expect(command.domain).toBe('share.note.youdao.com');
|
|
24
|
+
expect(command.columns).toEqual(['title', 'content', 'summary', 'keywords', 'created_at', 'file_size', 'url']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('strictly validates share URL before navigation', async () => {
|
|
28
|
+
const page = makePage({});
|
|
29
|
+
await expect(command.func(page, { url: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
30
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com.evil/ynoteshare/index.html?id=1&type=note' })).rejects.toBeInstanceOf(ArgumentError);
|
|
31
|
+
await expect(command.func(page, { url: 'javascript:alert(1)' })).rejects.toBeInstanceOf(ArgumentError);
|
|
32
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com/ynoteshare/index.html?type=note' })).rejects.toBeInstanceOf(ArgumentError);
|
|
33
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=1&type=notebook' })).rejects.toBeInstanceOf(ArgumentError);
|
|
34
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('accepts canonical youdao share URLs', () => {
|
|
38
|
+
expect(__test__.normalizeShareUrl('https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note#/')).toBe('https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note#/');
|
|
39
|
+
expect(__test__.normalizeShareUrl('https://note.youdao.cn/ynoteshare/index.html?id=abc')).toBe('https://note.youdao.cn/ynoteshare/index.html?id=abc');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('unwraps Browser Bridge envelopes and returns full note rows', async () => {
|
|
43
|
+
const page = makePage({
|
|
44
|
+
session: 'site:youdao:test',
|
|
45
|
+
data: [true, 'Expert call', 'Question\nAnswer', 'Short summary', 'PCB | ABF', 1715750400000, 1234, true, 42, 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note#/'],
|
|
46
|
+
});
|
|
47
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note#/' }))
|
|
48
|
+
.resolves.toEqual([{
|
|
49
|
+
title: 'Expert call',
|
|
50
|
+
content: 'Question\nAnswer',
|
|
51
|
+
summary: 'Short summary',
|
|
52
|
+
keywords: 'PCB | ABF',
|
|
53
|
+
created_at: '2024-05-15T05:20:00.000Z',
|
|
54
|
+
file_size: '1234',
|
|
55
|
+
url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note#/',
|
|
56
|
+
}]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('maps missing or expired shares to EmptyResultError', async () => {
|
|
60
|
+
const page = makePage([false, 'not_found']);
|
|
61
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=missing&type=note' }))
|
|
62
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('maps permission/login pages to AuthRequiredError', async () => {
|
|
66
|
+
const page = makePage([false, 'auth']);
|
|
67
|
+
await expect(command.func(page, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=private&type=note' }))
|
|
68
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('treats parser drift as CommandExecutionError instead of an empty success row', async () => {
|
|
72
|
+
await expect(command.func(makePage([false, 'store_missing']), { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note' }))
|
|
73
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
74
|
+
await expect(command.func(makePage([true, 'Only title', '', '', '', null, null, false, 0, '']), { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note' }))
|
|
75
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
76
|
+
await expect(command.func(makePage([true, 'Bad body', '', '', '', null, null, true, 100, '']), { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note' }))
|
|
77
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('wraps navigation and evaluate failures as CommandExecutionError', async () => {
|
|
81
|
+
const navPage = makePage({});
|
|
82
|
+
navPage.goto.mockRejectedValueOnce(new Error('network down'));
|
|
83
|
+
await expect(command.func(navPage, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note' }))
|
|
84
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
85
|
+
|
|
86
|
+
const evalPage = makePage({});
|
|
87
|
+
evalPage.evaluate.mockRejectedValueOnce(new Error('bad script'));
|
|
88
|
+
await expect(command.func(evalPage, { url: 'https://share.note.youdao.com/ynoteshare/index.html?id=abc&type=note' }))
|
|
89
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('keeps the full-content React store extractor contract in source', () => {
|
|
93
|
+
const js = __test__.buildExtractorJs();
|
|
94
|
+
expect(js).toContain('store.content');
|
|
95
|
+
expect(js).toContain('contentData.content');
|
|
96
|
+
expect(js).toContain('__reactContainer$');
|
|
97
|
+
expect(js).toContain('walkText');
|
|
98
|
+
});
|
|
99
|
+
});
|