@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.
- package/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- 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
|
+
});
|
package/clis/twitter/bookmark.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
removeBtn =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
173
|
+
const trimmed = allTweets.slice(0, limit);
|
|
174
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
173
175
|
},
|
|
174
176
|
});
|
package/clis/twitter/delete.js
CHANGED
|
@@ -1,33 +1,13 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
22
|
-
const targetArticle =
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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('
|
|
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(
|
|
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
|
});
|
package/clis/twitter/download.js
CHANGED
|
@@ -12,14 +12,14 @@ cli({
|
|
|
12
12
|
site: 'twitter',
|
|
13
13
|
name: 'download',
|
|
14
14
|
access: 'read',
|
|
15
|
-
description: '
|
|
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 (
|
|
20
|
-
{ name: 'tweet-url', help: 'Single tweet URL to download' },
|
|
21
|
-
{ name: 'limit', type: 'int', default: 10, help: '
|
|
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
|
-
{
|
|
88
|
-
|
|
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
|
-
{
|
|
144
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
if (
|
|
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
|
|
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) {
|