@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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const { mockDownloadMedia, mockFormatCookieHeader } = vi.hoisted(() => ({
|
|
3
|
+
mockDownloadMedia: vi.fn(),
|
|
4
|
+
mockFormatCookieHeader: vi.fn(() => 'ct0=token'),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('@jackwener/opencli/download/media-download', () => ({
|
|
7
|
+
downloadMedia: mockDownloadMedia,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@jackwener/opencli/download', () => ({
|
|
10
|
+
formatCookieHeader: mockFormatCookieHeader,
|
|
11
|
+
}));
|
|
12
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
13
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
14
|
+
import { __test__ } from './download.js';
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
buildUserMediaUrl,
|
|
18
|
+
buildUserByScreenNameUrl,
|
|
19
|
+
parseUserMedia,
|
|
20
|
+
classifyMediaUrl,
|
|
21
|
+
requireLimit,
|
|
22
|
+
nextUserMediaFetchCount,
|
|
23
|
+
} = __test__;
|
|
24
|
+
|
|
25
|
+
function createPageMock(evaluateResults = []) {
|
|
26
|
+
const evaluate = vi.fn();
|
|
27
|
+
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
|
|
28
|
+
evaluate.mockResolvedValue(undefined);
|
|
29
|
+
return {
|
|
30
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
evaluate,
|
|
33
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token', domain: '.x.com' }]),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function userLookupPayload(userId = '42') {
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
payload: {
|
|
41
|
+
data: {
|
|
42
|
+
user: {
|
|
43
|
+
result: { rest_id: userId },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function userMediaPayload(entries) {
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
payload: {
|
|
54
|
+
data: {
|
|
55
|
+
user: {
|
|
56
|
+
result: {
|
|
57
|
+
timeline_v2: {
|
|
58
|
+
timeline: {
|
|
59
|
+
instructions: [{ entries }],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tweetEntry(id, url = `https://pbs.twimg.com/media/${id}.jpg`) {
|
|
70
|
+
return {
|
|
71
|
+
content: {
|
|
72
|
+
itemContent: {
|
|
73
|
+
tweet_results: {
|
|
74
|
+
result: {
|
|
75
|
+
rest_id: id,
|
|
76
|
+
legacy: {
|
|
77
|
+
extended_entities: {
|
|
78
|
+
media: [{ type: 'photo', media_url_https: url }],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('twitter download helpers', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mockDownloadMedia.mockReset();
|
|
91
|
+
mockDownloadMedia.mockResolvedValue([{ index: 1, type: 'image', status: 'success', size: '1 KB' }]);
|
|
92
|
+
mockFormatCookieHeader.mockClear();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('registers the canonical download columns', () => {
|
|
96
|
+
const cmd = getRegistry().get('twitter/download');
|
|
97
|
+
expect(cmd?.columns).toEqual(['index', 'tweet_id', 'url', 'type', 'status', 'size']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('makes username positional and tweet-url a flag', () => {
|
|
101
|
+
const cmd = getRegistry().get('twitter/download');
|
|
102
|
+
const usernameArg = cmd?.args?.find((a) => a.name === 'username');
|
|
103
|
+
const tweetUrlArg = cmd?.args?.find((a) => a.name === 'tweet-url');
|
|
104
|
+
expect(usernameArg?.positional).toBe(true);
|
|
105
|
+
expect(tweetUrlArg?.positional).not.toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('builds a UserMedia URL with userId, count and cursor', () => {
|
|
109
|
+
const url = buildUserMediaUrl(
|
|
110
|
+
{ queryId: 'QID', features: { fa: true }, fieldToggles: { fb: true } },
|
|
111
|
+
'42',
|
|
112
|
+
50,
|
|
113
|
+
'cursor-xyz',
|
|
114
|
+
);
|
|
115
|
+
expect(url.startsWith('/i/api/graphql/QID/UserMedia?')).toBe(true);
|
|
116
|
+
const vars = JSON.parse(decodeURIComponent(url.match(/variables=([^&]+)/)[1]));
|
|
117
|
+
expect(vars.userId).toBe('42');
|
|
118
|
+
expect(vars.count).toBe(50);
|
|
119
|
+
expect(vars.cursor).toBe('cursor-xyz');
|
|
120
|
+
expect(vars.includePromotedContent).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('omits cursor variable when not paging', () => {
|
|
124
|
+
const url = buildUserMediaUrl({ queryId: 'QID', features: {}, fieldToggles: {} }, '42', 10, null);
|
|
125
|
+
const vars = JSON.parse(decodeURIComponent(url.match(/variables=([^&]+)/)[1]));
|
|
126
|
+
expect(vars.cursor).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('builds a UserByScreenName URL with the screen_name variable', () => {
|
|
130
|
+
const url = buildUserByScreenNameUrl(
|
|
131
|
+
{ queryId: 'UBSN', features: {}, fieldToggles: {} },
|
|
132
|
+
'jack',
|
|
133
|
+
);
|
|
134
|
+
expect(url.startsWith('/i/api/graphql/UBSN/UserByScreenName?')).toBe(true);
|
|
135
|
+
expect(decodeURIComponent(url)).toContain('"screen_name":"jack"');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('classifies twimg video URLs as video and pbs URLs as image', () => {
|
|
139
|
+
expect(classifyMediaUrl('https://video.twimg.com/amplify_video/123/vid/avc1/720x1280/abc.mp4?tag=27')).toBe('video');
|
|
140
|
+
expect(classifyMediaUrl('https://pbs.twimg.com/media/AbCdEf.jpg')).toBe('image');
|
|
141
|
+
expect(classifyMediaUrl('https://example.com/clip.m3u8')).toBe('video');
|
|
142
|
+
expect(classifyMediaUrl(null)).toBe('unknown');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('strictly validates profile download limit', () => {
|
|
146
|
+
expect(requireLimit(undefined)).toBe(10);
|
|
147
|
+
expect(requireLimit(1)).toBe(1);
|
|
148
|
+
for (const value of [0, -1, 1.5, 'abc', 1001]) {
|
|
149
|
+
expect(() => requireLimit(value)).toThrow(ArgumentError);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('calculates profile media page sizes without silently clamping user input', () => {
|
|
154
|
+
expect(nextUserMediaFetchCount(1, 0)).toBe(11);
|
|
155
|
+
expect(nextUserMediaFetchCount(1000, 0)).toBe(100);
|
|
156
|
+
expect(nextUserMediaFetchCount(1000, 950)).toBe(60);
|
|
157
|
+
expect(nextUserMediaFetchCount(10, 10)).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('extracts media urls and the bottom cursor from a UserMedia payload', () => {
|
|
161
|
+
const payload = {
|
|
162
|
+
data: {
|
|
163
|
+
user: {
|
|
164
|
+
result: {
|
|
165
|
+
timeline_v2: {
|
|
166
|
+
timeline: {
|
|
167
|
+
instructions: [
|
|
168
|
+
{
|
|
169
|
+
entries: [
|
|
170
|
+
{
|
|
171
|
+
content: {
|
|
172
|
+
itemContent: {
|
|
173
|
+
tweet_results: {
|
|
174
|
+
result: {
|
|
175
|
+
rest_id: 'tweet-1',
|
|
176
|
+
legacy: {
|
|
177
|
+
extended_entities: {
|
|
178
|
+
media: [
|
|
179
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/IMG1.jpg' },
|
|
180
|
+
{ type: 'video', video_info: { variants: [{ content_type: 'video/mp4', url: 'https://video.twimg.com/v/1.mp4' }] } },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
content: {
|
|
191
|
+
entryType: 'TimelineTimelineCursor',
|
|
192
|
+
cursorType: 'Bottom',
|
|
193
|
+
value: 'next-cursor-abc',
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const { items, nextCursor } = parseUserMedia(payload, seen);
|
|
207
|
+
expect(nextCursor).toBe('next-cursor-abc');
|
|
208
|
+
expect(items).toHaveLength(2);
|
|
209
|
+
expect(items[0]).toMatchObject({ tweet_id: 'tweet-1', url: 'https://pbs.twimg.com/media/IMG1.jpg', type: 'image' });
|
|
210
|
+
expect(items[1]).toMatchObject({ tweet_id: 'tweet-1', url: 'https://video.twimg.com/v/1.mp4', type: 'video' });
|
|
211
|
+
expect(seen.has('tweet-1')).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('skips already-seen tweets across pages', () => {
|
|
215
|
+
const tweetEntry = (id) => ({
|
|
216
|
+
content: {
|
|
217
|
+
itemContent: {
|
|
218
|
+
tweet_results: {
|
|
219
|
+
result: {
|
|
220
|
+
rest_id: id,
|
|
221
|
+
legacy: {
|
|
222
|
+
extended_entities: {
|
|
223
|
+
media: [{ type: 'photo', media_url_https: `https://pbs.twimg.com/media/${id}.jpg` }],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const payload = {
|
|
232
|
+
data: {
|
|
233
|
+
user: {
|
|
234
|
+
result: {
|
|
235
|
+
timeline_v2: {
|
|
236
|
+
timeline: {
|
|
237
|
+
instructions: [{ entries: [tweetEntry('A'), tweetEntry('A'), tweetEntry('B')] }],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
const { items } = parseUserMedia(payload, seen);
|
|
246
|
+
expect(items.map((item) => item.tweet_id)).toEqual(['A', 'B']);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('treats TweetWithVisibilityResults wrappers as tweets', () => {
|
|
250
|
+
const payload = {
|
|
251
|
+
data: {
|
|
252
|
+
user: {
|
|
253
|
+
result: {
|
|
254
|
+
timeline_v2: {
|
|
255
|
+
timeline: {
|
|
256
|
+
instructions: [
|
|
257
|
+
{
|
|
258
|
+
entries: [
|
|
259
|
+
{
|
|
260
|
+
content: {
|
|
261
|
+
itemContent: {
|
|
262
|
+
tweet_results: {
|
|
263
|
+
result: {
|
|
264
|
+
__typename: 'TweetWithVisibilityResults',
|
|
265
|
+
tweet: {
|
|
266
|
+
rest_id: 'wrapped-1',
|
|
267
|
+
legacy: {
|
|
268
|
+
extended_entities: {
|
|
269
|
+
media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/W.jpg' }],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
const { items } = parseUserMedia(payload, new Set());
|
|
288
|
+
expect(items).toHaveLength(1);
|
|
289
|
+
expect(items[0].tweet_id).toBe('wrapped-1');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('fails typed when UserMedia payload has no timeline instructions', () => {
|
|
293
|
+
expect(() => parseUserMedia({ data: { user: { result: {} } } }, new Set()))
|
|
294
|
+
.toThrow(CommandExecutionError);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('rejects missing, mixed, invalid username and invalid limit before navigation', async () => {
|
|
298
|
+
const cmd = getRegistry().get('twitter/download');
|
|
299
|
+
for (const args of [
|
|
300
|
+
{},
|
|
301
|
+
{ username: 'jack', 'tweet-url': 'https://x.com/jack/status/123' },
|
|
302
|
+
{ username: 'bad/name' },
|
|
303
|
+
{ username: 'jack', limit: 0 },
|
|
304
|
+
]) {
|
|
305
|
+
const page = createPageMock();
|
|
306
|
+
await expect(cmd.func(page, args)).rejects.toBeInstanceOf(ArgumentError);
|
|
307
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('downloads profile media through UserByScreenName and UserMedia GraphQL payloads', async () => {
|
|
312
|
+
const cmd = getRegistry().get('twitter/download');
|
|
313
|
+
mockDownloadMedia.mockResolvedValueOnce([
|
|
314
|
+
{ index: 1, type: 'image', status: 'success', size: '1 KB' },
|
|
315
|
+
{ index: 2, type: 'image', status: 'success', size: '2 KB' },
|
|
316
|
+
]);
|
|
317
|
+
const page = createPageMock([
|
|
318
|
+
{ queryId: 'UM', features: { a: true }, fieldToggles: {} },
|
|
319
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
320
|
+
userLookupPayload('42'),
|
|
321
|
+
userMediaPayload([
|
|
322
|
+
tweetEntry('A'),
|
|
323
|
+
{
|
|
324
|
+
content: {
|
|
325
|
+
entryType: 'TimelineTimelineCursor',
|
|
326
|
+
cursorType: 'Bottom',
|
|
327
|
+
value: 'cursor-1',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
]),
|
|
331
|
+
userMediaPayload([tweetEntry('B')]),
|
|
332
|
+
]);
|
|
333
|
+
const rows = await cmd.func(page, { username: '@jack', limit: 2, output: './out' });
|
|
334
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/jack');
|
|
335
|
+
expect(page.evaluate).toHaveBeenCalledTimes(5);
|
|
336
|
+
expect(mockDownloadMedia).toHaveBeenCalledWith([
|
|
337
|
+
{ tweet_id: 'A', url: 'https://pbs.twimg.com/media/A.jpg', type: 'image' },
|
|
338
|
+
{ tweet_id: 'B', url: 'https://pbs.twimg.com/media/B.jpg', type: 'image' },
|
|
339
|
+
], expect.objectContaining({
|
|
340
|
+
output: './out',
|
|
341
|
+
subdir: 'jack',
|
|
342
|
+
filenamePrefix: 'jack',
|
|
343
|
+
cookies: 'ct0=token',
|
|
344
|
+
}));
|
|
345
|
+
expect(rows).toEqual([
|
|
346
|
+
{
|
|
347
|
+
index: 1,
|
|
348
|
+
tweet_id: 'A',
|
|
349
|
+
url: 'https://pbs.twimg.com/media/A.jpg',
|
|
350
|
+
type: 'image',
|
|
351
|
+
status: 'success',
|
|
352
|
+
size: '1 KB',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
index: 2,
|
|
356
|
+
tweet_id: 'B',
|
|
357
|
+
url: 'https://pbs.twimg.com/media/B.jpg',
|
|
358
|
+
type: 'image',
|
|
359
|
+
status: 'success',
|
|
360
|
+
size: '2 KB',
|
|
361
|
+
},
|
|
362
|
+
]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('maps missing ct0 and GraphQL auth failures to AuthRequiredError', async () => {
|
|
366
|
+
const cmd = getRegistry().get('twitter/download');
|
|
367
|
+
const noCt0Page = createPageMock();
|
|
368
|
+
noCt0Page.getCookies.mockResolvedValueOnce([]);
|
|
369
|
+
await expect(cmd.func(noCt0Page, { username: 'jack', limit: 1 }))
|
|
370
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
371
|
+
|
|
372
|
+
const authPage = createPageMock([
|
|
373
|
+
{ queryId: 'UM', features: {}, fieldToggles: {} },
|
|
374
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
375
|
+
{ ok: false, status: 401 },
|
|
376
|
+
]);
|
|
377
|
+
await expect(cmd.func(authPage, { username: 'jack', limit: 1 }))
|
|
378
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('fails typed for malformed UserMedia and fetch failures instead of partial success', async () => {
|
|
382
|
+
const cmd = getRegistry().get('twitter/download');
|
|
383
|
+
const malformedPage = createPageMock([
|
|
384
|
+
{ queryId: 'UM', features: {}, fieldToggles: {} },
|
|
385
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
386
|
+
userLookupPayload('42'),
|
|
387
|
+
{ ok: true, payload: { data: { user: { result: {} } } } },
|
|
388
|
+
]);
|
|
389
|
+
await expect(cmd.func(malformedPage, { username: 'jack', limit: 1 }))
|
|
390
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
391
|
+
|
|
392
|
+
const partialPage = createPageMock([
|
|
393
|
+
{ queryId: 'UM', features: {}, fieldToggles: {} },
|
|
394
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
395
|
+
userLookupPayload('42'),
|
|
396
|
+
userMediaPayload([
|
|
397
|
+
tweetEntry('A'),
|
|
398
|
+
{
|
|
399
|
+
content: {
|
|
400
|
+
entryType: 'TimelineTimelineCursor',
|
|
401
|
+
cursorType: 'Bottom',
|
|
402
|
+
value: 'cursor-1',
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
]),
|
|
406
|
+
{ ok: false, status: 500 },
|
|
407
|
+
]);
|
|
408
|
+
await expect(cmd.func(partialPage, { username: 'jack', limit: 2 }))
|
|
409
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
410
|
+
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
|
411
|
+
|
|
412
|
+
const repeatedCursorPage = createPageMock([
|
|
413
|
+
{ queryId: 'UM', features: {}, fieldToggles: {} },
|
|
414
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
415
|
+
userLookupPayload('42'),
|
|
416
|
+
userMediaPayload([
|
|
417
|
+
tweetEntry('A'),
|
|
418
|
+
{
|
|
419
|
+
content: {
|
|
420
|
+
entryType: 'TimelineTimelineCursor',
|
|
421
|
+
cursorType: 'Bottom',
|
|
422
|
+
value: 'cursor-1',
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
]),
|
|
426
|
+
userMediaPayload([
|
|
427
|
+
tweetEntry('B'),
|
|
428
|
+
{
|
|
429
|
+
content: {
|
|
430
|
+
entryType: 'TimelineTimelineCursor',
|
|
431
|
+
cursorType: 'Bottom',
|
|
432
|
+
value: 'cursor-1',
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
]),
|
|
436
|
+
]);
|
|
437
|
+
await expect(cmd.func(repeatedCursorPage, { username: 'jack', limit: 3 }))
|
|
438
|
+
.rejects.toThrowError(/same cursor twice/);
|
|
439
|
+
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('uses typed empty result for profile or tweet media absence', async () => {
|
|
443
|
+
const cmd = getRegistry().get('twitter/download');
|
|
444
|
+
const profilePage = createPageMock([
|
|
445
|
+
{ queryId: 'UM', features: {}, fieldToggles: {} },
|
|
446
|
+
{ queryId: 'UB', features: {}, fieldToggles: {} },
|
|
447
|
+
userLookupPayload('42'),
|
|
448
|
+
userMediaPayload([]),
|
|
449
|
+
]);
|
|
450
|
+
await expect(cmd.func(profilePage, { username: 'jack', limit: 1 }))
|
|
451
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
452
|
+
|
|
453
|
+
const tweetPage = createPageMock([[]]);
|
|
454
|
+
await expect(cmd.func(tweetPage, { 'tweet-url': 'https://x.com/jack/status/123' }))
|
|
455
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
|
|
@@ -161,7 +161,11 @@ cli({
|
|
|
161
161
|
const seen = new Set();
|
|
162
162
|
let sameCount = 0;
|
|
163
163
|
while (allFollowers.length < limit && sameCount < 3) {
|
|
164
|
-
const
|
|
164
|
+
const rawFollowers = await extractFollowersFromDOM(page);
|
|
165
|
+
if (!Array.isArray(rawFollowers)) {
|
|
166
|
+
throw new CommandExecutionError('Twitter followers extraction returned malformed rows');
|
|
167
|
+
}
|
|
168
|
+
const followers = rawFollowers;
|
|
165
169
|
const newFollowers = followers.filter(f => !seen.has(f.screen_name));
|
|
166
170
|
for (const f of newFollowers) {
|
|
167
171
|
seen.add(f.screen_name);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
4
|
import { __test__ } from './followers.js';
|
|
5
5
|
|
|
6
6
|
describe('twitter followers command', () => {
|
|
@@ -41,4 +41,22 @@ describe('twitter followers command', () => {
|
|
|
41
41
|
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
42
42
|
expect(page.goto).not.toHaveBeenCalledWith('https://x.com/home/followers');
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it('typed-fails instead of throwing "filter is not a function" when extractFollowersFromDOM returns a non-array', async () => {
|
|
46
|
+
const command = getRegistry().get('twitter/followers');
|
|
47
|
+
const page = {
|
|
48
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
evaluate: vi.fn(async (script) => {
|
|
52
|
+
const text = String(script);
|
|
53
|
+
if (text.includes('AppTabBar_Profile_Link')) return '/viewer';
|
|
54
|
+
if (text.includes('/followers') && text.includes('click')) return true;
|
|
55
|
+
if (text.includes('UserCell')) return undefined;
|
|
56
|
+
return undefined;
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
await expect(command.func(page, { user: 'someone', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
61
|
+
});
|
|
44
62
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
|
|
3
|
+
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
5
|
|
|
6
|
-
const FOLLOWING_QUERY_ID = '
|
|
7
|
-
const USER_BY_SCREEN_NAME_QUERY_ID = '
|
|
6
|
+
const FOLLOWING_QUERY_ID = 'F42cDX8PDFxkbjjq6JrM2w';
|
|
7
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
8
8
|
const MAX_PAGINATION_PAGES = 100;
|
|
9
9
|
|
|
10
10
|
const FEATURES = {
|
|
@@ -90,9 +90,13 @@ function extractUser(result) {
|
|
|
90
90
|
return null;
|
|
91
91
|
const core = result.core || {};
|
|
92
92
|
const legacy = result.legacy || {};
|
|
93
|
+
const screenName = core.screen_name || legacy.screen_name || '';
|
|
94
|
+
if (!screenName) {
|
|
95
|
+
throw new CommandExecutionError('Malformed Twitter following user: missing screen_name');
|
|
96
|
+
}
|
|
93
97
|
return {
|
|
94
|
-
screen_name:
|
|
95
|
-
name: core.name || legacy.name || '
|
|
98
|
+
screen_name: screenName,
|
|
99
|
+
name: core.name || legacy.name || '',
|
|
96
100
|
bio: legacy.description || result.profile_bio?.description || '',
|
|
97
101
|
followers: legacy.followers_count || legacy.normal_followers_count || 0,
|
|
98
102
|
};
|
|
@@ -221,6 +225,7 @@ cli({
|
|
|
221
225
|
const allUsers = [];
|
|
222
226
|
const seen = new Set();
|
|
223
227
|
let cursor = null;
|
|
228
|
+
let lastRawResponse = null;
|
|
224
229
|
|
|
225
230
|
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
226
231
|
for (let i = 0; i < MAX_PAGINATION_PAGES && allUsers.length < limit; i++) {
|
|
@@ -235,6 +240,7 @@ cli({
|
|
|
235
240
|
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
|
|
236
241
|
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch following list. queryId may have expired.`);
|
|
237
242
|
}
|
|
243
|
+
lastRawResponse = data;
|
|
238
244
|
const { users, nextCursor } = parseFollowing(data);
|
|
239
245
|
for (const u of users) {
|
|
240
246
|
if (!seen.has(u.screen_name)) {
|
|
@@ -248,6 +254,9 @@ cli({
|
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
if (allUsers.length === 0) {
|
|
257
|
+
if (looksLikePrivateTwitterTimeline(lastRawResponse)) {
|
|
258
|
+
throw new EmptyResultError('twitter following', `No following data returned for @${targetUser} (the target account may have set their following list to private)`);
|
|
259
|
+
}
|
|
251
260
|
throw new EmptyResultError('twitter following', `No following accounts found for @${targetUser}`);
|
|
252
261
|
}
|
|
253
262
|
|
|
@@ -53,6 +53,27 @@ describe('twitter following helpers', () => {
|
|
|
53
53
|
expect(user?.screen_name).toBe('bob');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it('typed-fails when upstream omits screen_name identity', () => {
|
|
57
|
+
expect(() => __test__.extractUser({
|
|
58
|
+
__typename: 'User',
|
|
59
|
+
legacy: { description: 'no names', followers_count: 7 },
|
|
60
|
+
})).toThrow(CommandExecutionError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('surfaces empty name display when upstream omits only name', () => {
|
|
64
|
+
const user = __test__.extractUser({
|
|
65
|
+
__typename: 'User',
|
|
66
|
+
core: { screen_name: 'alice' },
|
|
67
|
+
legacy: { description: 'no display name', followers_count: 7 },
|
|
68
|
+
});
|
|
69
|
+
expect(user).toMatchObject({
|
|
70
|
+
screen_name: 'alice',
|
|
71
|
+
name: '',
|
|
72
|
+
bio: 'no display name',
|
|
73
|
+
followers: 7,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
56
77
|
it('parses following timeline with users and cursor', () => {
|
|
57
78
|
const payload = {
|
|
58
79
|
data: {
|
|
@@ -327,4 +348,12 @@ describe('twitter following command', () => {
|
|
|
327
348
|
|
|
328
349
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
329
350
|
});
|
|
351
|
+
|
|
352
|
+
it('surfaces the private-following privacy hint when result.timeline is empty', async () => {
|
|
353
|
+
const command = getRegistry().get('twitter/following');
|
|
354
|
+
const page = createFollowingPage([{ data: { user: { result: { __typename: 'User', timeline: {} } } } }]);
|
|
355
|
+
|
|
356
|
+
await expect(command.func(page, { user: 'simonw', limit: 10 }))
|
|
357
|
+
.rejects.toMatchObject({ hint: expect.stringContaining('following list to private') });
|
|
358
|
+
});
|
|
330
359
|
});
|
package/clis/twitter/likes.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
|
-
const LIKES_QUERY_ID = '
|
|
6
|
-
const USER_BY_SCREEN_NAME_QUERY_ID = '
|
|
5
|
+
const LIKES_QUERY_ID = 'CDWHmpZeSdIJ3HGeRbNm0w';
|
|
6
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
7
7
|
const MAX_PAGINATION_PAGES = 100;
|
|
8
8
|
const FEATURES = {
|
|
9
9
|
rweb_video_screen_enabled: false,
|
|
@@ -202,6 +202,7 @@ cli({
|
|
|
202
202
|
const allTweets = [];
|
|
203
203
|
const seen = new Set();
|
|
204
204
|
let cursor = null;
|
|
205
|
+
let lastRawResponse = null;
|
|
205
206
|
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
206
207
|
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
|
|
207
208
|
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
@@ -215,12 +216,19 @@ cli({
|
|
|
215
216
|
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
|
|
216
217
|
break;
|
|
217
218
|
}
|
|
219
|
+
lastRawResponse = data;
|
|
218
220
|
const { tweets, nextCursor } = parseLikes(data, seen);
|
|
219
221
|
allTweets.push(...tweets);
|
|
220
222
|
if (!nextCursor || nextCursor === cursor)
|
|
221
223
|
break;
|
|
222
224
|
cursor = nextCursor;
|
|
223
225
|
}
|
|
226
|
+
if (allTweets.length === 0) {
|
|
227
|
+
if (looksLikePrivateTwitterTimeline(lastRawResponse)) {
|
|
228
|
+
throw new EmptyResultError('twitter likes', `No likes returned for @${username} (Likes are private by default on X; only the account owner can view their own likes)`);
|
|
229
|
+
}
|
|
230
|
+
throw new EmptyResultError('twitter likes', `No likes found for @${username}`);
|
|
231
|
+
}
|
|
224
232
|
const trimmed = allTweets.slice(0, limit);
|
|
225
233
|
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
226
234
|
},
|