@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,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './hide-reply.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter hide-reply command', () => {
8
+ it('navigates to the reply URL and reports success when the hide-reply script confirms', async () => {
9
+ const cmd = getRegistry().get('twitter/hide-reply');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Reply successfully hidden.' },
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
+ // Article-scoped More menu lookup — without scoping, the bare
22
+ // [aria-label="More"] selector grabs the parent tweet's More menu and
23
+ // silently hides the wrong reply (or fails because the parent is not a
24
+ // reply you authored).
25
+ expect(script).toContain('moreMenu.click()');
26
+ expect(script).toContain('[role="menuitem"]');
27
+ expect(script).toContain("'Hide reply'");
28
+ expect(script).toContain('hideItem.click()');
29
+ // Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
30
+ // emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
31
+ // tweet-path regex. JSDOM-level coverage lives in shared.test.js.
32
+ expect(script).toContain('__twHasLinkToTarget');
33
+ expect(script).toContain('__twGetStatusIdFromHref');
34
+ expect(script).toContain("document.querySelectorAll('article')");
35
+ expect(result).toEqual([
36
+ { status: 'success', message: 'Reply successfully hidden.' },
37
+ ]);
38
+ });
39
+
40
+ it('returns a failed row without re-waiting when the hide-reply script reports a UI mismatch', async () => {
41
+ const cmd = getRegistry().get('twitter/hide-reply');
42
+ const page = createPageMock([
43
+ {
44
+ ok: false,
45
+ message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
46
+ },
47
+ ]);
48
+ const result = await cmd.func(page, {
49
+ url: 'https://x.com/alice/status/2040254679301718161',
50
+ });
51
+ expect(result).toEqual([
52
+ {
53
+ status: 'failed',
54
+ message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
55
+ },
56
+ ]);
57
+ expect(page.wait).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it('throws CommandExecutionError when no page is provided', async () => {
61
+ const cmd = getRegistry().get('twitter/hide-reply');
62
+ await expect(cmd.func(undefined, {
63
+ url: 'https://x.com/alice/status/2040254679301718161',
64
+ })).rejects.toThrow(CommandExecutionError);
65
+ });
66
+
67
+ it('rejects invalid tweet URLs before navigation', async () => {
68
+ const cmd = getRegistry().get('twitter/hide-reply');
69
+ const page = createPageMock([]);
70
+ await expect(cmd.func(page, {
71
+ url: 'https://x.com.evil.com/alice/status/2040254679301718161',
72
+ })).rejects.toThrow(ArgumentError);
73
+ expect(page.goto).not.toHaveBeenCalled();
74
+ expect(page.evaluate).not.toHaveBeenCalled();
75
+ });
76
+ });
@@ -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: 'like',
@@ -15,21 +17,28 @@ cli({
15
17
  func: async (page, kwargs) => {
16
18
  if (!page)
17
19
  throw new CommandExecutionError('Browser session required for twitter like');
18
- await page.goto(kwargs.url);
20
+ const target = parseTweetUrl(kwargs.url);
21
+ await page.goto(target.url);
19
22
  await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
20
23
  const result = await page.evaluate(`(async () => {
21
24
  try {
22
- // Poll for the tweet to render
25
+ ${buildTwitterArticleScopeSource(target.id)}
26
+ // Poll for the tweet to render. We scope state probes to the
27
+ // article matching the requested status id — on conversation
28
+ // pages multiple articles render and a bare querySelector would
29
+ // grab the first one (silent: like the wrong tweet).
23
30
  let attempts = 0;
24
31
  let likeBtn = null;
25
32
  let unlikeBtn = null;
26
-
33
+ let targetArticle = null;
34
+
27
35
  while (attempts < 20) {
28
- unlikeBtn = document.querySelector('[data-testid="unlike"]');
29
- likeBtn = document.querySelector('[data-testid="like"]');
30
-
31
- if (unlikeBtn || likeBtn) break;
32
-
36
+ targetArticle = findTargetArticle();
37
+ likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
38
+ unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
39
+
40
+ if (likeBtn || unlikeBtn) break;
41
+
33
42
  await new Promise(r => setTimeout(r, 500));
34
43
  attempts++;
35
44
  }
@@ -46,9 +55,10 @@ cli({
46
55
  // Click Like
47
56
  likeBtn.click();
48
57
  await new Promise(r => setTimeout(r, 1000));
49
-
50
- // Verify success by checking if the 'unlike' button appeared
51
- const verifyBtn = document.querySelector('[data-testid="unlike"]');
58
+
59
+ // Verify success by checking if the 'unlike' button reappeared
60
+ const verifyArticle = findTargetArticle() || targetArticle;
61
+ const verifyBtn = verifyArticle?.querySelector('[data-testid="unlike"]');
52
62
  if (verifyBtn) {
53
63
  return { ok: true, message: 'Tweet successfully liked.' };
54
64
  } else {
@@ -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,13 +138,14 @@ 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
145
  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 },
146
+ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
147
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
148
+ { 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
149
  ],
149
150
  columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
150
151
  func: async (page, kwargs) => {
@@ -170,7 +171,7 @@ cli({
170
171
  const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
171
172
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
172
173
  const headers = JSON.stringify({
173
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
174
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
174
175
  'X-Csrf-Token': ct0,
175
176
  'X-Twitter-Auth-Type': 'OAuth2Session',
176
177
  'X-Twitter-Active-User': 'yes',
@@ -208,7 +209,8 @@ cli({
208
209
  break;
209
210
  cursor = nextCursor;
210
211
  }
211
- return allTweets.slice(0, limit);
212
+ const trimmed = allTweets.slice(0, limit);
213
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
212
214
  },
213
215
  });
214
216
  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) => {
@@ -94,7 +94,7 @@ cli({
94
94
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
95
95
 
96
96
  const headers = JSON.stringify({
97
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
97
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
98
98
  'X-Csrf-Token': ct0,
99
99
  'X-Twitter-Auth-Type': 'OAuth2Session',
100
100
  'X-Twitter-Active-User': 'yes',
@@ -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) => {
@@ -101,7 +101,7 @@ cli({
101
101
 
102
102
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
103
103
  const headers = JSON.stringify({
104
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
104
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
105
105
  'X-Csrf-Token': ct0,
106
106
  'X-Twitter-Auth-Type': 'OAuth2Session',
107
107
  'X-Twitter-Active-User': 'yes',
@@ -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
 
@@ -113,8 +113,9 @@ cli({
113
113
  strategy: Strategy.COOKIE,
114
114
  browser: true,
115
115
  args: [
116
- { name: 'listId', positional: true, type: 'string', required: true },
116
+ { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
117
117
  { name: 'limit', type: 'int', default: 50 },
118
+ { 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
119
  ],
119
120
  columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
120
121
  func: async (page, kwargs) => {
@@ -155,7 +156,7 @@ cli({
155
156
  return null;
156
157
  }`) || LIST_TWEETS_QUERY_ID;
157
158
  const headers = JSON.stringify({
158
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
159
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
159
160
  'X-Csrf-Token': ct0,
160
161
  'X-Twitter-Auth-Type': 'OAuth2Session',
161
162
  'X-Twitter-Active-User': 'yes',
@@ -181,6 +182,7 @@ cli({
181
182
  break;
182
183
  cursor = nextCursor;
183
184
  }
184
- return allTweets.slice(0, limit);
185
+ const trimmed = allTweets.slice(0, limit);
186
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
185
187
  },
186
188
  });
@@ -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
 
@@ -93,7 +93,7 @@ export const command = cli({
93
93
  strategy: Strategy.COOKIE,
94
94
  browser: true,
95
95
  args: [
96
- { name: 'limit', type: 'int', default: 50 },
96
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
97
97
  ],
98
98
  columns: ['id', 'name', 'members', 'followers', 'mode'],
99
99
  func: async (page, kwargs) => {
@@ -130,7 +130,7 @@ export const command = cli({
130
130
  return null;
131
131
  }`) || LISTS_QUERY_ID;
132
132
  const headers = JSON.stringify({
133
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
133
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
134
134
  'X-Csrf-Token': ct0,
135
135
  'X-Twitter-Auth-Type': 'OAuth2Session',
136
136
  'X-Twitter-Active-User': 'yes',
@@ -4,12 +4,12 @@ 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
11
  args: [
12
- { name: 'limit', type: 'int', default: 20 },
12
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
13
13
  ],
14
14
  columns: ['id', 'action', 'author', 'text', 'url'],
15
15
  func: async (page, kwargs) => {
@@ -1,17 +1,18 @@
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,
13
14
  args: [
14
- { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
15
+ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
15
16
  ],
16
17
  columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
17
18
  func: async (page, kwargs) => {
@@ -38,7 +39,7 @@ cli({
38
39
  const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
39
40
  if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
40
41
 
41
- const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
42
+ const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
42
43
  const headers = {
43
44
  'Authorization': 'Bearer ' + decodeURIComponent(bearer),
44
45
  'X-Csrf-Token': ct0,
@@ -0,0 +1,167 @@
1
+ import * as fs from 'node:fs';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
5
+ import {
6
+ COMPOSER_FILE_INPUT_SELECTOR,
7
+ attachComposerImage,
8
+ downloadRemoteImage,
9
+ resolveImagePath,
10
+ } from './utils.js';
11
+
12
+ function buildQuoteComposerUrl(url) {
13
+ // Twitter/X quote-tweet compose URL: the `url` param attaches the source
14
+ // tweet as a quoted card. Validating tweet-id shape early surfaces obvious
15
+ // typos before any browser interaction.
16
+ const parsed = parseTweetUrl(url);
17
+ return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
18
+ }
19
+
20
+ async function submitQuote(page, text, tweetId) {
21
+ return page.evaluate(`(async () => {
22
+ try {
23
+ ${buildTwitterArticleScopeSource(tweetId)}
24
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
25
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
26
+ const box = boxes.find(visible) || boxes[0];
27
+ if (!box) {
28
+ return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
29
+ }
30
+
31
+ box.focus();
32
+ const textToInsert = ${JSON.stringify(text)};
33
+ // execCommand('insertText') is more reliable with Twitter's Draft.js editor.
34
+ if (!document.execCommand('insertText', false, textToInsert)) {
35
+ // Fallback to paste event if execCommand fails.
36
+ const dataTransfer = new DataTransfer();
37
+ dataTransfer.setData('text/plain', textToInsert);
38
+ box.dispatchEvent(new ClipboardEvent('paste', {
39
+ clipboardData: dataTransfer,
40
+ bubbles: true,
41
+ cancelable: true,
42
+ }));
43
+ }
44
+
45
+ await new Promise(r => setTimeout(r, 1000));
46
+
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).
53
+ let cardAttempts = 0;
54
+ let hasQuoteCard = false;
55
+ while (cardAttempts < 20) {
56
+ hasQuoteCard = __twHasLinkToTarget(document);
57
+ if (hasQuoteCard) break;
58
+ await new Promise(r => setTimeout(r, 250));
59
+ cardAttempts++;
60
+ }
61
+ if (!hasQuoteCard) {
62
+ return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
63
+ }
64
+
65
+ const buttons = Array.from(
66
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
67
+ );
68
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
69
+ if (!btn) {
70
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
71
+ }
72
+
73
+ btn.click();
74
+
75
+ const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
76
+ const expectedText = normalize(textToInsert);
77
+ for (let i = 0; i < 30; i++) {
78
+ await new Promise(r => setTimeout(r, 500));
79
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
80
+ .filter((el) => visible(el));
81
+ const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
82
+ if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
83
+ const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
84
+ if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
85
+
86
+ const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
87
+ const composerStillHasText = visibleBoxes.some((box) =>
88
+ normalize(box.innerText || box.textContent || '').includes(expectedText)
89
+ );
90
+ if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
91
+ }
92
+ return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
93
+ } catch (e) {
94
+ return { ok: false, message: e.toString() };
95
+ }
96
+ })()`);
97
+ }
98
+
99
+ cli({
100
+ site: 'twitter',
101
+ name: 'quote',
102
+ access: 'write',
103
+ description: 'Quote-tweet a specific tweet with your own text, optionally with a local or remote image',
104
+ domain: 'x.com',
105
+ strategy: Strategy.UI,
106
+ browser: true,
107
+ args: [
108
+ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
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
+ ],
113
+ columns: ['status', 'message', 'text'],
114
+ func: async (page, kwargs) => {
115
+ if (!page)
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
+ }
120
+
121
+ // Validate URL (typed ArgumentError on malformed/off-domain inputs)
122
+ // before any browser interaction or remote image download.
123
+ const target = parseTweetUrl(kwargs.url);
124
+
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
+ }
161
+ }
162
+ }
163
+ });
164
+
165
+ export const __test__ = {
166
+ buildQuoteComposerUrl,
167
+ };