@jackwener/opencli 1.7.13 → 1.7.15

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.
Files changed (97) hide show
  1. package/cli-manifest.json +326 -44
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +167 -0
  41. package/clis/twitter/quote.test.js +194 -0
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +94 -0
  45. package/clis/twitter/retweet.test.js +73 -0
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +81 -0
  49. package/clis/twitter/shared.test.js +134 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +80 -0
  56. package/clis/twitter/unlike.test.js +75 -0
  57. package/clis/twitter/unretweet.js +94 -0
  58. package/clis/twitter/unretweet.test.js +73 -0
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/bridge.js +47 -45
  69. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  70. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  71. package/dist/src/browser/cdp.js +5 -0
  72. package/dist/src/browser/cdp.test.js +1 -0
  73. package/dist/src/browser/daemon-client.d.ts +3 -1
  74. package/dist/src/browser/find.d.ts +9 -1
  75. package/dist/src/browser/find.js +219 -0
  76. package/dist/src/browser/find.test.js +61 -1
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +13 -0
  79. package/dist/src/browser/page.test.js +28 -0
  80. package/dist/src/browser/target-errors.d.ts +3 -1
  81. package/dist/src/browser/target-errors.js +2 -0
  82. package/dist/src/browser/target-resolver.d.ts +14 -0
  83. package/dist/src/browser/target-resolver.js +28 -0
  84. package/dist/src/browser/visual-refs.d.ts +11 -0
  85. package/dist/src/browser/visual-refs.js +108 -0
  86. package/dist/src/browser.test.js +18 -0
  87. package/dist/src/build-manifest.d.ts +23 -0
  88. package/dist/src/build-manifest.js +34 -0
  89. package/dist/src/build-manifest.test.js +108 -1
  90. package/dist/src/cli.js +560 -58
  91. package/dist/src/cli.test.js +689 -1
  92. package/dist/src/commanderAdapter.js +23 -4
  93. package/dist/src/help.d.ts +36 -0
  94. package/dist/src/help.js +301 -5
  95. package/dist/src/types.d.ts +82 -0
  96. package/package.json +1 -1
  97. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,189 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4
+ import { resolveTwitterQueryId } from './shared.js';
5
+
6
+ // Companion to bookmark-folders.js: reads tweets inside a single folder.
7
+ // X exposes folder contents through a separate timeline operation
8
+ // (BookmarkFolderTimeline). The shape is essentially the same as the
9
+ // global bookmarks timeline (bookmark_timeline_v2.timeline.instructions),
10
+ // just scoped to one folder via the bookmark_collection_id variable.
11
+ const OPERATION_NAME = 'BookmarkFolderTimeline';
12
+ const FALLBACK_QUERY_ID = '13H7EUATwethsj_jZ6QQAQ';
13
+ const FOLDER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
14
+
15
+ const FEATURES = {
16
+ rweb_video_screen_enabled: false,
17
+ profile_label_improvements_pcf_label_in_post_enabled: true,
18
+ responsive_web_profile_redirect_enabled: false,
19
+ rweb_tipjar_consumption_enabled: false,
20
+ verified_phone_label_enabled: false,
21
+ creator_subscriptions_tweet_preview_api_enabled: true,
22
+ responsive_web_graphql_timeline_navigation_enabled: true,
23
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
24
+ premium_content_api_read_enabled: false,
25
+ communities_web_enable_tweet_community_results_fetch: true,
26
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
27
+ articles_preview_enabled: true,
28
+ responsive_web_edit_tweet_api_enabled: true,
29
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
30
+ view_counts_everywhere_api_enabled: true,
31
+ longform_notetweets_consumption_enabled: true,
32
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
33
+ tweet_awards_web_tipping_enabled: false,
34
+ content_disclosure_indicator_enabled: true,
35
+ content_disclosure_ai_generated_indicator_enabled: true,
36
+ freedom_of_speech_not_reach_fetch_enabled: true,
37
+ standardized_nudges_misinfo: true,
38
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
39
+ longform_notetweets_rich_text_read_enabled: true,
40
+ longform_notetweets_inline_media_enabled: false,
41
+ responsive_web_enhance_cards_enabled: false,
42
+ };
43
+
44
+ function buildFolderTimelineUrl(queryId, folderId, count, cursor) {
45
+ const vars = {
46
+ bookmark_collection_id: String(folderId),
47
+ count,
48
+ includePromotedContent: false,
49
+ };
50
+ if (cursor) vars.cursor = cursor;
51
+ return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
52
+ + `?variables=${encodeURIComponent(JSON.stringify(vars))}`
53
+ + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
54
+ }
55
+
56
+ function extractFolderTweet(result, seen) {
57
+ if (!result) return null;
58
+ const tw = result.tweet || result;
59
+ const legacy = tw.legacy || {};
60
+ if (!tw.rest_id || seen.has(tw.rest_id)) return null;
61
+ seen.add(tw.rest_id);
62
+ const user = tw.core?.user_results?.result;
63
+ const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
64
+ const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
65
+ return {
66
+ id: tw.rest_id,
67
+ author: screenName,
68
+ text: noteText || legacy.full_text || '',
69
+ likes: legacy.favorite_count || 0,
70
+ retweets: legacy.retweet_count || 0,
71
+ bookmarks: legacy.bookmark_count || 0,
72
+ created_at: legacy.created_at || '',
73
+ url: screenName ? `https://x.com/${screenName}/status/${tw.rest_id}` : `https://x.com/i/status/${tw.rest_id}`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Parse the bookmark-folder timeline payload. Same instruction-walking
79
+ * pattern as the global bookmarks timeline; X re-uses the
80
+ * `bookmark_timeline_v2` envelope for folder-scoped queries.
81
+ *
82
+ * Exported via __test__.
83
+ */
84
+ export function parseBookmarkFolderTimeline(data, seen) {
85
+ const tweets = [];
86
+ let nextCursor = null;
87
+ const instructions = data?.data?.bookmark_collection_timeline?.timeline?.instructions
88
+ || data?.data?.bookmark_timeline_v2?.timeline?.instructions
89
+ || data?.data?.bookmark_timeline?.timeline?.instructions
90
+ || [];
91
+ for (const inst of instructions) {
92
+ for (const entry of inst.entries || []) {
93
+ const content = entry.content;
94
+ if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
95
+ if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
96
+ nextCursor = content.value;
97
+ continue;
98
+ }
99
+ if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
100
+ nextCursor = content?.value || content?.itemContent?.value || nextCursor;
101
+ continue;
102
+ }
103
+ const direct = extractFolderTweet(content?.itemContent?.tweet_results?.result, seen);
104
+ if (direct) {
105
+ tweets.push(direct);
106
+ continue;
107
+ }
108
+ for (const item of content?.items || []) {
109
+ const nested = extractFolderTweet(item.item?.itemContent?.tweet_results?.result, seen);
110
+ if (nested) tweets.push(nested);
111
+ }
112
+ }
113
+ }
114
+ return { tweets, nextCursor };
115
+ }
116
+
117
+ cli({
118
+ site: 'twitter',
119
+ name: 'bookmark-folder',
120
+ access: 'read',
121
+ description: 'Read the tweets inside a single Twitter/X bookmark folder. Get the folder id from `opencli twitter bookmark-folders`.',
122
+ domain: 'x.com',
123
+ strategy: Strategy.COOKIE,
124
+ browser: true,
125
+ args: [
126
+ { name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
127
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
128
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the folder by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
129
+ ],
130
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
131
+ func: async (page, kwargs) => {
132
+ const folderId = String(kwargs['folder-id'] || '').trim();
133
+ if (!folderId || !FOLDER_ID_PATTERN.test(folderId)) {
134
+ throw new ArgumentError(
135
+ `Invalid folder-id: ${JSON.stringify(kwargs['folder-id'])}. Expected a safe folder ID from \`opencli twitter bookmark-folders\`.`,
136
+ );
137
+ }
138
+ const limit = Number(kwargs.limit ?? 20);
139
+ if (!Number.isInteger(limit) || limit < 1) {
140
+ throw new ArgumentError(`Invalid --limit: ${JSON.stringify(kwargs.limit)}. Expected a positive integer.`);
141
+ }
142
+
143
+ await page.goto('https://x.com');
144
+ await page.wait(3);
145
+ const ct0 = await page.evaluate(`() => {
146
+ return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
147
+ }`);
148
+ if (!ct0)
149
+ throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
150
+
151
+ const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID);
152
+
153
+ const headers = JSON.stringify({
154
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
155
+ 'X-Csrf-Token': ct0,
156
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
157
+ 'X-Twitter-Active-User': 'yes',
158
+ });
159
+
160
+ const allTweets = [];
161
+ const seen = new Set();
162
+ let cursor = null;
163
+ for (let i = 0; i < 5 && allTweets.length < limit; i++) {
164
+ const fetchCount = Math.min(100, limit - allTweets.length + 10);
165
+ const apiUrl = buildFolderTimelineUrl(queryId, folderId, fetchCount, cursor);
166
+ const data = await page.evaluate(`async () => {
167
+ const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
168
+ return r.ok ? await r.json() : { error: r.status };
169
+ }`);
170
+ if (data?.error) {
171
+ if (allTweets.length === 0)
172
+ throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch folder ${folderId}. queryId may have expired, or the folder may not exist.`);
173
+ break;
174
+ }
175
+ const { tweets, nextCursor } = parseBookmarkFolderTimeline(data, seen);
176
+ allTweets.push(...tweets);
177
+ if (!nextCursor || nextCursor === cursor) break;
178
+ cursor = nextCursor;
179
+ }
180
+ const trimmed = allTweets.slice(0, limit);
181
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
182
+ },
183
+ });
184
+
185
+ export const __test__ = {
186
+ parseBookmarkFolderTimeline,
187
+ buildFolderTimelineUrl,
188
+ FOLDER_ID_PATTERN,
189
+ };
@@ -0,0 +1,334 @@
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
+ evaluate: vi.fn().mockResolvedValue(null),
313
+ };
314
+ await expect(command.func(page, { 'folder-id': '12345', limit: 5 }))
315
+ .rejects
316
+ .toThrow(/Not logged into x.com/);
317
+ });
318
+
319
+ it('accepts an opaque safe folder-id and sends it in the GraphQL variables', async () => {
320
+ const command = getRegistry().get('twitter/bookmark-folder');
321
+ const page = {
322
+ goto: vi.fn().mockResolvedValue(undefined),
323
+ wait: vi.fn().mockResolvedValue(undefined),
324
+ evaluate: vi.fn()
325
+ .mockResolvedValueOnce('ct0-token')
326
+ .mockResolvedValueOnce('queryX')
327
+ .mockResolvedValueOnce({ data: { bookmark_timeline_v2: { timeline: { instructions: [] } } } }),
328
+ };
329
+ const result = await command.func(page, { 'folder-id': 'folder_AbC-123', limit: 5 });
330
+ expect(result).toEqual([]);
331
+ const fetchScript = page.evaluate.mock.calls[2][0];
332
+ expect(decodeURIComponent(fetchScript)).toContain('"bookmark_collection_id":"folder_AbC-123"');
333
+ });
334
+ });
@@ -0,0 +1,117 @@
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
+ args: [],
81
+ columns: ['id', 'name', 'items', 'created_at'],
82
+ func: async (page) => {
83
+ await page.goto('https://x.com');
84
+ await page.wait(3);
85
+ const ct0 = await page.evaluate(`() => {
86
+ return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
87
+ }`);
88
+ if (!ct0)
89
+ throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
90
+
91
+ // Try the fa0311/twitter-openapi placeholder first; fall back to scraping
92
+ // client-web bundles for the queryId; final fallback is the pinned constant.
93
+ const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID);
94
+
95
+ const headers = JSON.stringify({
96
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
97
+ 'X-Csrf-Token': ct0,
98
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
99
+ 'X-Twitter-Active-User': 'yes',
100
+ });
101
+ const apiUrl = buildUrl(queryId);
102
+ const data = await page.evaluate(`async () => {
103
+ const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
104
+ return r.ok ? await r.json() : { error: r.status };
105
+ }`);
106
+ if (data?.error) {
107
+ throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmark folders. queryId may have expired, or your account may not have folder access.`);
108
+ }
109
+ const seen = new Set();
110
+ return parseBookmarkFolders(data, seen);
111
+ },
112
+ });
113
+
114
+ export const __test__ = {
115
+ parseBookmarkFolders,
116
+ buildUrl,
117
+ };