@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,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './bookmark.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter bookmark command', () => {
8
+ it('navigates to the tweet URL and reports success when the bookmark script confirms', async () => {
9
+ const cmd = getRegistry().get('twitter/bookmark');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Tweet successfully bookmarked.' },
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 probe: when already bookmarked ([data-testid="removeBookmark"] present),
22
+ // the script returns ok:true with an "already bookmarked" message.
23
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"removeBookmark\"]')");
24
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"bookmark\"]')");
25
+ expect(script).toContain('bookmarkBtn.click()');
26
+ // Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
27
+ // critical here because conversation pages render multiple
28
+ // bookmark/removeBookmark buttons and a bare querySelector would
29
+ // silently bookmark a different tweet.
30
+ expect(script).toContain('__twHasLinkToTarget');
31
+ expect(script).toContain('__twGetStatusIdFromHref');
32
+ expect(script).toContain("document.querySelectorAll('article')");
33
+ expect(result).toEqual([
34
+ { status: 'success', message: 'Tweet successfully bookmarked.' },
35
+ ]);
36
+ });
37
+
38
+ it('returns a failed row without re-waiting when the bookmark script reports a UI mismatch', async () => {
39
+ const cmd = getRegistry().get('twitter/bookmark');
40
+ const page = createPageMock([
41
+ {
42
+ ok: false,
43
+ message: 'Could not find Bookmark button on the requested tweet. Are you logged in?',
44
+ },
45
+ ]);
46
+ const result = await cmd.func(page, {
47
+ url: 'https://x.com/alice/status/2040254679301718161',
48
+ });
49
+ expect(result).toEqual([
50
+ {
51
+ status: 'failed',
52
+ message: 'Could not find Bookmark button on the requested tweet. Are you logged in?',
53
+ },
54
+ ]);
55
+ expect(page.wait).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it('throws CommandExecutionError when no page is provided', async () => {
59
+ const cmd = getRegistry().get('twitter/bookmark');
60
+ await expect(cmd.func(undefined, {
61
+ url: 'https://x.com/alice/status/2040254679301718161',
62
+ })).rejects.toThrow(CommandExecutionError);
63
+ });
64
+
65
+ it('rejects invalid tweet URLs before navigation', async () => {
66
+ const cmd = getRegistry().get('twitter/bookmark');
67
+ const page = createPageMock([]);
68
+ await expect(cmd.func(page, {
69
+ url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
70
+ })).rejects.toThrow(ArgumentError);
71
+ expect(page.goto).not.toHaveBeenCalled();
72
+ expect(page.evaluate).not.toHaveBeenCalled();
73
+ });
74
+ });
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
3
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4
4
  const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
5
5
  const FEATURES = {
6
6
  rweb_video_screen_enabled: false,
@@ -101,21 +101,20 @@ cli({
101
101
  site: 'twitter',
102
102
  name: 'bookmarks',
103
103
  access: 'read',
104
- description: 'Fetch Twitter/X bookmarks',
104
+ description: 'Fetch your Twitter/X bookmarks (the logged-in user\'s saved tweets, newest first)',
105
105
  domain: 'x.com',
106
106
  strategy: Strategy.COOKIE,
107
107
  browser: true,
108
+ browserSession: { reuse: 'site' },
108
109
  args: [
109
- { name: 'limit', type: 'int', default: 20 },
110
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
111
+ { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks 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.' },
110
112
  ],
111
113
  columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
112
114
  func: async (page, kwargs) => {
113
115
  const limit = kwargs.limit || 20;
114
- await page.goto('https://x.com');
115
- await page.wait(3);
116
- const ct0 = await page.evaluate(`() => {
117
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
118
- }`);
116
+ const cookies = await page.getCookies({ url: 'https://x.com' });
117
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
119
118
  if (!ct0)
120
119
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
121
120
  const queryId = await page.evaluate(`async () => {
@@ -143,7 +142,7 @@ cli({
143
142
  return null;
144
143
  }`) || BOOKMARKS_QUERY_ID;
145
144
  const headers = JSON.stringify({
146
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
145
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
147
146
  'X-Csrf-Token': ct0,
148
147
  'X-Twitter-Auth-Type': 'OAuth2Session',
149
148
  'X-Twitter-Active-User': 'yes',
@@ -169,6 +168,7 @@ cli({
169
168
  break;
170
169
  cursor = nextCursor;
171
170
  }
172
- return allTweets.slice(0, limit);
171
+ const trimmed = allTweets.slice(0, limit);
172
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
173
173
  },
174
174
  });
@@ -1,33 +1,13 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CommandExecutionError } from '@jackwener/opencli/errors';
3
- function extractTweetId(url) {
4
- let pathname = '';
5
- try {
6
- pathname = new URL(url).pathname;
7
- }
8
- catch {
9
- throw new Error(`Invalid tweet URL: ${url}`);
10
- }
11
- const match = pathname.match(/\/status\/(\d+)/);
12
- if (!match?.[1]) {
13
- throw new Error(`Could not extract tweet ID from URL: ${url}`);
14
- }
15
- return match[1];
16
- }
3
+ import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
4
+
17
5
  function buildDeleteScript(tweetId) {
18
6
  return `(async () => {
19
7
  try {
20
8
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
21
- const tweetId = ${JSON.stringify(tweetId)};
22
- const targetArticle = Array.from(document.querySelectorAll('article')).find((article) =>
23
- Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
24
- try {
25
- return new URL(link.href, window.location.origin).pathname.includes('/status/' + tweetId);
26
- } catch {
27
- return false;
28
- }
29
- })
30
- );
9
+ ${buildTwitterArticleScopeSource(tweetId)}
10
+ const targetArticle = findTargetArticle();
31
11
 
32
12
  if (!targetArticle) {
33
13
  return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
@@ -82,17 +62,14 @@ cli({
82
62
  func: async (page, kwargs) => {
83
63
  if (!page)
84
64
  throw new CommandExecutionError('Browser session required for twitter delete');
85
- let tweetId = '';
86
- try {
87
- tweetId = extractTweetId(kwargs.url);
88
- }
89
- catch (error) {
90
- const message = error instanceof Error ? error.message : String(error);
91
- throw new CommandExecutionError(message);
92
- }
93
- await page.goto(kwargs.url);
65
+ // parseTweetUrl throws ArgumentError on malformed/off-domain inputs —
66
+ // this replaces the ad-hoc local extractTweetId which only checked
67
+ // the path shape and accepted any host (silent: would try to act on
68
+ // attacker-controlled redirect URLs).
69
+ const target = parseTweetUrl(kwargs.url);
70
+ await page.goto(target.url);
94
71
  await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
95
- const result = await page.evaluate(buildDeleteScript(tweetId));
72
+ const result = await page.evaluate(buildDeleteScript(target.id));
96
73
  if (result.ok) {
97
74
  // Wait for the deletion request to be processed
98
75
  await page.wait(2);
@@ -105,5 +82,4 @@ cli({
105
82
  });
106
83
  export const __test__ = {
107
84
  buildDeleteScript,
108
- extractTweetId,
109
85
  };
@@ -1,13 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { getRegistry } from '@jackwener/opencli/registry';
4
- import { __test__ } from './delete.js';
5
4
  import './delete.js';
6
5
  describe('twitter delete command', () => {
7
- it('extracts tweet ids from both user and i/status URLs', () => {
8
- expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
9
- expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
10
- });
11
6
  it('targets the matched tweet article instead of the first More button on the page', async () => {
12
7
  const cmd = getRegistry().get('twitter/delete');
13
8
  expect(cmd?.func).toBeTypeOf('function');
@@ -23,9 +18,17 @@ describe('twitter delete command', () => {
23
18
  expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
24
19
  expect(page.wait).toHaveBeenNthCalledWith(2, 2);
25
20
  const script = page.evaluate.mock.calls[0][0];
21
+ // Article-scoping must come from the shared helper (not an inline
22
+ // `pathname.includes('/status/' + tweetId)` substring match — see
23
+ // codex-mini0 #1400 catch where `/status/123` would match
24
+ // `/status/1234567`). The helper emits `__twHasLinkToTarget` and
25
+ // `__twGetStatusIdFromHref` plus the canonical anchored regex.
26
+ expect(script).toContain('__twHasLinkToTarget');
27
+ expect(script).toContain('__twGetStatusIdFromHref');
26
28
  expect(script).toContain("document.querySelectorAll('article')");
27
- expect(script).toContain("'/status/' + tweetId");
28
29
  expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
30
+ // Substring match must NOT appear — exact-id match only.
31
+ expect(script).not.toContain("'/status/' + tweetId");
29
32
  expect(result).toEqual([
30
33
  {
31
34
  status: 'success',
@@ -55,7 +58,7 @@ describe('twitter delete command', () => {
55
58
  ]);
56
59
  expect(page.wait).toHaveBeenCalledTimes(1);
57
60
  });
58
- it('normalizes invalid tweet URLs into CommandExecutionError', async () => {
61
+ it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
59
62
  const cmd = getRegistry().get('twitter/delete');
60
63
  expect(cmd?.func).toBeTypeOf('function');
61
64
  const page = {
@@ -63,11 +66,20 @@ describe('twitter delete command', () => {
63
66
  wait: vi.fn(),
64
67
  evaluate: vi.fn(),
65
68
  };
69
+ // parseTweetUrl bubbles ArgumentError directly (no CommandExecutionError
70
+ // wrapping); replaces the previous local extractTweetId path that hid
71
+ // typed-input failures behind a generic CliError.
66
72
  await expect(cmd.func(page, {
67
73
  url: 'https://x.com/alice/home',
68
- })).rejects.toThrow(CommandExecutionError);
74
+ })).rejects.toThrow(ArgumentError);
69
75
  expect(page.goto).not.toHaveBeenCalled();
70
76
  expect(page.wait).not.toHaveBeenCalled();
71
77
  expect(page.evaluate).not.toHaveBeenCalled();
72
78
  });
79
+ it('throws CommandExecutionError when no page is provided', async () => {
80
+ const cmd = getRegistry().get('twitter/delete');
81
+ await expect(cmd.func(undefined, {
82
+ url: 'https://x.com/alice/status/2040254679301718161',
83
+ })).rejects.toThrow(CommandExecutionError);
84
+ });
73
85
  });
@@ -12,14 +12,15 @@ cli({
12
12
  site: 'twitter',
13
13
  name: 'download',
14
14
  access: 'read',
15
- description: '下载 Twitter/X 媒体(图片和视频)',
15
+ description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
16
16
  domain: 'x.com',
17
17
  strategy: Strategy.COOKIE,
18
+ browserSession: { reuse: 'site' },
18
19
  args: [
19
- { name: 'username', positional: true, help: 'Twitter username (downloads from media tab)' },
20
- { name: 'tweet-url', help: 'Single tweet URL to download' },
21
- { name: 'limit', type: 'int', default: 10, help: 'Number of tweets to scan' },
22
- { name: 'output', default: './twitter-downloads', help: 'Output directory' },
20
+ { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
21
+ { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
22
+ { name: 'limit', type: 'int', default: 10, help: 'Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used.' },
23
+ { name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
23
24
  ],
24
25
  columns: ['index', 'type', 'status', 'size'],
25
26
  func: async (page, kwargs) => {
@@ -79,13 +79,20 @@ cli({
79
79
  site: 'twitter',
80
80
  name: 'followers',
81
81
  access: 'read',
82
- description: 'Get accounts following a Twitter/X user',
82
+ description: 'Get accounts following a Twitter/X user (defaults to the logged-in user when no user is given)',
83
83
  domain: 'x.com',
84
84
  strategy: Strategy.UI,
85
85
  browser: true,
86
+ browserSession: { reuse: 'site' },
86
87
  args: [
87
- { name: 'user', positional: true, type: 'string', required: false },
88
- { name: 'limit', type: 'int', default: 50 },
88
+ {
89
+ name: 'user',
90
+ positional: true,
91
+ type: 'string',
92
+ required: false,
93
+ help: 'Twitter/X handle (with or without @). Omit to fetch followers of the currently logged-in account.',
94
+ },
95
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of follower rows to return (default 50). Must be a positive integer.' },
89
96
  ],
90
97
  // `followers` (count) is NOT exposed: the SPA followers-list view does not
91
98
  // render it. Use `twitter profile <user>` for per-user follower counts.
@@ -1,8 +1,8 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
4
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
4
5
 
5
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
6
6
  const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
8
 
@@ -135,13 +135,20 @@ cli({
135
135
  site: 'twitter',
136
136
  name: 'following',
137
137
  access: 'read',
138
- description: 'Get accounts a Twitter/X user is following',
138
+ description: 'Get accounts a Twitter/X user is following (defaults to the logged-in user when no user is given)',
139
139
  domain: 'x.com',
140
140
  strategy: Strategy.COOKIE,
141
141
  browser: true,
142
+ browserSession: { reuse: 'site' },
142
143
  args: [
143
- { name: 'user', positional: true, type: 'string', required: false },
144
- { name: 'limit', type: 'int', default: 50 },
144
+ {
145
+ name: 'user',
146
+ positional: true,
147
+ type: 'string',
148
+ required: false,
149
+ help: 'Twitter/X handle (with or without @). Omit to fetch the accounts the currently logged-in user follows.',
150
+ },
151
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of following rows to return (default 50). Must be a positive integer.' },
145
152
  ],
146
153
  columns: ['screen_name', 'name', 'bio', 'followers'],
147
154
  func: async (page, kwargs) => {
@@ -151,12 +158,8 @@ cli({
151
158
  }
152
159
  let targetUser = normalizeScreenName(kwargs.user);
153
160
 
154
- await page.goto('https://x.com');
155
- await page.wait(3);
156
-
157
- const ct0 = await page.evaluate(`() => {
158
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
159
- }`);
161
+ const cookies = await page.getCookies({ url: 'https://x.com' });
162
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
160
163
  if (!ct0)
161
164
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
162
165
 
@@ -176,7 +179,7 @@ cli({
176
179
  const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
177
180
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
178
181
  const headers = JSON.stringify({
179
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
182
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
180
183
  'X-Csrf-Token': ct0,
181
184
  'X-Twitter-Auth-Type': 'OAuth2Session',
182
185
  'X-Twitter-Active-User': 'yes',
@@ -205,8 +205,8 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
205
205
  const page = {
206
206
  goto: vi.fn().mockResolvedValue(undefined),
207
207
  wait: vi.fn().mockResolvedValue(undefined),
208
+ getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
208
209
  evaluate: vi.fn(async (script) => {
209
- if (script.includes('document.cookie')) return ct0;
210
210
  if (script.includes('operationName')) return null;
211
211
  if (script.includes('/UserByScreenName')) return userLookup;
212
212
  if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
@@ -228,6 +228,7 @@ describe('twitter following command', () => {
228
228
  const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
229
229
 
230
230
  expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
231
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
231
232
  const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
232
233
  expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
233
234
  expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
@@ -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: 'hide-reply',
@@ -15,28 +17,45 @@ cli({
15
17
  func: async (page, kwargs) => {
16
18
  if (!page)
17
19
  throw new CommandExecutionError('Browser session required for twitter hide-reply');
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"]' });
20
23
  const result = await page.evaluate(`(async () => {
21
24
  try {
25
+ ${buildTwitterArticleScopeSource(target.id)}
26
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
27
+ // Locate the article matching the requested status id, then find
28
+ // its More menu. Without article scoping we'd grab whatever the
29
+ // first "More" button on the page is — usually the parent tweet
30
+ // (silent: hide the wrong reply, or fail silently if the parent
31
+ // is not a reply you authored).
22
32
  let attempts = 0;
33
+ let targetArticle = null;
23
34
  let moreMenu = null;
24
35
 
25
36
  while (attempts < 20) {
26
- moreMenu = document.querySelector('[aria-label="More"]');
27
- if (moreMenu) break;
37
+ targetArticle = findTargetArticle();
38
+ if (targetArticle) {
39
+ const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
40
+ moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
41
+ if (moreMenu) break;
42
+ }
28
43
  await new Promise(r => setTimeout(r, 500));
29
44
  attempts++;
30
45
  }
31
46
 
47
+ if (!targetArticle) {
48
+ return { ok: false, message: 'Could not find the requested reply article on this page.' };
49
+ }
32
50
  if (!moreMenu) {
33
- return { ok: false, message: 'Could not find the "More" menu on this tweet. Are you logged in?' };
51
+ return { ok: false, message: 'Could not find the "More" menu on the requested reply. Are you logged in?' };
34
52
  }
35
53
 
36
54
  moreMenu.click();
37
55
  await new Promise(r => setTimeout(r, 1000));
38
56
 
39
- // Look for the "Hide reply" menu item
57
+ // Look for the "Hide reply" menu item. Menu items render at the
58
+ // document root, not inside the article — scope is the open menu.
40
59
  const items = document.querySelectorAll('[role="menuitem"]');
41
60
  let hideItem = null;
42
61
  for (const item of items) {
@@ -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 {