@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.
Files changed (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './like.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter like command', () => {
8
+ it('navigates to the tweet URL and reports success when the like script confirms', async () => {
9
+ const cmd = getRegistry().get('twitter/like');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Tweet successfully liked.' },
13
+ ]);
14
+ const result = await cmd.func(page, {
15
+ url: 'https://x.com/alice/status/2040254679301718161',
16
+ });
17
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
18
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
19
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
20
+ const script = page.evaluate.mock.calls[0][0];
21
+ // Idempotency: looks for the unlike button (already-liked path) before clicking.
22
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
23
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
24
+ expect(script).toContain('likeBtn.click()');
25
+ // Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
26
+ // emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
27
+ // tweet-path regex. JSDOM-level coverage lives in shared.test.js.
28
+ expect(script).toContain('__twHasLinkToTarget');
29
+ expect(script).toContain('__twGetStatusIdFromHref');
30
+ expect(script).toContain("document.querySelectorAll('article')");
31
+ expect(result).toEqual([
32
+ { status: 'success', message: 'Tweet successfully liked.' },
33
+ ]);
34
+ });
35
+
36
+ it('returns a failed row without re-waiting when the like script reports a UI mismatch', async () => {
37
+ const cmd = getRegistry().get('twitter/like');
38
+ const page = createPageMock([
39
+ {
40
+ ok: false,
41
+ message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?',
42
+ },
43
+ ]);
44
+ const result = await cmd.func(page, {
45
+ url: 'https://x.com/alice/status/2040254679301718161',
46
+ });
47
+ expect(result).toEqual([
48
+ {
49
+ status: 'failed',
50
+ message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?',
51
+ },
52
+ ]);
53
+ // Only the primaryColumn wait should run when ok is false.
54
+ expect(page.wait).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it('throws CommandExecutionError when no page is provided', async () => {
58
+ const cmd = getRegistry().get('twitter/like');
59
+ await expect(cmd.func(undefined, {
60
+ url: 'https://x.com/alice/status/2040254679301718161',
61
+ })).rejects.toThrow(CommandExecutionError);
62
+ });
63
+
64
+ it('rejects invalid tweet URLs before navigation', async () => {
65
+ const cmd = getRegistry().get('twitter/like');
66
+ const page = createPageMock([]);
67
+ await expect(cmd.func(page, {
68
+ url: 'https://x.com/alice/status/2040254679301718161/photo/1',
69
+ })).rejects.toThrow(ArgumentError);
70
+ expect(page.goto).not.toHaveBeenCalled();
71
+ expect(page.evaluate).not.toHaveBeenCalled();
72
+ });
73
+ });
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
4
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
4
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
  const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
6
6
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
7
  const FEATURES = {
@@ -138,23 +138,22 @@ cli({
138
138
  site: 'twitter',
139
139
  name: 'likes',
140
140
  access: 'read',
141
- description: 'Fetch liked tweets of a Twitter user',
141
+ description: 'Fetch liked tweets of a Twitter user (defaults to the logged-in user when no username is given)',
142
142
  domain: 'x.com',
143
143
  strategy: Strategy.COOKIE,
144
144
  browser: true,
145
+ browserSession: { reuse: 'site' },
145
146
  args: [
146
- { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
147
- { name: 'limit', type: 'int', default: 20 },
147
+ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
148
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
149
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the liked tweets 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 (recency) ordering.' },
148
150
  ],
149
151
  columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
150
152
  func: async (page, kwargs) => {
151
153
  const limit = kwargs.limit || 20;
152
154
  let username = (kwargs.username || '').replace(/^@/, '');
153
- await page.goto('https://x.com');
154
- await page.wait(3);
155
- const ct0 = await page.evaluate(`() => {
156
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
157
- }`);
155
+ const cookies = await page.getCookies({ url: 'https://x.com' });
156
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
158
157
  if (!ct0)
159
158
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
160
159
  // If no username provided, detect the logged-in user
@@ -170,7 +169,7 @@ cli({
170
169
  const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
171
170
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
172
171
  const headers = JSON.stringify({
173
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
172
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
174
173
  'X-Csrf-Token': ct0,
175
174
  'X-Twitter-Auth-Type': 'OAuth2Session',
176
175
  'X-Twitter-Active-User': 'yes',
@@ -208,7 +207,8 @@ cli({
208
207
  break;
209
208
  cursor = nextCursor;
210
209
  }
211
- return allTweets.slice(0, limit);
210
+ const trimmed = allTweets.slice(0, limit);
211
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
212
212
  },
213
213
  });
214
214
  export const __test__ = {
@@ -2,8 +2,8 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
4
  import { parseListsManagement } from './lists.js';
5
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
5
6
 
6
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
 
@@ -71,8 +71,8 @@ cli({
71
71
  strategy: Strategy.UI,
72
72
  browser: true,
73
73
  args: [
74
- { name: 'listId', positional: true, type: 'string', required: true },
75
- { name: 'username', positional: true, type: 'string', required: true },
74
+ { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' },
75
+ { name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to add (with or without @)' },
76
76
  ],
77
77
  columns: ['listId', 'username', 'userId', 'status', 'message'],
78
78
  func: async (page, kwargs) => {
@@ -84,17 +84,18 @@ cli({
84
84
  if (!username) {
85
85
  throw new CommandExecutionError('Username is required');
86
86
  }
87
+ // Strategy.UI does not get a domain URL pre-nav from the framework.
88
+ // This page context is load-bearing for pre-target GraphQL calls below.
87
89
  await page.goto('https://x.com');
88
90
  await page.wait(3);
89
- const ct0 = await page.evaluate(`() => {
90
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
91
- }`);
91
+ const cookies = await page.getCookies({ url: 'https://x.com' });
92
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
92
93
  if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
93
94
 
94
95
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
95
96
 
96
97
  const headers = JSON.stringify({
97
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
98
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
98
99
  'X-Csrf-Token': ct0,
99
100
  'X-Twitter-Auth-Type': 'OAuth2Session',
100
101
  'X-Twitter-Active-User': 'yes',
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './list-add.js';
4
4
 
@@ -12,4 +12,26 @@ describe('twitter list-add registration', () => {
12
12
  expect(listIdArg?.required).toBe(true);
13
13
  expect(listIdArg?.positional).toBe(true);
14
14
  });
15
+
16
+ it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
17
+ const cmd = getRegistry().get('twitter/list-add');
18
+ const page = {
19
+ goto: vi.fn().mockResolvedValue(undefined),
20
+ wait: vi.fn().mockResolvedValue(undefined),
21
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
22
+ evaluate: vi.fn()
23
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
24
+ .mockResolvedValueOnce('user-1')
25
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
26
+ .mockResolvedValueOnce({}),
27
+ };
28
+
29
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
30
+ .rejects
31
+ .toThrow(/List 123 not found/);
32
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
33
+ expect(page.goto).toHaveBeenCalledTimes(1);
34
+ expect(page.wait).toHaveBeenCalledWith(3);
35
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
36
+ });
15
37
  });
@@ -2,8 +2,8 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
4
  import { parseListsManagement } from './lists.js';
5
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
5
6
 
6
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
 
@@ -80,8 +80,8 @@ cli({
80
80
  strategy: Strategy.UI,
81
81
  browser: true,
82
82
  args: [
83
- { name: 'listId', positional: true, type: 'string', required: true },
84
- { name: 'username', positional: true, type: 'string', required: true },
83
+ { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' },
84
+ { name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to remove (with or without @)' },
85
85
  ],
86
86
  columns: ['listId', 'username', 'userId', 'status', 'message'],
87
87
  func: async (page, kwargs) => {
@@ -92,16 +92,17 @@ cli({
92
92
  }
93
93
  if (!username) throw new CommandExecutionError('Username is required');
94
94
 
95
+ // Strategy.UI does not get a domain URL pre-nav from the framework.
96
+ // This page context is load-bearing for pre-target GraphQL calls below.
95
97
  await page.goto('https://x.com');
96
98
  await page.wait(3);
97
- const ct0 = await page.evaluate(`() => {
98
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
99
- }`);
99
+ const cookies = await page.getCookies({ url: 'https://x.com' });
100
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
100
101
  if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
101
102
 
102
103
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
103
104
  const headers = JSON.stringify({
104
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
105
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
105
106
  'X-Csrf-Token': ct0,
106
107
  'X-Twitter-Auth-Type': 'OAuth2Session',
107
108
  'X-Twitter-Active-User': 'yes',
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './list-remove.js';
4
4
 
@@ -11,4 +11,26 @@ describe('twitter list-remove registration', () => {
11
11
  expect(listIdArg).toBeTruthy();
12
12
  expect(listIdArg?.required).toBe(true);
13
13
  });
14
+
15
+ it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
16
+ const cmd = getRegistry().get('twitter/list-remove');
17
+ const page = {
18
+ goto: vi.fn().mockResolvedValue(undefined),
19
+ wait: vi.fn().mockResolvedValue(undefined),
20
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
21
+ evaluate: vi.fn()
22
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
23
+ .mockResolvedValueOnce('user-1')
24
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
25
+ .mockResolvedValueOnce({}),
26
+ };
27
+
28
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
29
+ .rejects
30
+ .toThrow(/List 123 not found/);
31
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
32
+ expect(page.goto).toHaveBeenCalledTimes(1);
33
+ expect(page.wait).toHaveBeenCalledWith(3);
34
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
35
+ });
14
36
  });
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
3
4
 
4
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
5
5
  const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
6
6
  const OPERATION_NAME = 'ListLatestTweetsTimeline';
7
7
 
@@ -112,9 +112,11 @@ cli({
112
112
  domain: 'x.com',
113
113
  strategy: Strategy.COOKIE,
114
114
  browser: true,
115
+ browserSession: { reuse: 'site' },
115
116
  args: [
116
- { name: 'listId', positional: true, type: 'string', required: true },
117
+ { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
117
118
  { name: 'limit', type: 'int', default: 50 },
119
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline 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 list\'s native (recency) ordering.' },
118
120
  ],
119
121
  columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
120
122
  func: async (page, kwargs) => {
@@ -123,11 +125,8 @@ cli({
123
125
  throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`);
124
126
  }
125
127
  const limit = kwargs.limit || 50;
126
- await page.goto('https://x.com');
127
- await page.wait(3);
128
- const ct0 = await page.evaluate(`() => {
129
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
130
- }`);
128
+ const cookies = await page.getCookies({ url: 'https://x.com' });
129
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
131
130
  if (!ct0)
132
131
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
133
132
  const queryId = await page.evaluate(`async () => {
@@ -155,7 +154,7 @@ cli({
155
154
  return null;
156
155
  }`) || LIST_TWEETS_QUERY_ID;
157
156
  const headers = JSON.stringify({
158
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
157
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
159
158
  'X-Csrf-Token': ct0,
160
159
  'X-Twitter-Auth-Type': 'OAuth2Session',
161
160
  'X-Twitter-Active-User': 'yes',
@@ -181,6 +180,7 @@ cli({
181
180
  break;
182
181
  cursor = nextCursor;
183
182
  }
184
- return allTweets.slice(0, limit);
183
+ const trimmed = allTweets.slice(0, limit);
184
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
185
185
  },
186
186
  });
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
3
4
 
4
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
5
5
  const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
6
6
  const OPERATION_NAME = 'ListsManagementPageTimeline';
7
7
 
@@ -92,17 +92,15 @@ export const command = cli({
92
92
  domain: 'x.com',
93
93
  strategy: Strategy.COOKIE,
94
94
  browser: true,
95
+ browserSession: { reuse: 'site' },
95
96
  args: [
96
- { name: 'limit', type: 'int', default: 50 },
97
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
97
98
  ],
98
99
  columns: ['id', 'name', 'members', 'followers', 'mode'],
99
100
  func: async (page, kwargs) => {
100
101
  const limit = kwargs.limit || 50;
101
- await page.goto('https://x.com');
102
- await page.wait(3);
103
- const ct0 = await page.evaluate(`() => {
104
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
105
- }`);
102
+ const cookies = await page.getCookies({ url: 'https://x.com' });
103
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
106
104
  if (!ct0)
107
105
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
108
106
  const queryId = await page.evaluate(`async () => {
@@ -130,7 +128,7 @@ export const command = cli({
130
128
  return null;
131
129
  }`) || LISTS_QUERY_ID;
132
130
  const headers = JSON.stringify({
133
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
131
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
134
132
  'X-Csrf-Token': ct0,
135
133
  'X-Twitter-Auth-Type': 'OAuth2Session',
136
134
  'X-Twitter-Active-User': 'yes',
@@ -4,12 +4,13 @@ cli({
4
4
  site: 'twitter',
5
5
  name: 'notifications',
6
6
  access: 'read',
7
- description: 'Get Twitter/X notifications',
7
+ description: 'Get your Twitter/X notifications (the logged-in user\'s likes/replies/follows feed, newest first)',
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.INTERCEPT,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
- { name: 'limit', type: 'int', default: 20 },
13
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
13
14
  ],
14
15
  columns: ['id', 'action', 'author', 'text', 'url'],
15
16
  func: async (page, kwargs) => {
@@ -1,17 +1,19 @@
1
1
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
4
5
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
5
6
  cli({
6
7
  site: 'twitter',
7
8
  name: 'profile',
8
9
  access: 'read',
9
- description: 'Fetch a Twitter user profile (bio, stats, etc.)',
10
+ description: 'Fetch a Twitter user profile bio, stats, etc. (defaults to the logged-in user when no username is given)',
10
11
  domain: 'x.com',
11
12
  strategy: Strategy.COOKIE,
12
13
  browser: true,
14
+ browserSession: { reuse: 'site' },
13
15
  args: [
14
- { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
16
+ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
15
17
  ],
16
18
  columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
17
19
  func: async (page, kwargs) => {
@@ -31,14 +33,18 @@ cli({
31
33
  // Navigate directly to the user's profile page (gives us cookie context)
32
34
  await page.goto(`https://x.com/${username}`);
33
35
  await page.wait(3);
36
+ // Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
37
+ const cookies = await page.getCookies({ url: 'https://x.com' });
38
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
39
+ if (!ct0)
40
+ throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
34
41
  const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
35
42
  const result = await page.evaluate(`
36
43
  async () => {
37
44
  const screenName = "${username}";
38
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
39
- if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
45
+ const ct0 = ${JSON.stringify(ct0)};
40
46
 
41
- const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
47
+ const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
42
48
  const headers = {
43
49
  'Authorization': 'Bearer ' + decodeURIComponent(bearer),
44
50
  'X-Csrf-Token': ct0,
@@ -95,8 +101,6 @@ cli({
95
101
  }
96
102
  `);
97
103
  if (result?.error) {
98
- if (String(result.error).includes('No ct0 cookie'))
99
- throw new AuthRequiredError('x.com', result.error);
100
104
  throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
101
105
  }
102
106
  return result || [];
@@ -1,10 +1,13 @@
1
+ import * as fs from 'node:fs';
1
2
  import { CommandExecutionError } from '@jackwener/opencli/errors';
2
3
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { parseTweetUrl } from './shared.js';
4
-
5
- function extractTweetId(url) {
6
- return parseTweetUrl(url).id;
7
- }
4
+ import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
5
+ import {
6
+ COMPOSER_FILE_INPUT_SELECTOR,
7
+ attachComposerImage,
8
+ downloadRemoteImage,
9
+ resolveImagePath,
10
+ } from './utils.js';
8
11
 
9
12
  function buildQuoteComposerUrl(url) {
10
13
  // Twitter/X quote-tweet compose URL: the `url` param attaches the source
@@ -17,15 +20,8 @@ function buildQuoteComposerUrl(url) {
17
20
  async function submitQuote(page, text, tweetId) {
18
21
  return page.evaluate(`(async () => {
19
22
  try {
23
+ ${buildTwitterArticleScopeSource(tweetId)}
20
24
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
21
- const getStatusId = (href) => {
22
- try {
23
- const match = new URL(href, window.location.origin).pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/);
24
- return match?.[1] || null;
25
- } catch {
26
- return null;
27
- }
28
- };
29
25
  const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
30
26
  const box = boxes.find(visible) || boxes[0];
31
27
  if (!box) {
@@ -34,7 +30,6 @@ async function submitQuote(page, text, tweetId) {
34
30
 
35
31
  box.focus();
36
32
  const textToInsert = ${JSON.stringify(text)};
37
- const tweetId = ${JSON.stringify(tweetId)};
38
33
  // execCommand('insertText') is more reliable with Twitter's Draft.js editor.
39
34
  if (!document.execCommand('insertText', false, textToInsert)) {
40
35
  // Fallback to paste event if execCommand fails.
@@ -49,13 +44,16 @@ async function submitQuote(page, text, tweetId) {
49
44
 
50
45
  await new Promise(r => setTimeout(r, 1000));
51
46
 
52
- // Confirm the quoted card is rendered before submitting; otherwise we may
53
- // accidentally post a plain tweet without the quote attachment.
47
+ // Confirm the quoted card is rendered before submitting; otherwise
48
+ // we may accidentally post a plain tweet without the quote
49
+ // attachment. The compose page does not wrap the card in an
50
+ // <article>, so we probe the document for any link whose path
51
+ // exactly matches the requested status id (uses __twHasLinkToTarget
52
+ // from buildTwitterArticleScopeSource).
54
53
  let cardAttempts = 0;
55
54
  let hasQuoteCard = false;
56
55
  while (cardAttempts < 20) {
57
- hasQuoteCard = Array.from(document.querySelectorAll('a[href*="/status/"]'))
58
- .some((link) => getStatusId(link.href) === tweetId);
56
+ hasQuoteCard = __twHasLinkToTarget(document);
59
57
  if (hasQuoteCard) break;
60
58
  await new Promise(r => setTimeout(r, 250));
61
59
  cardAttempts++;
@@ -102,38 +100,68 @@ cli({
102
100
  site: 'twitter',
103
101
  name: 'quote',
104
102
  access: 'write',
105
- description: 'Quote-tweet a specific tweet with your own text',
103
+ description: 'Quote-tweet a specific tweet with your own text, optionally with a local or remote image',
106
104
  domain: 'x.com',
107
105
  strategy: Strategy.UI,
108
106
  browser: true,
109
107
  args: [
110
108
  { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
111
109
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
110
+ { name: 'image', help: 'Optional local image path to attach to the quote tweet' },
111
+ { name: 'image-url', help: 'Optional remote image URL to download and attach to the quote tweet' },
112
112
  ],
113
113
  columns: ['status', 'message', 'text'],
114
114
  func: async (page, kwargs) => {
115
115
  if (!page)
116
116
  throw new CommandExecutionError('Browser session required for twitter quote');
117
+ if (kwargs.image && kwargs['image-url']) {
118
+ throw new CommandExecutionError('Use either --image or --image-url, not both.');
119
+ }
117
120
 
118
- // Dedicated composer is more reliable than the inline quote-tweet button.
121
+ // Validate URL (typed ArgumentError on malformed/off-domain inputs)
122
+ // before any browser interaction or remote image download.
119
123
  const target = parseTweetUrl(kwargs.url);
120
- await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
121
- await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
122
124
 
123
- const result = await submitQuote(page, kwargs.text, target.id);
124
- if (result.ok) {
125
- // Wait for network submission to complete
126
- await page.wait(3);
125
+ let localImagePath;
126
+ let cleanupDir;
127
+ try {
128
+ if (kwargs.image) {
129
+ localImagePath = resolveImagePath(kwargs.image);
130
+ } else if (kwargs['image-url']) {
131
+ const downloaded = await downloadRemoteImage(kwargs['image-url']);
132
+ localImagePath = downloaded.absPath;
133
+ cleanupDir = downloaded.cleanupDir;
134
+ }
135
+
136
+ // Dedicated composer is more reliable than the inline quote-tweet button.
137
+ await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
138
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
139
+
140
+ if (localImagePath) {
141
+ await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
142
+ await attachComposerImage(page, localImagePath);
143
+ }
144
+
145
+ const result = await submitQuote(page, kwargs.text, target.id);
146
+ if (result.ok) {
147
+ // Wait for network submission to complete
148
+ await page.wait(3);
149
+ }
150
+ return [{
151
+ status: result.ok ? 'success' : 'failed',
152
+ message: result.message,
153
+ text: kwargs.text,
154
+ ...(kwargs.image ? { image: kwargs.image } : {}),
155
+ ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
156
+ }];
157
+ } finally {
158
+ if (cleanupDir) {
159
+ fs.rmSync(cleanupDir, { recursive: true, force: true });
160
+ }
127
161
  }
128
- return [{
129
- status: result.ok ? 'success' : 'failed',
130
- message: result.message,
131
- text: kwargs.text,
132
- }];
133
162
  }
134
163
  });
135
164
 
136
165
  export const __test__ = {
137
166
  buildQuoteComposerUrl,
138
- extractTweetId,
139
167
  };