@jackwener/opencli 1.7.14 → 1.7.16
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 +9 -6
- package/README.zh-CN.md +9 -6
- package/cli-manifest.json +374 -74
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/chatgpt/ask.js +2 -1
- package/clis/chatgpt/detail.js +6 -1
- package/clis/chatgpt/read.js +2 -1
- package/clis/chatgpt/send.js +2 -1
- package/clis/chatgpt/utils.js +54 -12
- package/clis/chatgpt/utils.test.js +36 -1
- package/clis/claude/ask.js +22 -7
- package/clis/claude/detail.js +9 -2
- package/clis/claude/new.js +8 -2
- package/clis/claude/read.js +2 -1
- package/clis/claude/send.js +8 -3
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +21 -8
- package/clis/deepseek/detail.js +9 -1
- package/clis/deepseek/new.js +13 -2
- package/clis/deepseek/read.js +2 -1
- package/clis/deepseek/utils.js +8 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +2 -1
- package/clis/reddit/subreddit.js +2 -1
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +2 -1
- package/clis/reddit/user-posts.js +2 -1
- package/clis/reddit/user.js +2 -1
- package/clis/twitter/article.js +9 -5
- package/clis/twitter/bookmark-folder.js +187 -0
- package/clis/twitter/bookmark-folder.test.js +337 -0
- package/clis/twitter/bookmark-folders.js +115 -0
- package/clis/twitter/bookmark-folders.test.js +152 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +10 -10
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +6 -5
- package/clis/twitter/followers.js +10 -3
- package/clis/twitter/following.js +14 -11
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +11 -11
- package/clis/twitter/list-add.js +8 -7
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +8 -7
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +9 -9
- package/clis/twitter/lists.js +6 -8
- package/clis/twitter/notifications.js +3 -2
- package/clis/twitter/profile.js +11 -7
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +176 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +11 -11
- package/clis/twitter/timeline.js +13 -13
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +8 -9
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +5 -3
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +4 -2
- package/dist/src/browser/page.js +18 -1
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +630 -60
- package/dist/src/cli.test.js +731 -1
- package/dist/src/commanderAdapter.js +7 -0
- package/dist/src/doctor.js +2 -2
- package/dist/src/doctor.test.js +4 -4
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +31 -6
- package/dist/src/execution.test.js +43 -16
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +33 -0
- package/dist/src/help.js +174 -0
- package/dist/src/main.js +4 -14
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +83 -1
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { __test__ } from './bookmark-folder.js';
|
|
4
|
+
|
|
5
|
+
const { parseBookmarkFolderTimeline, buildFolderTimelineUrl, FOLDER_ID_PATTERN } = __test__;
|
|
6
|
+
|
|
7
|
+
describe('twitter bookmark-folder URL builder', () => {
|
|
8
|
+
it('embeds the folder id and count in the variables payload', () => {
|
|
9
|
+
const url = buildFolderTimelineUrl('queryX', '12345', 50, null);
|
|
10
|
+
const m = url.match(/variables=([^&]+)/);
|
|
11
|
+
const vars = JSON.parse(decodeURIComponent(m[1]));
|
|
12
|
+
expect(vars.bookmark_collection_id).toBe('12345');
|
|
13
|
+
expect(vars.count).toBe(50);
|
|
14
|
+
expect(vars.includePromotedContent).toBe(false);
|
|
15
|
+
expect(vars.cursor).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('appends the cursor when one is supplied', () => {
|
|
19
|
+
const url = buildFolderTimelineUrl('queryX', '12345', 50, 'CURSOR_VAL');
|
|
20
|
+
const m = url.match(/variables=([^&]+)/);
|
|
21
|
+
const vars = JSON.parse(decodeURIComponent(m[1]));
|
|
22
|
+
expect(vars.cursor).toBe('CURSOR_VAL');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('coerces a numeric folder id to a string', () => {
|
|
26
|
+
const url = buildFolderTimelineUrl('queryX', 555, 10);
|
|
27
|
+
const m = url.match(/variables=([^&]+)/);
|
|
28
|
+
const vars = JSON.parse(decodeURIComponent(m[1]));
|
|
29
|
+
expect(vars.bookmark_collection_id).toBe('555');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('preserves opaque folder ids without truncating them', () => {
|
|
33
|
+
const url = buildFolderTimelineUrl('queryX', 'folder_AbC-123', 10);
|
|
34
|
+
const m = url.match(/variables=([^&]+)/);
|
|
35
|
+
const vars = JSON.parse(decodeURIComponent(m[1]));
|
|
36
|
+
expect(vars.bookmark_collection_id).toBe('folder_AbC-123');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('twitter bookmark-folder timeline parser', () => {
|
|
41
|
+
it('extracts tweets from bookmark_timeline_v2 envelope', () => {
|
|
42
|
+
const data = {
|
|
43
|
+
data: {
|
|
44
|
+
bookmark_timeline_v2: {
|
|
45
|
+
timeline: {
|
|
46
|
+
instructions: [
|
|
47
|
+
{
|
|
48
|
+
type: 'TimelineAddEntries',
|
|
49
|
+
entries: [
|
|
50
|
+
{
|
|
51
|
+
entryId: 'tweet-1',
|
|
52
|
+
content: {
|
|
53
|
+
itemContent: {
|
|
54
|
+
tweet_results: {
|
|
55
|
+
result: {
|
|
56
|
+
rest_id: '1',
|
|
57
|
+
legacy: {
|
|
58
|
+
full_text: 'first folder tweet',
|
|
59
|
+
favorite_count: 9,
|
|
60
|
+
retweet_count: 2,
|
|
61
|
+
bookmark_count: 3,
|
|
62
|
+
created_at: 'Tue Mar 17 09:00:00 +0000 2026',
|
|
63
|
+
},
|
|
64
|
+
core: {
|
|
65
|
+
user_results: {
|
|
66
|
+
result: { core: { screen_name: 'alice' } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
entryId: 'cursor-bottom-X',
|
|
76
|
+
content: {
|
|
77
|
+
__typename: 'TimelineTimelineCursor',
|
|
78
|
+
cursorType: 'Bottom',
|
|
79
|
+
value: 'NEXT_CURSOR',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const { tweets, nextCursor } = parseBookmarkFolderTimeline(data, new Set());
|
|
90
|
+
expect(tweets).toEqual([
|
|
91
|
+
{
|
|
92
|
+
id: '1',
|
|
93
|
+
author: 'alice',
|
|
94
|
+
text: 'first folder tweet',
|
|
95
|
+
likes: 9,
|
|
96
|
+
retweets: 2,
|
|
97
|
+
bookmarks: 3,
|
|
98
|
+
created_at: 'Tue Mar 17 09:00:00 +0000 2026',
|
|
99
|
+
url: 'https://x.com/alice/status/1',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
expect(nextCursor).toBe('NEXT_CURSOR');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('falls back to bookmark_collection_timeline envelope', () => {
|
|
106
|
+
const data = {
|
|
107
|
+
data: {
|
|
108
|
+
bookmark_collection_timeline: {
|
|
109
|
+
timeline: {
|
|
110
|
+
instructions: [
|
|
111
|
+
{
|
|
112
|
+
entries: [
|
|
113
|
+
{
|
|
114
|
+
entryId: 'tweet-2',
|
|
115
|
+
content: {
|
|
116
|
+
itemContent: {
|
|
117
|
+
tweet_results: {
|
|
118
|
+
result: {
|
|
119
|
+
rest_id: '2',
|
|
120
|
+
legacy: { full_text: 'collection envelope', favorite_count: 1, retweet_count: 0, bookmark_count: 0 },
|
|
121
|
+
core: { user_results: { result: { legacy: { screen_name: 'bob' } } } },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const { tweets } = parseBookmarkFolderTimeline(data, new Set());
|
|
135
|
+
expect(tweets).toHaveLength(1);
|
|
136
|
+
expect(tweets[0].id).toBe('2');
|
|
137
|
+
expect(tweets[0].author).toBe('bob');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('uses note_tweet text when present (long-form tweets)', () => {
|
|
141
|
+
const data = {
|
|
142
|
+
data: {
|
|
143
|
+
bookmark_timeline_v2: {
|
|
144
|
+
timeline: {
|
|
145
|
+
instructions: [{
|
|
146
|
+
entries: [{
|
|
147
|
+
entryId: 'tweet-3',
|
|
148
|
+
content: {
|
|
149
|
+
itemContent: {
|
|
150
|
+
tweet_results: {
|
|
151
|
+
result: {
|
|
152
|
+
rest_id: '3',
|
|
153
|
+
legacy: { full_text: 'short text', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
|
|
154
|
+
note_tweet: { note_tweet_results: { result: { text: 'full long-form text' } } },
|
|
155
|
+
core: { user_results: { result: { core: { screen_name: 'carol' } } } },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}],
|
|
161
|
+
}],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const { tweets } = parseBookmarkFolderTimeline(data, new Set());
|
|
167
|
+
expect(tweets[0].text).toBe('full long-form text');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('deduplicates tweets across the seen Set', () => {
|
|
171
|
+
const data = {
|
|
172
|
+
data: {
|
|
173
|
+
bookmark_timeline_v2: {
|
|
174
|
+
timeline: {
|
|
175
|
+
instructions: [{
|
|
176
|
+
entries: [
|
|
177
|
+
{
|
|
178
|
+
entryId: 'tweet-4',
|
|
179
|
+
content: {
|
|
180
|
+
itemContent: {
|
|
181
|
+
tweet_results: {
|
|
182
|
+
result: {
|
|
183
|
+
rest_id: '4',
|
|
184
|
+
legacy: { full_text: 'first', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
|
|
185
|
+
core: { user_results: { result: { core: { screen_name: 'dan' } } } },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
entryId: 'tweet-4-dup',
|
|
193
|
+
content: {
|
|
194
|
+
itemContent: {
|
|
195
|
+
tweet_results: {
|
|
196
|
+
result: {
|
|
197
|
+
rest_id: '4',
|
|
198
|
+
legacy: { full_text: 'duplicate' },
|
|
199
|
+
core: { user_results: { result: { core: { screen_name: 'dan' } } } },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
}],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const { tweets } = parseBookmarkFolderTimeline(data, seen);
|
|
213
|
+
expect(tweets).toHaveLength(1);
|
|
214
|
+
expect(tweets[0].text).toBe('first');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('does not synthesize an unknown author sentinel when screen_name is missing', () => {
|
|
218
|
+
const data = {
|
|
219
|
+
data: {
|
|
220
|
+
bookmark_timeline_v2: {
|
|
221
|
+
timeline: {
|
|
222
|
+
instructions: [{
|
|
223
|
+
entries: [{
|
|
224
|
+
entryId: 'tweet-5',
|
|
225
|
+
content: {
|
|
226
|
+
itemContent: {
|
|
227
|
+
tweet_results: {
|
|
228
|
+
result: {
|
|
229
|
+
rest_id: '5',
|
|
230
|
+
legacy: { full_text: 'missing author', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
|
|
231
|
+
core: { user_results: { result: {} } },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}],
|
|
237
|
+
}],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const { tweets } = parseBookmarkFolderTimeline(data, new Set());
|
|
243
|
+
expect(tweets[0].author).toBe('');
|
|
244
|
+
expect(tweets[0].url).toBe('https://x.com/i/status/5');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('returns empty array + null cursor for unknown envelope', () => {
|
|
248
|
+
expect(parseBookmarkFolderTimeline({}, new Set())).toEqual({ tweets: [], nextCursor: null });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('twitter bookmark-folder id validation', () => {
|
|
253
|
+
it('accepts numeric and opaque safe ids from bookmark-folders output', () => {
|
|
254
|
+
expect(FOLDER_ID_PATTERN.test('1234567890')).toBe(true);
|
|
255
|
+
expect(FOLDER_ID_PATTERN.test('folder_AbC-123')).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('rejects ids that could pollute GraphQL variables or URL construction', () => {
|
|
259
|
+
for (const value of ['folder/123', 'folder?x=1', 'folder%2F123', 'folder.123', 'folder 123', '']) {
|
|
260
|
+
expect(FOLDER_ID_PATTERN.test(value)).toBe(false);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('twitter bookmark-folder command (registry)', () => {
|
|
266
|
+
it('throws ArgumentError on unsafe folder-id before navigation', async () => {
|
|
267
|
+
const command = getRegistry().get('twitter/bookmark-folder');
|
|
268
|
+
expect(command?.func).toBeTypeOf('function');
|
|
269
|
+
const page = {
|
|
270
|
+
goto: vi.fn(),
|
|
271
|
+
wait: vi.fn(),
|
|
272
|
+
evaluate: vi.fn(),
|
|
273
|
+
};
|
|
274
|
+
await expect(command.func(page, { 'folder-id': 'folder/123', limit: 5 }))
|
|
275
|
+
.rejects
|
|
276
|
+
.toThrow(/Invalid folder-id/);
|
|
277
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('throws ArgumentError on empty folder-id', async () => {
|
|
281
|
+
const command = getRegistry().get('twitter/bookmark-folder');
|
|
282
|
+
const page = {
|
|
283
|
+
goto: vi.fn(),
|
|
284
|
+
wait: vi.fn(),
|
|
285
|
+
evaluate: vi.fn(),
|
|
286
|
+
};
|
|
287
|
+
await expect(command.func(page, { 'folder-id': ' ', limit: 5 }))
|
|
288
|
+
.rejects
|
|
289
|
+
.toThrow(/Invalid folder-id/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('throws ArgumentError on invalid limit before navigation', async () => {
|
|
293
|
+
const command = getRegistry().get('twitter/bookmark-folder');
|
|
294
|
+
for (const limit of [0, -1, 1.5, Number.NaN]) {
|
|
295
|
+
const page = {
|
|
296
|
+
goto: vi.fn(),
|
|
297
|
+
wait: vi.fn(),
|
|
298
|
+
evaluate: vi.fn(),
|
|
299
|
+
};
|
|
300
|
+
await expect(command.func(page, { 'folder-id': '12345', limit }))
|
|
301
|
+
.rejects
|
|
302
|
+
.toThrow(/Invalid --limit/);
|
|
303
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('throws AuthRequiredError when ct0 cookie is missing', async () => {
|
|
308
|
+
const command = getRegistry().get('twitter/bookmark-folder');
|
|
309
|
+
const page = {
|
|
310
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
311
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
312
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
313
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
314
|
+
};
|
|
315
|
+
await expect(command.func(page, { 'folder-id': '12345', limit: 5 }))
|
|
316
|
+
.rejects
|
|
317
|
+
.toThrow(/Not logged into x.com/);
|
|
318
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('accepts an opaque safe folder-id and sends it in the GraphQL variables', async () => {
|
|
322
|
+
const command = getRegistry().get('twitter/bookmark-folder');
|
|
323
|
+
const page = {
|
|
324
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
325
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
326
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'ct0-token' }]),
|
|
327
|
+
evaluate: vi.fn()
|
|
328
|
+
.mockResolvedValueOnce('queryX')
|
|
329
|
+
.mockResolvedValueOnce({ data: { bookmark_timeline_v2: { timeline: { instructions: [] } } } }),
|
|
330
|
+
};
|
|
331
|
+
const result = await command.func(page, { 'folder-id': 'folder_AbC-123', limit: 5 });
|
|
332
|
+
expect(result).toEqual([]);
|
|
333
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
334
|
+
const fetchScript = page.evaluate.mock.calls[1][0];
|
|
335
|
+
expect(decodeURIComponent(fetchScript)).toContain('"bookmark_collection_id":"folder_AbC-123"');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
4
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
5
|
+
|
|
6
|
+
// X surfaces user-created bookmark folders through a GraphQL slice query.
|
|
7
|
+
// We mirror the patterns used in bookmarks.js / lists.js: a literal
|
|
8
|
+
// fallback queryId combined with a runtime lookup against the
|
|
9
|
+
// twitter-openapi placeholder.json so we keep working when X rotates IDs.
|
|
10
|
+
const OPERATION_NAME = 'bookmarkFoldersSlice';
|
|
11
|
+
const FALLBACK_QUERY_ID = 'i78YDd0Tza-dWKw5H2Y7WA';
|
|
12
|
+
|
|
13
|
+
const FEATURES = {
|
|
14
|
+
rweb_tipjar_consumption_enabled: false,
|
|
15
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
16
|
+
verified_phone_label_enabled: false,
|
|
17
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
18
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
19
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function buildUrl(queryId) {
|
|
23
|
+
const variables = JSON.stringify({});
|
|
24
|
+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
|
|
25
|
+
+ `?variables=${encodeURIComponent(variables)}`
|
|
26
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk the GraphQL response shape used by X's bookmark folders slice and
|
|
31
|
+
* project each folder onto our column row.
|
|
32
|
+
*
|
|
33
|
+
* X has shipped at least three different envelope shapes for this query
|
|
34
|
+
* across the last two years; the precedence order below preserves
|
|
35
|
+
* compatibility with older accounts whose Premium-eligibility flag is
|
|
36
|
+
* still on the legacy V2 envelope.
|
|
37
|
+
*
|
|
38
|
+
* Exported via __test__ so the parser is unit-testable without a browser.
|
|
39
|
+
*/
|
|
40
|
+
export function parseBookmarkFolders(data, seen) {
|
|
41
|
+
const folders = [];
|
|
42
|
+
const slice = data?.data?.viewer?.bookmark_collections_slice
|
|
43
|
+
|| data?.data?.viewer_v2?.user_results?.result?.bookmark_collections_slice
|
|
44
|
+
|| data?.data?.bookmark_collections_slice
|
|
45
|
+
|| null;
|
|
46
|
+
const items = slice?.items || slice?.timeline?.timeline?.instructions?.flatMap?.(i => i.entries || []) || [];
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
// Two known item shapes: direct {id, name, ...} (newer) or wrapped
|
|
49
|
+
// {content: {bookmarkCollectionResult: {...}}} (older / nested).
|
|
50
|
+
const folder
|
|
51
|
+
= item?.bookmarkCollection
|
|
52
|
+
|| item?.content?.bookmarkCollection
|
|
53
|
+
|| item?.content?.itemContent?.bookmark_collection
|
|
54
|
+
|| item;
|
|
55
|
+
const id = folder?.id_str || folder?.id || folder?.rest_id || '';
|
|
56
|
+
if (!id || seen.has(id)) continue;
|
|
57
|
+
seen.add(id);
|
|
58
|
+
const name = folder?.name || folder?.collection_name || '';
|
|
59
|
+
// bookmarks_count is the X UI label; older envelopes used `count`.
|
|
60
|
+
const itemsCount = Number(folder?.bookmarks_count ?? folder?.items_count ?? folder?.count ?? 0) || 0;
|
|
61
|
+
const createdAt = folder?.created_at || folder?.timestamp_ms || '';
|
|
62
|
+
folders.push({
|
|
63
|
+
id: String(id),
|
|
64
|
+
name: String(name),
|
|
65
|
+
items: itemsCount,
|
|
66
|
+
created_at: String(createdAt),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return folders;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cli({
|
|
73
|
+
site: 'twitter',
|
|
74
|
+
name: 'bookmark-folders',
|
|
75
|
+
access: 'read',
|
|
76
|
+
description: 'List your Twitter/X bookmark folders (the user-created collections under Bookmarks). Returns folder id, name, item count, and created_at.',
|
|
77
|
+
domain: 'x.com',
|
|
78
|
+
strategy: Strategy.COOKIE,
|
|
79
|
+
browser: true,
|
|
80
|
+
browserSession: { reuse: 'site' },
|
|
81
|
+
args: [],
|
|
82
|
+
columns: ['id', 'name', 'items', 'created_at'],
|
|
83
|
+
func: async (page) => {
|
|
84
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
85
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
86
|
+
if (!ct0)
|
|
87
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
88
|
+
|
|
89
|
+
// Try the fa0311/twitter-openapi placeholder first; fall back to scraping
|
|
90
|
+
// client-web bundles for the queryId; final fallback is the pinned constant.
|
|
91
|
+
const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID);
|
|
92
|
+
|
|
93
|
+
const headers = JSON.stringify({
|
|
94
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
95
|
+
'X-Csrf-Token': ct0,
|
|
96
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
97
|
+
'X-Twitter-Active-User': 'yes',
|
|
98
|
+
});
|
|
99
|
+
const apiUrl = buildUrl(queryId);
|
|
100
|
+
const data = await page.evaluate(`async () => {
|
|
101
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
102
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
103
|
+
}`);
|
|
104
|
+
if (data?.error) {
|
|
105
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmark folders. queryId may have expired, or your account may not have folder access.`);
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
return parseBookmarkFolders(data, seen);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const __test__ = {
|
|
113
|
+
parseBookmarkFolders,
|
|
114
|
+
buildUrl,
|
|
115
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { __test__ } from './bookmark-folders.js';
|
|
4
|
+
|
|
5
|
+
const { parseBookmarkFolders, buildUrl } = __test__;
|
|
6
|
+
|
|
7
|
+
describe('twitter bookmark-folders parser', () => {
|
|
8
|
+
it('returns [] for empty payload', () => {
|
|
9
|
+
expect(parseBookmarkFolders({}, new Set())).toEqual([]);
|
|
10
|
+
expect(parseBookmarkFolders({ data: {} }, new Set())).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('extracts folders from the modern viewer.bookmark_collections_slice envelope', () => {
|
|
14
|
+
const data = {
|
|
15
|
+
data: {
|
|
16
|
+
viewer: {
|
|
17
|
+
bookmark_collections_slice: {
|
|
18
|
+
items: [
|
|
19
|
+
{
|
|
20
|
+
bookmarkCollection: {
|
|
21
|
+
id_str: '1234567890',
|
|
22
|
+
name: 'Reading list',
|
|
23
|
+
bookmarks_count: 42,
|
|
24
|
+
created_at: '2025-09-15T10:00:00.000Z',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
bookmarkCollection: {
|
|
29
|
+
id_str: '9876543210',
|
|
30
|
+
name: 'Recipes',
|
|
31
|
+
bookmarks_count: 7,
|
|
32
|
+
created_at: '2026-01-03T03:14:00.000Z',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
expect(parseBookmarkFolders(data, new Set())).toEqual([
|
|
41
|
+
{ id: '1234567890', name: 'Reading list', items: 42, created_at: '2025-09-15T10:00:00.000Z' },
|
|
42
|
+
{ id: '9876543210', name: 'Recipes', items: 7, created_at: '2026-01-03T03:14:00.000Z' },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('falls back to legacy viewer_v2 envelope', () => {
|
|
47
|
+
const data = {
|
|
48
|
+
data: {
|
|
49
|
+
viewer_v2: {
|
|
50
|
+
user_results: {
|
|
51
|
+
result: {
|
|
52
|
+
bookmark_collections_slice: {
|
|
53
|
+
items: [{ bookmarkCollection: { id: 'abc', name: 'Old', count: 3 } }],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
expect(parseBookmarkFolders(data, new Set())).toEqual([
|
|
61
|
+
{ id: 'abc', name: 'Old', items: 3, created_at: '' },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('falls back to flat bookmark_collections_slice envelope', () => {
|
|
66
|
+
const data = {
|
|
67
|
+
data: {
|
|
68
|
+
bookmark_collections_slice: {
|
|
69
|
+
items: [{ id_str: '5', name: 'Flat', bookmarks_count: 1, created_at: '2024-01-01' }],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
expect(parseBookmarkFolders(data, new Set())).toEqual([
|
|
74
|
+
{ id: '5', name: 'Flat', items: 1, created_at: '2024-01-01' },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('deduplicates folders by id across the seen Set', () => {
|
|
79
|
+
const data = {
|
|
80
|
+
data: {
|
|
81
|
+
viewer: {
|
|
82
|
+
bookmark_collections_slice: {
|
|
83
|
+
items: [
|
|
84
|
+
{ bookmarkCollection: { id_str: '1', name: 'A', bookmarks_count: 0 } },
|
|
85
|
+
{ bookmarkCollection: { id_str: '1', name: 'A again', bookmarks_count: 0 } },
|
|
86
|
+
{ bookmarkCollection: { id_str: '2', name: 'B', bookmarks_count: 0 } },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
expect(parseBookmarkFolders(data, new Set())).toEqual([
|
|
93
|
+
{ id: '1', name: 'A', items: 0, created_at: '' },
|
|
94
|
+
{ id: '2', name: 'B', items: 0, created_at: '' },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('coerces missing items count to 0', () => {
|
|
99
|
+
const data = {
|
|
100
|
+
data: {
|
|
101
|
+
viewer: {
|
|
102
|
+
bookmark_collections_slice: {
|
|
103
|
+
items: [{ bookmarkCollection: { id: '1', name: 'No count' } }],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
expect(parseBookmarkFolders(data, new Set())[0].items).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('skips entries without an id', () => {
|
|
112
|
+
const data = {
|
|
113
|
+
data: {
|
|
114
|
+
viewer: {
|
|
115
|
+
bookmark_collections_slice: {
|
|
116
|
+
items: [
|
|
117
|
+
{ bookmarkCollection: { name: 'Anonymous' } },
|
|
118
|
+
{ bookmarkCollection: { id: '1', name: 'OK' } },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
expect(parseBookmarkFolders(data, new Set())).toEqual([
|
|
125
|
+
{ id: '1', name: 'OK', items: 0, created_at: '' },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('twitter bookmark-folders URL builder', () => {
|
|
131
|
+
it('encodes the empty variables object and includes the queryId in the path', () => {
|
|
132
|
+
const url = buildUrl('queryid123');
|
|
133
|
+
expect(url).toContain('/i/api/graphql/queryid123/bookmarkFoldersSlice');
|
|
134
|
+
expect(url).toContain('variables=' + encodeURIComponent('{}'));
|
|
135
|
+
expect(url).toContain('features=');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('twitter bookmark-folders command (registry)', () => {
|
|
140
|
+
it('throws AuthRequiredError when ct0 cookie is missing', async () => {
|
|
141
|
+
const command = getRegistry().get('twitter/bookmark-folders');
|
|
142
|
+
expect(command?.func).toBeTypeOf('function');
|
|
143
|
+
const page = {
|
|
144
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
145
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
146
|
+
getCookies: vi.fn().mockResolvedValue([]), // no ct0 cookie → AuthRequired
|
|
147
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
148
|
+
};
|
|
149
|
+
await expect(command.func(page, {})).rejects.toThrow(/Not logged into x.com/);
|
|
150
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
151
|
+
});
|
|
152
|
+
});
|
package/clis/twitter/bookmark.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'bookmark',
|
|
@@ -15,22 +17,28 @@ cli({
|
|
|
15
17
|
func: async (page, kwargs) => {
|
|
16
18
|
if (!page)
|
|
17
19
|
throw new CommandExecutionError('Browser session required for twitter bookmark');
|
|
18
|
-
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
19
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
23
|
const result = await page.evaluate(`(async () => {
|
|
21
24
|
try {
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Article-scoped: on conversation pages multiple bookmark/remove
|
|
27
|
+
// buttons render and a bare querySelector would silently bookmark
|
|
28
|
+
// a different tweet (e.g. the parent of the requested reply).
|
|
22
29
|
let attempts = 0;
|
|
23
30
|
let bookmarkBtn = null;
|
|
24
31
|
let removeBtn = null;
|
|
32
|
+
let targetArticle = null;
|
|
25
33
|
|
|
26
34
|
while (attempts < 20) {
|
|
27
|
-
|
|
28
|
-
removeBtn =
|
|
35
|
+
targetArticle = findTargetArticle();
|
|
36
|
+
removeBtn = targetArticle?.querySelector('[data-testid="removeBookmark"]') || null;
|
|
29
37
|
if (removeBtn) {
|
|
30
38
|
return { ok: true, message: 'Tweet is already bookmarked.' };
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
bookmarkBtn =
|
|
41
|
+
bookmarkBtn = targetArticle?.querySelector('[data-testid="bookmark"]') || null;
|
|
34
42
|
if (bookmarkBtn) break;
|
|
35
43
|
|
|
36
44
|
await new Promise(r => setTimeout(r, 500));
|
|
@@ -38,14 +46,15 @@ cli({
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
if (!bookmarkBtn) {
|
|
41
|
-
return { ok: false, message: 'Could not find Bookmark button. Are you logged in?' };
|
|
49
|
+
return { ok: false, message: 'Could not find Bookmark button on the requested tweet. Are you logged in?' };
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
bookmarkBtn.click();
|
|
45
53
|
await new Promise(r => setTimeout(r, 1000));
|
|
46
54
|
|
|
47
55
|
// Verify
|
|
48
|
-
const
|
|
56
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
57
|
+
const verify = verifyArticle?.querySelector('[data-testid="removeBookmark"]');
|
|
49
58
|
if (verify) {
|
|
50
59
|
return { ok: true, message: 'Tweet successfully bookmarked.' };
|
|
51
60
|
} else {
|