@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { unwrapBrowserResult } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
|
+
|
|
6
|
+
const CREATE_LIST_QUERY_ID = 'UQRa0jJ9doxGEIQRea1Y0w';
|
|
7
|
+
const NAME_MAX = 25;
|
|
8
|
+
const DESCRIPTION_MAX = 100;
|
|
9
|
+
|
|
10
|
+
// Minimal feature set as observed in the real CreateList web request payload.
|
|
11
|
+
// Twitter rejects requests with extra/unknown features (DecodeException).
|
|
12
|
+
const FEATURES = {
|
|
13
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
14
|
+
responsive_web_profile_redirect_enabled: false,
|
|
15
|
+
rweb_tipjar_consumption_enabled: false,
|
|
16
|
+
verified_phone_label_enabled: false,
|
|
17
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
18
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function parseListCreateArgs(kwargs) {
|
|
22
|
+
const name = String(kwargs.name || '').trim();
|
|
23
|
+
const description = String(kwargs.description || '').trim();
|
|
24
|
+
const modeRaw = String(kwargs.mode || 'public').trim().toLowerCase();
|
|
25
|
+
if (!name) {
|
|
26
|
+
throw new ArgumentError('List name is required', 'Example: opencli twitter list-create "My List"');
|
|
27
|
+
}
|
|
28
|
+
if (name.length > NAME_MAX) {
|
|
29
|
+
throw new ArgumentError(`List name too long: ${name.length} chars (max ${NAME_MAX})`);
|
|
30
|
+
}
|
|
31
|
+
if (description.length > DESCRIPTION_MAX) {
|
|
32
|
+
throw new ArgumentError(`Description too long: ${description.length} chars (max ${DESCRIPTION_MAX})`);
|
|
33
|
+
}
|
|
34
|
+
if (modeRaw !== 'public' && modeRaw !== 'private') {
|
|
35
|
+
throw new ArgumentError(`Invalid mode: ${JSON.stringify(kwargs.mode)}. Expected "public" or "private".`);
|
|
36
|
+
}
|
|
37
|
+
return { listName: name, listDescription: description, listMode: modeRaw, privateFlag: modeRaw === 'private' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requireCreateListResult(result, expectedName, expectedMode) {
|
|
41
|
+
if (!result || typeof result !== 'object') {
|
|
42
|
+
throw new CommandExecutionError(`Unexpected result from twitter list-create: ${JSON.stringify(result)}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.httpStatus === 401 || result.httpStatus === 403) {
|
|
45
|
+
throw new AuthRequiredError('x.com', `Twitter CreateList returned HTTP ${result.httpStatus}`);
|
|
46
|
+
}
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
const snippet = String(result.bodyText || '').slice(0, 300);
|
|
49
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from CreateList: ${snippet}`);
|
|
50
|
+
}
|
|
51
|
+
if (!result.bodyJson || typeof result.bodyJson !== 'object') {
|
|
52
|
+
throw new CommandExecutionError(`CreateList returned malformed JSON payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
|
|
53
|
+
}
|
|
54
|
+
const list = result.bodyJson?.data?.list;
|
|
55
|
+
if (!list || typeof list !== 'object') {
|
|
56
|
+
const errors = result.bodyJson?.errors;
|
|
57
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
58
|
+
throw new CommandExecutionError(`CreateList failed: ${errors[0].message || JSON.stringify(errors[0])}`);
|
|
59
|
+
}
|
|
60
|
+
throw new CommandExecutionError(`CreateList returned no list payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
|
|
61
|
+
}
|
|
62
|
+
const id = String(list.id_str || list.id || '');
|
|
63
|
+
if (!/^\d+$/.test(id)) {
|
|
64
|
+
throw new CommandExecutionError('CreateList returned a list payload without a numeric list id.');
|
|
65
|
+
}
|
|
66
|
+
if (typeof list.name !== 'string' || !list.name.trim()) {
|
|
67
|
+
throw new CommandExecutionError('CreateList returned a list payload without a list name.');
|
|
68
|
+
}
|
|
69
|
+
if (list.name.trim() !== expectedName) {
|
|
70
|
+
throw new CommandExecutionError(`CreateList returned name ${JSON.stringify(list.name)}, expected ${JSON.stringify(expectedName)}.`);
|
|
71
|
+
}
|
|
72
|
+
const modeValue = typeof list.mode === 'string' ? list.mode : '';
|
|
73
|
+
if (!modeValue) {
|
|
74
|
+
throw new CommandExecutionError('CreateList returned a list payload without list mode.');
|
|
75
|
+
}
|
|
76
|
+
const mode = /private/i.test(modeValue) ? 'private' : 'public';
|
|
77
|
+
if (mode !== expectedMode) {
|
|
78
|
+
throw new CommandExecutionError(`CreateList returned mode ${mode}, expected ${expectedMode}.`);
|
|
79
|
+
}
|
|
80
|
+
return { createdList: list, listId: id, listMode: mode };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildListCreateRow({ result, name, description, mode }) {
|
|
84
|
+
const { createdList, listId, listMode } = requireCreateListResult(result, name, mode);
|
|
85
|
+
return {
|
|
86
|
+
id: listId,
|
|
87
|
+
name: createdList.name,
|
|
88
|
+
description: typeof createdList.description === 'string' ? createdList.description : description,
|
|
89
|
+
mode: listMode,
|
|
90
|
+
status: 'success',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cli({
|
|
95
|
+
site: 'twitter',
|
|
96
|
+
name: 'list-create',
|
|
97
|
+
description: 'Create a new Twitter/X list (returns the new list id)',
|
|
98
|
+
access: 'write',
|
|
99
|
+
domain: 'x.com',
|
|
100
|
+
strategy: Strategy.COOKIE,
|
|
101
|
+
browser: true,
|
|
102
|
+
args: [
|
|
103
|
+
{ name: 'name', positional: true, type: 'string', required: true, help: `List name (max ${NAME_MAX} chars)` },
|
|
104
|
+
{ name: 'description', type: 'string', default: '', help: `Optional list description (max ${DESCRIPTION_MAX} chars)` },
|
|
105
|
+
{ name: 'mode', type: 'string', default: 'public', help: 'public | private' },
|
|
106
|
+
],
|
|
107
|
+
columns: ['id', 'name', 'description', 'mode', 'status'],
|
|
108
|
+
func: async (page, kwargs) => {
|
|
109
|
+
const { listName: name, listDescription: description, listMode: mode, privateFlag: isPrivate } = parseListCreateArgs(kwargs);
|
|
110
|
+
|
|
111
|
+
await page.goto('https://x.com');
|
|
112
|
+
await page.wait(3);
|
|
113
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
114
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
115
|
+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
116
|
+
|
|
117
|
+
// Hardcode queryId: it must match the FEATURES schema below.
|
|
118
|
+
// Letting resolveTwitterQueryId() drift would pull a newer queryId
|
|
119
|
+
// whose schema would reject our simplified features payload.
|
|
120
|
+
const queryId = CREATE_LIST_QUERY_ID;
|
|
121
|
+
|
|
122
|
+
const headers = JSON.stringify({
|
|
123
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
124
|
+
'X-Csrf-Token': ct0,
|
|
125
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
126
|
+
'X-Twitter-Active-User': 'yes',
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
});
|
|
129
|
+
const body = JSON.stringify({
|
|
130
|
+
variables: { isPrivate, name, description },
|
|
131
|
+
features: FEATURES,
|
|
132
|
+
queryId,
|
|
133
|
+
});
|
|
134
|
+
const apiUrl = `/i/api/graphql/${queryId}/CreateList`;
|
|
135
|
+
|
|
136
|
+
const result = unwrapBrowserResult(await page.evaluate(`async () => {
|
|
137
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: ${headers},
|
|
140
|
+
credentials: 'include',
|
|
141
|
+
body: ${JSON.stringify(body)},
|
|
142
|
+
});
|
|
143
|
+
const bodyText = await r.text();
|
|
144
|
+
let bodyJson = null;
|
|
145
|
+
try { bodyJson = JSON.parse(bodyText); } catch {}
|
|
146
|
+
return { ok: r.ok, httpStatus: r.status, bodyJson, bodyText };
|
|
147
|
+
}`));
|
|
148
|
+
|
|
149
|
+
// Note: Twitter sometimes returns a non-fatal `errors` array (e.g. a
|
|
150
|
+
// strato DecodeException from a side-effect serializer) WHILE STILL
|
|
151
|
+
// creating the list. So check for a valid list payload FIRST and
|
|
152
|
+
// only treat errors as fatal if no list came back.
|
|
153
|
+
return [buildListCreateRow({ result, name, description, mode })];
|
|
154
|
+
},
|
|
155
|
+
});
|