@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,150 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { __test__ } from './bookmark-folders.js';
4
+
5
+ const { parseBookmarkFolders, buildUrl } = __test__;
6
+
7
+ describe('twitter bookmark-folders parser', () => {
8
+ it('returns [] for empty payload', () => {
9
+ expect(parseBookmarkFolders({}, new Set())).toEqual([]);
10
+ expect(parseBookmarkFolders({ data: {} }, new Set())).toEqual([]);
11
+ });
12
+
13
+ it('extracts folders from the modern viewer.bookmark_collections_slice envelope', () => {
14
+ const data = {
15
+ data: {
16
+ viewer: {
17
+ bookmark_collections_slice: {
18
+ items: [
19
+ {
20
+ bookmarkCollection: {
21
+ id_str: '1234567890',
22
+ name: 'Reading list',
23
+ bookmarks_count: 42,
24
+ created_at: '2025-09-15T10:00:00.000Z',
25
+ },
26
+ },
27
+ {
28
+ bookmarkCollection: {
29
+ id_str: '9876543210',
30
+ name: 'Recipes',
31
+ bookmarks_count: 7,
32
+ created_at: '2026-01-03T03:14:00.000Z',
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ };
40
+ expect(parseBookmarkFolders(data, new Set())).toEqual([
41
+ { id: '1234567890', name: 'Reading list', items: 42, created_at: '2025-09-15T10:00:00.000Z' },
42
+ { id: '9876543210', name: 'Recipes', items: 7, created_at: '2026-01-03T03:14:00.000Z' },
43
+ ]);
44
+ });
45
+
46
+ it('falls back to legacy viewer_v2 envelope', () => {
47
+ const data = {
48
+ data: {
49
+ viewer_v2: {
50
+ user_results: {
51
+ result: {
52
+ bookmark_collections_slice: {
53
+ items: [{ bookmarkCollection: { id: 'abc', name: 'Old', count: 3 } }],
54
+ },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ };
60
+ expect(parseBookmarkFolders(data, new Set())).toEqual([
61
+ { id: 'abc', name: 'Old', items: 3, created_at: '' },
62
+ ]);
63
+ });
64
+
65
+ it('falls back to flat bookmark_collections_slice envelope', () => {
66
+ const data = {
67
+ data: {
68
+ bookmark_collections_slice: {
69
+ items: [{ id_str: '5', name: 'Flat', bookmarks_count: 1, created_at: '2024-01-01' }],
70
+ },
71
+ },
72
+ };
73
+ expect(parseBookmarkFolders(data, new Set())).toEqual([
74
+ { id: '5', name: 'Flat', items: 1, created_at: '2024-01-01' },
75
+ ]);
76
+ });
77
+
78
+ it('deduplicates folders by id across the seen Set', () => {
79
+ const data = {
80
+ data: {
81
+ viewer: {
82
+ bookmark_collections_slice: {
83
+ items: [
84
+ { bookmarkCollection: { id_str: '1', name: 'A', bookmarks_count: 0 } },
85
+ { bookmarkCollection: { id_str: '1', name: 'A again', bookmarks_count: 0 } },
86
+ { bookmarkCollection: { id_str: '2', name: 'B', bookmarks_count: 0 } },
87
+ ],
88
+ },
89
+ },
90
+ },
91
+ };
92
+ expect(parseBookmarkFolders(data, new Set())).toEqual([
93
+ { id: '1', name: 'A', items: 0, created_at: '' },
94
+ { id: '2', name: 'B', items: 0, created_at: '' },
95
+ ]);
96
+ });
97
+
98
+ it('coerces missing items count to 0', () => {
99
+ const data = {
100
+ data: {
101
+ viewer: {
102
+ bookmark_collections_slice: {
103
+ items: [{ bookmarkCollection: { id: '1', name: 'No count' } }],
104
+ },
105
+ },
106
+ },
107
+ };
108
+ expect(parseBookmarkFolders(data, new Set())[0].items).toBe(0);
109
+ });
110
+
111
+ it('skips entries without an id', () => {
112
+ const data = {
113
+ data: {
114
+ viewer: {
115
+ bookmark_collections_slice: {
116
+ items: [
117
+ { bookmarkCollection: { name: 'Anonymous' } },
118
+ { bookmarkCollection: { id: '1', name: 'OK' } },
119
+ ],
120
+ },
121
+ },
122
+ },
123
+ };
124
+ expect(parseBookmarkFolders(data, new Set())).toEqual([
125
+ { id: '1', name: 'OK', items: 0, created_at: '' },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe('twitter bookmark-folders URL builder', () => {
131
+ it('encodes the empty variables object and includes the queryId in the path', () => {
132
+ const url = buildUrl('queryid123');
133
+ expect(url).toContain('/i/api/graphql/queryid123/bookmarkFoldersSlice');
134
+ expect(url).toContain('variables=' + encodeURIComponent('{}'));
135
+ expect(url).toContain('features=');
136
+ });
137
+ });
138
+
139
+ describe('twitter bookmark-folders command (registry)', () => {
140
+ it('throws AuthRequiredError when ct0 cookie is missing', async () => {
141
+ const command = getRegistry().get('twitter/bookmark-folders');
142
+ expect(command?.func).toBeTypeOf('function');
143
+ const page = {
144
+ goto: vi.fn().mockResolvedValue(undefined),
145
+ wait: vi.fn().mockResolvedValue(undefined),
146
+ evaluate: vi.fn().mockResolvedValue(null), // null cookie → AuthRequired
147
+ };
148
+ await expect(command.func(page, {})).rejects.toThrow(/Not logged into x.com/);
149
+ });
150
+ });
@@ -1,5 +1,7 @@
1
1
  import { CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
4
+
3
5
  cli({
4
6
  site: 'twitter',
5
7
  name: 'bookmark',
@@ -15,22 +17,28 @@ cli({
15
17
  func: async (page, kwargs) => {
16
18
  if (!page)
17
19
  throw new CommandExecutionError('Browser session required for twitter bookmark');
18
- 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
+ // Article-scoped: on conversation pages multiple bookmark/remove
27
+ // buttons render and a bare querySelector would silently bookmark
28
+ // a different tweet (e.g. the parent of the requested reply).
22
29
  let attempts = 0;
23
30
  let bookmarkBtn = null;
24
31
  let removeBtn = null;
32
+ let targetArticle = null;
25
33
 
26
34
  while (attempts < 20) {
27
- // Check if already bookmarked
28
- removeBtn = document.querySelector('[data-testid="removeBookmark"]');
35
+ targetArticle = findTargetArticle();
36
+ removeBtn = targetArticle?.querySelector('[data-testid="removeBookmark"]') || null;
29
37
  if (removeBtn) {
30
38
  return { ok: true, message: 'Tweet is already bookmarked.' };
31
39
  }
32
40
 
33
- bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
41
+ bookmarkBtn = targetArticle?.querySelector('[data-testid="bookmark"]') || null;
34
42
  if (bookmarkBtn) break;
35
43
 
36
44
  await new Promise(r => setTimeout(r, 500));
@@ -38,14 +46,15 @@ cli({
38
46
  }
39
47
 
40
48
  if (!bookmarkBtn) {
41
- return { ok: false, message: 'Could not find Bookmark button. Are you logged in?' };
49
+ return { ok: false, message: 'Could not find Bookmark button on the requested tweet. Are you logged in?' };
42
50
  }
43
51
 
44
52
  bookmarkBtn.click();
45
53
  await new Promise(r => setTimeout(r, 1000));
46
54
 
47
55
  // Verify
48
- const verify = document.querySelector('[data-testid="removeBookmark"]');
56
+ const verifyArticle = findTargetArticle() || targetArticle;
57
+ const verify = verifyArticle?.querySelector('[data-testid="removeBookmark"]');
49
58
  if (verify) {
50
59
  return { ok: true, message: 'Tweet successfully bookmarked.' };
51
60
  } else {
@@ -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,12 +101,13 @@ 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
108
  args: [
109
- { name: 'limit', type: 'int', default: 20 },
109
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
110
+ { 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
111
  ],
111
112
  columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
112
113
  func: async (page, kwargs) => {
@@ -143,7 +144,7 @@ cli({
143
144
  return null;
144
145
  }`) || BOOKMARKS_QUERY_ID;
145
146
  const headers = JSON.stringify({
146
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
147
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
147
148
  'X-Csrf-Token': ct0,
148
149
  'X-Twitter-Auth-Type': 'OAuth2Session',
149
150
  'X-Twitter-Active-User': 'yes',
@@ -169,6 +170,7 @@ cli({
169
170
  break;
170
171
  cursor = nextCursor;
171
172
  }
172
- return allTweets.slice(0, limit);
173
+ const trimmed = allTweets.slice(0, limit);
174
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
173
175
  },
174
176
  });
@@ -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,14 @@ 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
18
  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' },
19
+ { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
20
+ { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
21
+ { 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.' },
22
+ { name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
23
23
  ],
24
24
  columns: ['index', 'type', 'status', 'size'],
25
25
  func: async (page, kwargs) => {
@@ -79,13 +79,19 @@ 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
86
  args: [
87
- { name: 'user', positional: true, type: 'string', required: false },
88
- { name: 'limit', type: 'int', default: 50 },
87
+ {
88
+ name: 'user',
89
+ positional: true,
90
+ type: 'string',
91
+ required: false,
92
+ help: 'Twitter/X handle (with or without @). Omit to fetch followers of the currently logged-in account.',
93
+ },
94
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of follower rows to return (default 50). Must be a positive integer.' },
89
95
  ],
90
96
  // `followers` (count) is NOT exposed: the SPA followers-list view does not
91
97
  // 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,19 @@ 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
142
  args: [
143
- { name: 'user', positional: true, type: 'string', required: false },
144
- { name: 'limit', type: 'int', default: 50 },
143
+ {
144
+ name: 'user',
145
+ positional: true,
146
+ type: 'string',
147
+ required: false,
148
+ help: 'Twitter/X handle (with or without @). Omit to fetch the accounts the currently logged-in user follows.',
149
+ },
150
+ { name: 'limit', type: 'int', default: 50, help: 'Maximum number of following rows to return (default 50). Must be a positive integer.' },
145
151
  ],
146
152
  columns: ['screen_name', 'name', 'bio', 'followers'],
147
153
  func: async (page, kwargs) => {
@@ -176,7 +182,7 @@ cli({
176
182
  const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
177
183
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
178
184
  const headers = JSON.stringify({
179
- 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
185
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
180
186
  'X-Csrf-Token': ct0,
181
187
  'X-Twitter-Auth-Type': 'OAuth2Session',
182
188
  'X-Twitter-Active-User': 'yes',
@@ -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) {