@jackwener/opencli 1.7.18 → 1.7.19
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/README.md +7 -8
- package/README.zh-CN.md +7 -8
- package/cli-manifest.json +305 -9
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- package/clis/rednote/search.js +6 -2
- package/clis/twitter/bookmark-folder.js +3 -1
- package/clis/twitter/bookmarks.js +3 -1
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +23 -2
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/search.js +168 -13
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +123 -86
- package/dist/src/cli.test.js +33 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +15 -16
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/help.d.ts +11 -0
- package/dist/src/help.js +46 -5
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- package/package.json +5 -4
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'reddit',
|
|
6
|
+
name: 'whoami',
|
|
7
|
+
access: 'read',
|
|
8
|
+
description: 'Show the currently logged-in Reddit user',
|
|
9
|
+
domain: 'reddit.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
siteSession: 'persistent',
|
|
13
|
+
args: [],
|
|
14
|
+
columns: ['field', 'value'],
|
|
15
|
+
func: async (page) => {
|
|
16
|
+
await page.goto('https://www.reddit.com');
|
|
17
|
+
// Probe identity via /api/me.json. Reddit returns 200 with an empty
|
|
18
|
+
// body for stale anonymous sessions, so 401/403 alone is not a
|
|
19
|
+
// sufficient logged-out signal — we also verify `data.name` exists
|
|
20
|
+
// (two-pronged auth detection from PR #1428).
|
|
21
|
+
//
|
|
22
|
+
// Intermediate object keys deliberately avoid `field` / `value` to
|
|
23
|
+
// sidestep the silent-column-drop audit (columns are ['field',
|
|
24
|
+
// 'value']) — see PR #1329 sediment "中间解析对象 key 不能跟 columns
|
|
25
|
+
// 任一项重叠".
|
|
26
|
+
const result = await page.evaluate(`(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
|
|
29
|
+
if (res.status === 401 || res.status === 403) {
|
|
30
|
+
return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + res.status };
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
return { kind: 'http', httpStatus: res.status, where: '/api/me.json' };
|
|
34
|
+
}
|
|
35
|
+
const d = await res.json();
|
|
36
|
+
const me = d?.data;
|
|
37
|
+
if (!me?.name) {
|
|
38
|
+
return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
|
|
39
|
+
}
|
|
40
|
+
return { kind: 'ok', identity: me };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { kind: 'exception', detail: String(e && e.message || e) };
|
|
43
|
+
}
|
|
44
|
+
})()`);
|
|
45
|
+
|
|
46
|
+
if (result?.kind === 'auth') {
|
|
47
|
+
throw new AuthRequiredError('reddit.com', result.detail);
|
|
48
|
+
}
|
|
49
|
+
if (result?.kind === 'http') {
|
|
50
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
|
51
|
+
}
|
|
52
|
+
if (result?.kind === 'exception') {
|
|
53
|
+
throw new CommandExecutionError(`whoami failed: ${result.detail}`);
|
|
54
|
+
}
|
|
55
|
+
if (result?.kind !== 'ok') {
|
|
56
|
+
throw new CommandExecutionError(`Unexpected result from reddit whoami: ${JSON.stringify(result)}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const u = result.identity;
|
|
60
|
+
const created = u.created_utc
|
|
61
|
+
? new Date(u.created_utc * 1000).toISOString().split('T')[0]
|
|
62
|
+
: null;
|
|
63
|
+
const linkKarma = typeof u.link_karma === 'number' ? u.link_karma : null;
|
|
64
|
+
const commentKarma = typeof u.comment_karma === 'number' ? u.comment_karma : null;
|
|
65
|
+
const totalKarma = typeof u.total_karma === 'number'
|
|
66
|
+
? u.total_karma
|
|
67
|
+
: (linkKarma != null && commentKarma != null ? linkKarma + commentKarma : null);
|
|
68
|
+
const inboxCount = typeof u.inbox_count === 'number' ? u.inbox_count : null;
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
{ field: 'Username', value: 'u/' + u.name },
|
|
72
|
+
{ field: 'ID', value: u.id ? 't2_' + u.id : null },
|
|
73
|
+
{ field: 'Post Karma', value: linkKarma != null ? String(linkKarma) : null },
|
|
74
|
+
{ field: 'Comment Karma', value: commentKarma != null ? String(commentKarma) : null },
|
|
75
|
+
{ field: 'Total Karma', value: totalKarma != null ? String(totalKarma) : null },
|
|
76
|
+
{ field: 'Account Created', value: created },
|
|
77
|
+
{ field: 'Gold', value: u.is_gold ? 'Yes' : 'No' },
|
|
78
|
+
{ field: 'Mod', value: u.is_mod ? 'Yes' : 'No' },
|
|
79
|
+
{ field: 'Verified Email', value: u.has_verified_email ? 'Yes' : 'No' },
|
|
80
|
+
{ field: 'Has Mail', value: u.has_mail ? 'Yes' : 'No' },
|
|
81
|
+
{ field: 'Inbox Count', value: inboxCount != null ? String(inboxCount) : null },
|
|
82
|
+
];
|
|
83
|
+
},
|
|
84
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './whoami.js';
|
|
5
|
+
|
|
6
|
+
function makePage(result) {
|
|
7
|
+
return {
|
|
8
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('reddit whoami command', () => {
|
|
14
|
+
const command = getRegistry().get('reddit/whoami');
|
|
15
|
+
|
|
16
|
+
it('registers with the expected shape', () => {
|
|
17
|
+
expect(command).toBeDefined();
|
|
18
|
+
expect(command.access).toBe('read');
|
|
19
|
+
expect(command.browser).toBe(true);
|
|
20
|
+
expect(command.columns).toEqual(['field', 'value']);
|
|
21
|
+
expect(command.args).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('throws AuthRequiredError on 401/403 from /api/me.json', async () => {
|
|
25
|
+
const page = makePage({ kind: 'auth', detail: 'Reddit /api/me.json returned HTTP 401' });
|
|
26
|
+
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
27
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws AuthRequiredError on 200 with missing data.name (stale anon session)', async () => {
|
|
31
|
+
const page = makePage({ kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' });
|
|
32
|
+
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('throws CommandExecutionError on HTTP / exception failure modes', async () => {
|
|
36
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: '/api/me.json' }), {}))
|
|
37
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
38
|
+
await expect(command.func(makePage({ kind: 'exception', detail: 'bad json' }), {}))
|
|
39
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('maps a full identity payload into the field/value rows', async () => {
|
|
43
|
+
const identity = {
|
|
44
|
+
name: 'alice',
|
|
45
|
+
id: 'abcdef',
|
|
46
|
+
link_karma: 1234,
|
|
47
|
+
comment_karma: 5678,
|
|
48
|
+
total_karma: 6912,
|
|
49
|
+
created_utc: 1577836800, // 2020-01-01
|
|
50
|
+
is_gold: true,
|
|
51
|
+
is_mod: false,
|
|
52
|
+
has_verified_email: true,
|
|
53
|
+
has_mail: false,
|
|
54
|
+
inbox_count: 0,
|
|
55
|
+
};
|
|
56
|
+
const page = makePage({ kind: 'ok', identity });
|
|
57
|
+
const rows = await command.func(page, {});
|
|
58
|
+
|
|
59
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
60
|
+
expect(byField.Username).toBe('u/alice');
|
|
61
|
+
expect(byField.ID).toBe('t2_abcdef');
|
|
62
|
+
expect(byField['Post Karma']).toBe('1234');
|
|
63
|
+
expect(byField['Comment Karma']).toBe('5678');
|
|
64
|
+
expect(byField['Total Karma']).toBe('6912');
|
|
65
|
+
expect(byField['Account Created']).toBe('2020-01-01');
|
|
66
|
+
expect(byField.Gold).toBe('Yes');
|
|
67
|
+
expect(byField.Mod).toBe('No');
|
|
68
|
+
expect(byField['Verified Email']).toBe('Yes');
|
|
69
|
+
expect(byField['Has Mail']).toBe('No');
|
|
70
|
+
expect(byField['Inbox Count']).toBe('0');
|
|
71
|
+
|
|
72
|
+
// Row shape must match the declared columns exactly so the
|
|
73
|
+
// silent-column-drop audit can't be triggered.
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
expect(Object.keys(row).sort()).toEqual(['field', 'value']);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('falls back to null for missing numeric karma fields rather than 0 sentinels', async () => {
|
|
80
|
+
const identity = {
|
|
81
|
+
name: 'bob',
|
|
82
|
+
id: 'xyz',
|
|
83
|
+
created_utc: null,
|
|
84
|
+
is_gold: false,
|
|
85
|
+
is_mod: false,
|
|
86
|
+
has_verified_email: false,
|
|
87
|
+
has_mail: false,
|
|
88
|
+
};
|
|
89
|
+
const page = makePage({ kind: 'ok', identity });
|
|
90
|
+
const rows = await command.func(page, {});
|
|
91
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
92
|
+
expect(byField['Post Karma']).toBeNull();
|
|
93
|
+
expect(byField['Comment Karma']).toBeNull();
|
|
94
|
+
expect(byField['Total Karma']).toBeNull();
|
|
95
|
+
expect(byField['Account Created']).toBeNull();
|
|
96
|
+
expect(byField['Inbox Count']).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not throw on `data.name` present even if optional booleans are missing', async () => {
|
|
100
|
+
const identity = { name: 'carol', id: 'i1' };
|
|
101
|
+
const page = makePage({ kind: 'ok', identity });
|
|
102
|
+
const rows = await command.func(page, {});
|
|
103
|
+
expect(rows[0]).toEqual({ field: 'Username', value: 'u/carol' });
|
|
104
|
+
});
|
|
105
|
+
});
|
package/clis/rednote/search.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
9
|
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
10
|
-
import { buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
|
|
10
|
+
import { buildScrollUntilJs, buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
|
|
11
11
|
|
|
12
12
|
function parseLimit(raw) {
|
|
13
13
|
const parsed = Number(raw);
|
|
@@ -82,7 +82,11 @@ cli({
|
|
|
82
82
|
if (waitResult === 'login_wall') {
|
|
83
83
|
throw new AuthRequiredError('www.rednote.com', 'Rednote search results are blocked behind a login wall');
|
|
84
84
|
}
|
|
85
|
-
|
|
85
|
+
// Scroll until enough rows are rendered or the lazy-load plateaus.
|
|
86
|
+
// Same fix as xiaohongshu/search (#1471): the previous fixed
|
|
87
|
+
// `autoScroll({ times: 2 })` capped extraction at ~13 notes regardless
|
|
88
|
+
// of `--limit`.
|
|
89
|
+
await page.evaluate(buildScrollUntilJs(limit));
|
|
86
90
|
const payload = await page.evaluate(buildSearchExtractJs('www.rednote.com'));
|
|
87
91
|
const data = Array.isArray(payload) ? payload : [];
|
|
88
92
|
return data
|
|
@@ -11,6 +11,7 @@ import { resolveTwitterQueryId } from './shared.js';
|
|
|
11
11
|
const OPERATION_NAME = 'BookmarkFolderTimeline';
|
|
12
12
|
const FALLBACK_QUERY_ID = '13H7EUATwethsj_jZ6QQAQ';
|
|
13
13
|
const FOLDER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
14
15
|
|
|
15
16
|
const FEATURES = {
|
|
16
17
|
rweb_video_screen_enabled: false,
|
|
@@ -158,7 +159,8 @@ cli({
|
|
|
158
159
|
const allTweets = [];
|
|
159
160
|
const seen = new Set();
|
|
160
161
|
let cursor = null;
|
|
161
|
-
|
|
162
|
+
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
163
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
|
|
162
164
|
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
163
165
|
const apiUrl = buildFolderTimelineUrl(queryId, folderId, fetchCount, cursor);
|
|
164
166
|
const data = await page.evaluate(`async () => {
|
|
@@ -2,6 +2,7 @@ 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
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
5
6
|
const FEATURES = {
|
|
6
7
|
rweb_video_screen_enabled: false,
|
|
7
8
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
@@ -150,7 +151,8 @@ cli({
|
|
|
150
151
|
const allTweets = [];
|
|
151
152
|
const seen = new Set();
|
|
152
153
|
let cursor = null;
|
|
153
|
-
|
|
154
|
+
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
155
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
|
|
154
156
|
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
155
157
|
const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId);
|
|
156
158
|
const data = await page.evaluate(`async () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Extract follower rows from Twitter/X follower-list SPA cells.
|
|
@@ -72,7 +73,7 @@ async function extractFollowersFromDOM(page) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
function normalizeScreenName(value) {
|
|
75
|
-
return
|
|
76
|
+
return normalizeTwitterScreenName(value);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
cli({
|
|
@@ -103,18 +104,27 @@ cli({
|
|
|
103
104
|
throw new ArgumentError('limit must be a positive integer');
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
+
const rawUser = String(kwargs.user ?? '').trim();
|
|
108
|
+
let targetUser = normalizeScreenName(rawUser);
|
|
109
|
+
if (rawUser && !targetUser) {
|
|
110
|
+
throw new ArgumentError('twitter followers user must be a valid Twitter/X handle', 'Example: opencli twitter followers @elonmusk --limit 100');
|
|
111
|
+
}
|
|
107
112
|
if (!targetUser) {
|
|
108
113
|
await page.goto('https://x.com/home');
|
|
109
114
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
110
|
-
|
|
115
|
+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
|
|
116
|
+
// unwrap so the href string is usable downstream.
|
|
117
|
+
const href = unwrapBrowserResult(await page.evaluate(`() => {
|
|
111
118
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
112
119
|
return link ? link.getAttribute('href') : null;
|
|
113
|
-
}`);
|
|
114
|
-
if (!href) {
|
|
120
|
+
}`));
|
|
121
|
+
if (!href || typeof href !== 'string') {
|
|
115
122
|
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
116
123
|
}
|
|
117
124
|
targetUser = normalizeScreenName(href);
|
|
125
|
+
if (!targetUser) {
|
|
126
|
+
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
127
|
+
}
|
|
118
128
|
}
|
|
119
129
|
if (!targetUser) {
|
|
120
130
|
throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
|
|
@@ -173,3 +183,8 @@ cli({
|
|
|
173
183
|
return allFollowers.slice(0, limit);
|
|
174
184
|
}
|
|
175
185
|
});
|
|
186
|
+
|
|
187
|
+
export const __test__ = {
|
|
188
|
+
extractFollowersFromDOM,
|
|
189
|
+
normalizeScreenName,
|
|
190
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { __test__ } from './followers.js';
|
|
5
|
+
|
|
6
|
+
describe('twitter followers command', () => {
|
|
7
|
+
it('normalizes exact profile handles and rejects route-like hrefs', () => {
|
|
8
|
+
expect(__test__.normalizeScreenName('@viewer')).toBe('viewer');
|
|
9
|
+
expect(__test__.normalizeScreenName('/viewer')).toBe('viewer');
|
|
10
|
+
expect(__test__.normalizeScreenName('https://x.com/viewer')).toBe('viewer');
|
|
11
|
+
expect(__test__.normalizeScreenName('/home')).toBe('');
|
|
12
|
+
expect(__test__.normalizeScreenName('/viewer/extra')).toBe('');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects invalid explicit users before navigation', async () => {
|
|
16
|
+
const command = getRegistry().get('twitter/followers');
|
|
17
|
+
const page = {
|
|
18
|
+
goto: vi.fn(),
|
|
19
|
+
wait: vi.fn(),
|
|
20
|
+
evaluate: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
await expect(command.func(page, { user: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
|
|
24
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
25
|
+
expect(page.wait).not.toHaveBeenCalled();
|
|
26
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects non-profile AppTabBar hrefs instead of navigating to route followers', async () => {
|
|
30
|
+
const command = getRegistry().get('twitter/followers');
|
|
31
|
+
const page = {
|
|
32
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
evaluate: vi.fn(async (script) => {
|
|
35
|
+
if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
|
|
36
|
+
throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
41
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
42
|
+
expect(page.goto).not.toHaveBeenCalledWith('https://x.com/home/followers');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
3
|
+
import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
5
|
|
|
6
6
|
const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
|
|
7
7
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
8
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
8
9
|
|
|
9
10
|
const FEATURES = {
|
|
10
11
|
rweb_video_screen_enabled: false,
|
|
@@ -128,7 +129,7 @@ function parseFollowing(data) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
function normalizeScreenName(value) {
|
|
131
|
-
return
|
|
132
|
+
return normalizeTwitterScreenName(value);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
cli({
|
|
@@ -156,7 +157,11 @@ cli({
|
|
|
156
157
|
if (!Number.isInteger(limit) || limit <= 0) {
|
|
157
158
|
throw new ArgumentError('twitter following --limit must be a positive integer', 'Example: opencli twitter following @elonmusk --limit 200');
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
+
const rawUser = String(kwargs.user ?? '').trim();
|
|
161
|
+
let targetUser = normalizeScreenName(rawUser);
|
|
162
|
+
if (rawUser && !targetUser) {
|
|
163
|
+
throw new ArgumentError('twitter following user must be a valid Twitter/X handle', 'Example: opencli twitter following @elonmusk --limit 200');
|
|
164
|
+
}
|
|
160
165
|
|
|
161
166
|
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
162
167
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
@@ -164,13 +169,25 @@ cli({
|
|
|
164
169
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
165
170
|
|
|
166
171
|
if (!targetUser) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
// Force a navigation to the home surface so the AppTabBar sidebar
|
|
173
|
+
// is rendered; the framework pre-nav lands on bare x.com which
|
|
174
|
+
// does not always expose AppTabBar_Profile_Link.
|
|
175
|
+
await page.goto('https://x.com/home');
|
|
176
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
177
|
+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
|
|
178
|
+
// unwrap so the href string is usable downstream.
|
|
179
|
+
// NOTE: the function-literal form `() => ...` silently drops
|
|
180
|
+
// primitive return values through the bridge — only the template
|
|
181
|
+
// string form preserves the `data` field.
|
|
182
|
+
const href = unwrapBrowserResult(await page.evaluate(`() => {
|
|
183
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
184
|
+
return link ? link.getAttribute('href') : null;
|
|
185
|
+
}`));
|
|
186
|
+
if (!href || typeof href !== 'string')
|
|
187
|
+
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
188
|
+
targetUser = normalizeScreenName(href);
|
|
189
|
+
if (!targetUser)
|
|
172
190
|
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
173
|
-
targetUser = normalizeScreenName(href.replace('/', ''));
|
|
174
191
|
}
|
|
175
192
|
if (!targetUser) {
|
|
176
193
|
throw new ArgumentError('twitter following user cannot be empty', 'Example: opencli twitter following @elonmusk --limit 200');
|
|
@@ -178,21 +195,20 @@ cli({
|
|
|
178
195
|
|
|
179
196
|
const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
|
|
180
197
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
181
|
-
const headers =
|
|
198
|
+
const headers = {
|
|
182
199
|
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
183
200
|
'X-Csrf-Token': ct0,
|
|
184
201
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
185
202
|
'X-Twitter-Active-User': 'yes',
|
|
186
|
-
}
|
|
203
|
+
};
|
|
187
204
|
|
|
188
205
|
// Get userId from screen_name
|
|
189
|
-
const userLookup = await page.evaluate(
|
|
190
|
-
const
|
|
191
|
-
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
|
|
206
|
+
const userLookup = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
|
|
207
|
+
const resp = await fetch(url, { headers, credentials: 'include' });
|
|
192
208
|
if (!resp.ok) return { error: resp.status };
|
|
193
209
|
const d = await resp.json();
|
|
194
210
|
return { userId: d.data?.user?.result?.rest_id || null };
|
|
195
|
-
}
|
|
211
|
+
}, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers));
|
|
196
212
|
if (userLookup?.error === 401 || userLookup?.error === 403) {
|
|
197
213
|
throw new AuthRequiredError('x.com', `Twitter user lookup failed (HTTP ${userLookup.error})`);
|
|
198
214
|
}
|
|
@@ -207,14 +223,14 @@ cli({
|
|
|
207
223
|
const seen = new Set();
|
|
208
224
|
let cursor = null;
|
|
209
225
|
|
|
210
|
-
|
|
211
|
-
for (let i = 0; i <
|
|
226
|
+
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
227
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && allUsers.length < limit; i++) {
|
|
212
228
|
const fetchCount = Math.min(50, limit - allUsers.length + 10);
|
|
213
229
|
const apiUrl = buildFollowingUrl(followingQueryId, userId, fetchCount, cursor);
|
|
214
|
-
const data = await page.evaluate(
|
|
215
|
-
const r = await fetch(
|
|
230
|
+
const data = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
|
|
231
|
+
const r = await fetch(url, { headers, credentials: 'include' });
|
|
216
232
|
return r.ok ? await r.json() : { error: r.status };
|
|
217
|
-
}
|
|
233
|
+
}, apiUrl, headers));
|
|
218
234
|
if (data?.error) {
|
|
219
235
|
if (data.error === 401 || data.error === 403)
|
|
220
236
|
throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
|
|
@@ -157,6 +157,8 @@ describe('twitter following helpers', () => {
|
|
|
157
157
|
expect(__test__.normalizeScreenName('@elonmusk')).toBe('elonmusk');
|
|
158
158
|
expect(__test__.normalizeScreenName('/elonmusk')).toBe('elonmusk');
|
|
159
159
|
expect(__test__.normalizeScreenName(' @@alice ')).toBe('alice');
|
|
160
|
+
expect(__test__.normalizeScreenName('/home')).toBe('');
|
|
161
|
+
expect(__test__.normalizeScreenName('/elonmusk/extra')).toBe('');
|
|
160
162
|
});
|
|
161
163
|
});
|
|
162
164
|
|
|
@@ -201,16 +203,28 @@ function followingPayload(users, cursor) {
|
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
205
|
|
|
204
|
-
function
|
|
206
|
+
function bridgeEnvelope(data) {
|
|
207
|
+
return { session: 'site:twitter', data };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' }, envelope = false } = {}) {
|
|
205
211
|
const page = {
|
|
206
212
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
207
213
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
208
214
|
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
|
|
209
|
-
evaluate: vi.fn(async (script) => {
|
|
215
|
+
evaluate: vi.fn(async (script, ...args) => {
|
|
216
|
+
const wrap = (value) => envelope ? bridgeEnvelope(value) : value;
|
|
217
|
+
if (typeof script === 'function') {
|
|
218
|
+
const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
|
|
219
|
+
if (haystack.includes('/UserByScreenName')) return wrap(userLookup);
|
|
220
|
+
if (haystack.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
|
|
221
|
+
if (haystack.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
|
|
222
|
+
throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
|
|
223
|
+
}
|
|
210
224
|
if (script.includes('operationName')) return null;
|
|
211
|
-
if (script.includes('/UserByScreenName')) return userLookup;
|
|
212
|
-
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
|
|
213
|
-
if (script.includes('AppTabBar_Profile_Link')) return '/viewer';
|
|
225
|
+
if (script.includes('/UserByScreenName')) return wrap(userLookup);
|
|
226
|
+
if (script.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
|
|
227
|
+
if (script.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
|
|
214
228
|
throw new Error(`Unexpected evaluate script: ${script.slice(0, 80)}`);
|
|
215
229
|
}),
|
|
216
230
|
};
|
|
@@ -229,12 +243,13 @@ describe('twitter following command', () => {
|
|
|
229
243
|
|
|
230
244
|
expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
|
|
231
245
|
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
232
|
-
const
|
|
246
|
+
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
|
|
247
|
+
const userLookupScript = callText(page.evaluate.mock.calls.find((call) => callText(call).includes('/UserByScreenName')) || []);
|
|
233
248
|
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
|
|
234
249
|
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
|
|
235
|
-
const followingCalls = page.evaluate.mock.calls.filter((
|
|
250
|
+
const followingCalls = page.evaluate.mock.calls.filter((call) => callText(call).includes('/Following'));
|
|
236
251
|
expect(followingCalls).toHaveLength(2);
|
|
237
|
-
expect(decodeURIComponent(followingCalls[1]
|
|
252
|
+
expect(decodeURIComponent(callText(followingCalls[1]))).toContain('"cursor":"cursor-1"');
|
|
238
253
|
});
|
|
239
254
|
|
|
240
255
|
it('rejects invalid limits before navigating', async () => {
|
|
@@ -245,6 +260,29 @@ describe('twitter following command', () => {
|
|
|
245
260
|
expect(page.goto).not.toHaveBeenCalled();
|
|
246
261
|
});
|
|
247
262
|
|
|
263
|
+
it('rejects invalid explicit users before cookies or navigation', async () => {
|
|
264
|
+
const command = getRegistry().get('twitter/following');
|
|
265
|
+
const page = createFollowingPage([]);
|
|
266
|
+
|
|
267
|
+
await expect(command.func(page, { user: 'elonmusk/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
|
|
268
|
+
expect(page.getCookies).not.toHaveBeenCalled();
|
|
269
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
270
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
|
|
274
|
+
const command = getRegistry().get('twitter/following');
|
|
275
|
+
const page = createFollowingPage([]);
|
|
276
|
+
page.evaluate.mockImplementation(async (script, ...args) => {
|
|
277
|
+
const haystack = [typeof script === 'function' ? script.toString() : String(script), ...args.map((arg) => String(arg))].join('\n');
|
|
278
|
+
if (haystack.includes('AppTabBar_Profile_Link')) return '/home';
|
|
279
|
+
throw new Error(`Unexpected evaluate: ${haystack.slice(0, 80)}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
283
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
284
|
+
});
|
|
285
|
+
|
|
248
286
|
it('maps first-page auth failures to AuthRequiredError', async () => {
|
|
249
287
|
const command = getRegistry().get('twitter/following');
|
|
250
288
|
const page = createFollowingPage([{ error: 401 }]);
|
|
@@ -269,6 +307,20 @@ describe('twitter following command', () => {
|
|
|
269
307
|
await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
270
308
|
});
|
|
271
309
|
|
|
310
|
+
it('unwraps Browser Bridge envelopes for user lookup and following payloads', async () => {
|
|
311
|
+
const command = getRegistry().get('twitter/following');
|
|
312
|
+
const page = createFollowingPage([followingPayload(['alice'], null)], { envelope: true });
|
|
313
|
+
|
|
314
|
+
const rows = await command.func(page, { user: 'elonmusk', limit: 10 });
|
|
315
|
+
|
|
316
|
+
expect(rows.map((row) => row.screen_name)).toEqual(['alice']);
|
|
317
|
+
const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
|
|
318
|
+
const followingCall = page.evaluate.mock.calls.find((call) => callText(call).includes('/Following')) || [];
|
|
319
|
+
const followingUrl = String(followingCall[1] || '');
|
|
320
|
+
expect(decodeURIComponent(followingUrl)).toContain('"userId":"42"');
|
|
321
|
+
expect(decodeURIComponent(followingUrl)).not.toContain('[object Object]');
|
|
322
|
+
});
|
|
323
|
+
|
|
272
324
|
it('fails fast when the following timeline is empty', async () => {
|
|
273
325
|
const command = getRegistry().get('twitter/following');
|
|
274
326
|
const page = createFollowingPage([followingPayload([], null)]);
|