@jackwener/opencli 1.7.18 → 1.7.20
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 +18 -17
- package/README.zh-CN.md +16 -18
- package/cli-manifest.json +311 -186
- 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 +8 -4
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmarks.js +12 -4
- package/clis/twitter/bookmarks.test.js +205 -0
- 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/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +3 -0
- package/dist/src/browser/daemon-client.test.js +20 -0
- 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 +131 -89
- package/dist/src/cli.test.js +34 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +44 -13
- package/dist/src/daemon.test.js +42 -1
- 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/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/external-clis.yaml +12 -3
- package/dist/src/external.d.ts +4 -0
- package/dist/src/external.js +3 -0
- package/dist/src/external.test.js +24 -1
- package/dist/src/help.d.ts +16 -1
- package/dist/src/help.js +50 -8
- package/dist/src/help.test.js +5 -1
- 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
- package/clis/notion/export.js +0 -32
- package/clis/notion/favorites.js +0 -85
- package/clis/notion/new.js +0 -35
- package/clis/notion/read.js +0 -31
- package/clis/notion/search.js +0 -47
- package/clis/notion/sidebar.js +0 -42
- package/clis/notion/status.js +0 -17
- package/clis/notion/write.js +0 -41
|
@@ -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)]);
|
package/clis/twitter/likes.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
5
|
const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
|
|
6
6
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
7
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
7
8
|
const FEATURES = {
|
|
8
9
|
rweb_video_screen_enabled: false,
|
|
9
10
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
@@ -151,20 +152,33 @@ cli({
|
|
|
151
152
|
columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
152
153
|
func: async (page, kwargs) => {
|
|
153
154
|
const limit = kwargs.limit || 20;
|
|
154
|
-
|
|
155
|
+
const rawUsername = String(kwargs.username ?? '').trim();
|
|
156
|
+
let username = normalizeTwitterScreenName(rawUsername);
|
|
157
|
+
if (rawUsername && !username) {
|
|
158
|
+
throw new ArgumentError('twitter likes username must be a valid Twitter/X handle', 'Example: opencli twitter likes @jack --limit 20');
|
|
159
|
+
}
|
|
155
160
|
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
156
161
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
157
162
|
if (!ct0)
|
|
158
163
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
159
|
-
// If no username provided, detect the logged-in user
|
|
164
|
+
// If no username provided, detect the logged-in user.
|
|
165
|
+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
|
|
166
|
+
// unwrap so the href string is usable downstream.
|
|
160
167
|
if (!username) {
|
|
161
|
-
|
|
168
|
+
// Force a navigation to the home surface so the AppTabBar sidebar
|
|
169
|
+
// is rendered; the framework pre-nav lands on bare x.com which
|
|
170
|
+
// does not always expose AppTabBar_Profile_Link.
|
|
171
|
+
await page.goto('https://x.com/home');
|
|
172
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
173
|
+
const href = unwrapBrowserResult(await page.evaluate(`() => {
|
|
162
174
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
163
175
|
return link ? link.getAttribute('href') : null;
|
|
164
|
-
}`);
|
|
165
|
-
if (!href)
|
|
176
|
+
}`));
|
|
177
|
+
if (!href || typeof href !== 'string')
|
|
178
|
+
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
179
|
+
username = normalizeTwitterScreenName(href);
|
|
180
|
+
if (!username)
|
|
166
181
|
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
167
|
-
username = href.replace('/', '');
|
|
168
182
|
}
|
|
169
183
|
const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
|
|
170
184
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
@@ -175,27 +189,28 @@ cli({
|
|
|
175
189
|
'X-Twitter-Active-User': 'yes',
|
|
176
190
|
});
|
|
177
191
|
// Get userId from screen_name
|
|
178
|
-
const userId = await page.evaluate(`async () => {
|
|
192
|
+
const userId = unwrapBrowserResult(await page.evaluate(`async () => {
|
|
179
193
|
const screenName = ${JSON.stringify(username)};
|
|
180
194
|
const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))};
|
|
181
195
|
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
|
|
182
196
|
if (!resp.ok) return null;
|
|
183
197
|
const d = await resp.json();
|
|
184
198
|
return d.data?.user?.result?.rest_id || null;
|
|
185
|
-
}`);
|
|
199
|
+
}`));
|
|
186
200
|
if (!userId) {
|
|
187
201
|
throw new CommandExecutionError(`Could not find user @${username}`);
|
|
188
202
|
}
|
|
189
203
|
const allTweets = [];
|
|
190
204
|
const seen = new Set();
|
|
191
205
|
let cursor = null;
|
|
192
|
-
|
|
206
|
+
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
207
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
|
|
193
208
|
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
194
209
|
const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor);
|
|
195
|
-
const data = await page.evaluate(`async () => {
|
|
210
|
+
const data = unwrapBrowserResult(await page.evaluate(`async () => {
|
|
196
211
|
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
197
212
|
return r.ok ? await r.json() : { error: r.status };
|
|
198
|
-
}`);
|
|
213
|
+
}`));
|
|
199
214
|
if (data?.error) {
|
|
200
215
|
if (allTweets.length === 0)
|
|
201
216
|
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
|
|
@@ -1,5 +1,50 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
2
4
|
import { __test__ } from './likes.js';
|
|
5
|
+
|
|
6
|
+
function likesPayload() {
|
|
7
|
+
return {
|
|
8
|
+
data: {
|
|
9
|
+
user: {
|
|
10
|
+
result: {
|
|
11
|
+
timeline_v2: {
|
|
12
|
+
timeline: {
|
|
13
|
+
instructions: [{
|
|
14
|
+
entries: [{
|
|
15
|
+
entryId: 'tweet-1',
|
|
16
|
+
content: {
|
|
17
|
+
itemContent: {
|
|
18
|
+
tweet_results: {
|
|
19
|
+
result: {
|
|
20
|
+
rest_id: '1',
|
|
21
|
+
legacy: {
|
|
22
|
+
full_text: 'liked post',
|
|
23
|
+
favorite_count: 7,
|
|
24
|
+
retweet_count: 2,
|
|
25
|
+
created_at: 'now',
|
|
26
|
+
},
|
|
27
|
+
core: {
|
|
28
|
+
user_results: {
|
|
29
|
+
result: {
|
|
30
|
+
legacy: { screen_name: 'alice', name: 'Alice' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}],
|
|
39
|
+
}],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
3
48
|
describe('twitter likes helpers', () => {
|
|
4
49
|
it('falls back when queryId contains unsafe characters', () => {
|
|
5
50
|
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
@@ -83,3 +128,68 @@ describe('twitter likes helpers', () => {
|
|
|
83
128
|
});
|
|
84
129
|
});
|
|
85
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('twitter likes command', () => {
|
|
133
|
+
it('rejects invalid explicit username before cookies or navigation', async () => {
|
|
134
|
+
const command = getRegistry().get('twitter/likes');
|
|
135
|
+
const page = {
|
|
136
|
+
goto: vi.fn(),
|
|
137
|
+
wait: vi.fn(),
|
|
138
|
+
getCookies: vi.fn(),
|
|
139
|
+
evaluate: vi.fn(),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await expect(command.func(page, { username: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
|
|
143
|
+
expect(page.getCookies).not.toHaveBeenCalled();
|
|
144
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
145
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
|
|
149
|
+
const command = getRegistry().get('twitter/likes');
|
|
150
|
+
const page = {
|
|
151
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
152
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
153
|
+
getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
|
|
154
|
+
evaluate: vi.fn(async (script) => {
|
|
155
|
+
if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
|
|
156
|
+
throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
161
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
162
|
+
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('unwraps Browser Bridge envelopes for default-self user lookup and likes payload', async () => {
|
|
166
|
+
const command = getRegistry().get('twitter/likes');
|
|
167
|
+
const page = {
|
|
168
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
169
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
170
|
+
getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
|
|
171
|
+
evaluate: vi.fn(async (script) => {
|
|
172
|
+
const text = String(script);
|
|
173
|
+
if (text.includes('AppTabBar_Profile_Link')) {
|
|
174
|
+
return { session: 'site:twitter', data: '/viewer' };
|
|
175
|
+
}
|
|
176
|
+
if (text.includes('operationName')) return null;
|
|
177
|
+
if (text.includes('/UserByScreenName')) {
|
|
178
|
+
return { session: 'site:twitter', data: '42' };
|
|
179
|
+
}
|
|
180
|
+
if (text.includes('/Likes')) {
|
|
181
|
+
return { session: 'site:twitter', data: likesPayload() };
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`);
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const rows = await command.func(page, { limit: 1 });
|
|
188
|
+
|
|
189
|
+
expect(rows).toHaveLength(1);
|
|
190
|
+
expect(rows[0]).toMatchObject({ id: '1', author: 'alice', text: 'liked post' });
|
|
191
|
+
const likesCall = page.evaluate.mock.calls.find(([script]) => String(script).includes('/Likes')) || [];
|
|
192
|
+
expect(decodeURIComponent(String(likesCall[0]))).toContain('"userId":"42"');
|
|
193
|
+
expect(decodeURIComponent(String(likesCall[0]))).not.toContain('[object Object]');
|
|
194
|
+
});
|
|
195
|
+
});
|