@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import '
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { buildListAddMemberRow } from './list-add.js';
|
|
4
5
|
|
|
5
6
|
describe('twitter list-add registration', () => {
|
|
6
7
|
it('registers the list-add command with the expected shape', () => {
|
|
@@ -34,4 +35,99 @@ describe('twitter list-add registration', () => {
|
|
|
34
35
|
expect(page.wait).toHaveBeenCalledWith(3);
|
|
35
36
|
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
36
37
|
});
|
|
38
|
+
|
|
39
|
+
it('rejects invalid user input before navigation', async () => {
|
|
40
|
+
const cmd = getRegistry().get('twitter/list-add');
|
|
41
|
+
const page = {
|
|
42
|
+
goto: vi.fn(),
|
|
43
|
+
wait: vi.fn(),
|
|
44
|
+
getCookies: vi.fn(),
|
|
45
|
+
evaluate: vi.fn(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await expect(cmd.func(page, { listId: 'abc', username: 'alice' })).rejects.toBeInstanceOf(ArgumentError);
|
|
49
|
+
await expect(cmd.func(page, { listId: '123', username: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
50
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('builds success rows when member_count increases despite non-fatal decode errors', () => {
|
|
54
|
+
const row = buildListAddMemberRow({
|
|
55
|
+
addResult: {
|
|
56
|
+
httpOk: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
mc: 11,
|
|
59
|
+
isMember: true,
|
|
60
|
+
errors: [{ path: ['data', 'list', 'default_banner_media_results'], message: 'decode failed' }],
|
|
61
|
+
},
|
|
62
|
+
memberCountBefore: 10,
|
|
63
|
+
listId: '123',
|
|
64
|
+
username: 'alice',
|
|
65
|
+
userId: '42',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(row).toMatchObject({
|
|
69
|
+
listId: '123',
|
|
70
|
+
username: 'alice',
|
|
71
|
+
userId: '42',
|
|
72
|
+
status: 'success',
|
|
73
|
+
});
|
|
74
|
+
expect(row.message).toContain('member_count 10 → 11');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('treats unchanged member_count as noop only when membership is confirmed', () => {
|
|
78
|
+
const row = buildListAddMemberRow({
|
|
79
|
+
addResult: { httpOk: true, status: 200, mc: 10, isMember: true, errors: null },
|
|
80
|
+
memberCountBefore: 10,
|
|
81
|
+
listId: '123',
|
|
82
|
+
username: 'alice',
|
|
83
|
+
userId: '42',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(row.status).toBe('noop');
|
|
87
|
+
expect(row.message).toBe('@alice is already a member of list 123');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('fails typed when unchanged member_count does not confirm membership', () => {
|
|
91
|
+
expect(() => buildListAddMemberRow({
|
|
92
|
+
addResult: { httpOk: true, status: 200, mc: 10, isMember: false, errors: null },
|
|
93
|
+
memberCountBefore: 10,
|
|
94
|
+
listId: '123',
|
|
95
|
+
username: 'alice',
|
|
96
|
+
userId: '42',
|
|
97
|
+
})).toThrow(CommandExecutionError);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fails typed when member_count decreases unexpectedly', () => {
|
|
101
|
+
expect(() => buildListAddMemberRow({
|
|
102
|
+
addResult: { httpOk: true, status: 200, mc: 9, isMember: true, errors: null },
|
|
103
|
+
memberCountBefore: 10,
|
|
104
|
+
listId: '123',
|
|
105
|
+
username: 'alice',
|
|
106
|
+
userId: '42',
|
|
107
|
+
})).toThrow(/decreased unexpectedly/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('fails typed when GraphQL response has no usable member_count', () => {
|
|
111
|
+
expect(() => buildListAddMemberRow({
|
|
112
|
+
addResult: {
|
|
113
|
+
httpOk: true,
|
|
114
|
+
status: 200,
|
|
115
|
+
mc: undefined,
|
|
116
|
+
isMember: null,
|
|
117
|
+
errors: [{ message: 'List is unavailable', path: ['data', 'list'] }],
|
|
118
|
+
},
|
|
119
|
+
memberCountBefore: 10,
|
|
120
|
+
listId: '123',
|
|
121
|
+
username: 'alice',
|
|
122
|
+
userId: '42',
|
|
123
|
+
})).toThrow(/List is unavailable/);
|
|
124
|
+
|
|
125
|
+
expect(() => buildListAddMemberRow({
|
|
126
|
+
addResult: { httpOk: true, status: 200, mc: null, isMember: null, errors: { message: 'not an array' } },
|
|
127
|
+
memberCountBefore: 10,
|
|
128
|
+
listId: '123',
|
|
129
|
+
username: 'alice',
|
|
130
|
+
userId: '42',
|
|
131
|
+
})).toThrow(/no member_count/);
|
|
132
|
+
});
|
|
37
133
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { extractMedia } from './shared.js';
|
|
3
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
5
|
|
|
5
6
|
const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
|
|
6
7
|
const OPERATION_NAME = 'ListLatestTweetsTimeline';
|
|
8
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
7
9
|
|
|
8
10
|
const FEATURES = {
|
|
9
11
|
rweb_video_screen_enabled: false,
|
|
@@ -70,6 +72,7 @@ export function extractTimelineTweet(result, seen) {
|
|
|
70
72
|
replies: legacy.reply_count || 0,
|
|
71
73
|
created_at: legacy.created_at || '',
|
|
72
74
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
75
|
+
...extractMedia(legacy),
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -118,7 +121,7 @@ cli({
|
|
|
118
121
|
{ name: 'limit', type: 'int', default: 50 },
|
|
119
122
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline 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 list\'s native (recency) ordering.' },
|
|
120
123
|
],
|
|
121
|
-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
|
|
124
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
122
125
|
func: async (page, kwargs) => {
|
|
123
126
|
const listId = String(kwargs.listId || '').trim();
|
|
124
127
|
if (!listId || !/^\d+$/.test(listId)) {
|
|
@@ -129,7 +132,11 @@ cli({
|
|
|
129
132
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
130
133
|
if (!ct0)
|
|
131
134
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
132
|
-
|
|
135
|
+
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
|
|
136
|
+
// Without unwrap, the string queryId becomes "[object Object]" when interpolated into the URL,
|
|
137
|
+
// causing HTTP 400 "queryId may have expired".
|
|
138
|
+
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
|
|
139
|
+
const queryIdRaw = await page.evaluate(`async () => {
|
|
133
140
|
try {
|
|
134
141
|
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
135
142
|
if (ghResp.ok) {
|
|
@@ -152,7 +159,8 @@ cli({
|
|
|
152
159
|
}
|
|
153
160
|
} catch {}
|
|
154
161
|
return null;
|
|
155
|
-
}`)
|
|
162
|
+
}`);
|
|
163
|
+
const queryId = unwrap(queryIdRaw) || LIST_TWEETS_QUERY_ID;
|
|
156
164
|
const headers = JSON.stringify({
|
|
157
165
|
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
158
166
|
'X-Csrf-Token': ct0,
|
|
@@ -162,7 +170,8 @@ cli({
|
|
|
162
170
|
const allTweets = [];
|
|
163
171
|
const seen = new Set();
|
|
164
172
|
let cursor = null;
|
|
165
|
-
|
|
173
|
+
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
|
174
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
|
|
166
175
|
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
167
176
|
const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
|
|
168
177
|
const data = await page.evaluate(`async () => {
|
|
@@ -30,9 +30,57 @@ describe('twitter list-tweets parser', () => {
|
|
|
30
30
|
replies: 2,
|
|
31
31
|
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
|
|
32
32
|
url: 'https://x.com/bob/status/99',
|
|
33
|
+
has_media: false,
|
|
34
|
+
media_urls: [],
|
|
33
35
|
});
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
it('includes photo media URLs from extended_entities', () => {
|
|
39
|
+
const tweet = extractTimelineTweet({
|
|
40
|
+
rest_id: '101',
|
|
41
|
+
legacy: {
|
|
42
|
+
full_text: 'pic post',
|
|
43
|
+
extended_entities: {
|
|
44
|
+
media: [
|
|
45
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
|
|
46
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
|
|
51
|
+
}, new Set());
|
|
52
|
+
expect(tweet?.has_media).toBe(true);
|
|
53
|
+
expect(tweet?.media_urls).toEqual([
|
|
54
|
+
'https://pbs.twimg.com/media/abc.jpg',
|
|
55
|
+
'https://pbs.twimg.com/media/def.jpg',
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('extracts mp4 variant URL for video media', () => {
|
|
60
|
+
const tweet = extractTimelineTweet({
|
|
61
|
+
rest_id: '102',
|
|
62
|
+
legacy: {
|
|
63
|
+
full_text: 'video post',
|
|
64
|
+
extended_entities: {
|
|
65
|
+
media: [{
|
|
66
|
+
type: 'video',
|
|
67
|
+
media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
|
|
68
|
+
video_info: {
|
|
69
|
+
variants: [
|
|
70
|
+
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
|
|
71
|
+
{ content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
|
|
72
|
+
{ content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
}],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
core: { user_results: { result: { legacy: { screen_name: 'erin' } } } },
|
|
79
|
+
}, new Set());
|
|
80
|
+
expect(tweet?.has_media).toBe(true);
|
|
81
|
+
expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
|
|
82
|
+
});
|
|
83
|
+
|
|
36
84
|
it('prefers long-form note_tweet text over truncated legacy full_text', () => {
|
|
37
85
|
const tweet = extractTimelineTweet({
|
|
38
86
|
rest_id: '100',
|
package/clis/twitter/lists.js
CHANGED
|
@@ -103,7 +103,9 @@ export const command = cli({
|
|
|
103
103
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
104
104
|
if (!ct0)
|
|
105
105
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
106
|
-
|
|
106
|
+
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
|
|
107
|
+
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
|
|
108
|
+
const queryIdRaw = await page.evaluate(`async () => {
|
|
107
109
|
try {
|
|
108
110
|
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
109
111
|
if (ghResp.ok) {
|
|
@@ -126,7 +128,8 @@ export const command = cli({
|
|
|
126
128
|
}
|
|
127
129
|
} catch {}
|
|
128
130
|
return null;
|
|
129
|
-
}`)
|
|
131
|
+
}`);
|
|
132
|
+
const queryId = unwrap(queryIdRaw) || LISTS_QUERY_ID;
|
|
130
133
|
const headers = JSON.stringify({
|
|
131
134
|
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
132
135
|
'X-Csrf-Token': ct0,
|
package/clis/twitter/post.js
CHANGED
|
@@ -161,12 +161,25 @@ async function submitTweet(page, text) {
|
|
|
161
161
|
const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
|
|
162
162
|
const expectedText = normalize(expected);
|
|
163
163
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
164
|
+
const statusUrl = (root = document) => {
|
|
165
|
+
const links = Array.from(root.querySelectorAll('a[href*="/status/"]'));
|
|
166
|
+
for (const link of links) {
|
|
167
|
+
const href = link.href || link.getAttribute('href') || '';
|
|
168
|
+
if (!href) continue;
|
|
169
|
+
try {
|
|
170
|
+
const url = new URL(href, window.location.origin);
|
|
171
|
+
const match = url.pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)/);
|
|
172
|
+
if (match) return { url: url.href, id: match[1] };
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
return {};
|
|
176
|
+
};
|
|
164
177
|
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
165
178
|
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
|
|
166
179
|
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
167
180
|
.filter((el) => visible(el));
|
|
168
181
|
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
169
|
-
if (successToast) return { ok: true, message: 'Tweet posted successfully.' };
|
|
182
|
+
if (successToast) return { ok: true, message: 'Tweet posted successfully.', ...statusUrl(successToast) };
|
|
170
183
|
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
171
184
|
if (alert) return { ok: false, message: (alert.textContent || 'Tweet failed to post.').trim() };
|
|
172
185
|
|
|
@@ -175,7 +188,7 @@ async function submitTweet(page, text) {
|
|
|
175
188
|
const hasMedia = !!document.querySelector('[data-testid="attachments"], [data-testid="tweetPhoto"]')
|
|
176
189
|
|| document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length > 0;
|
|
177
190
|
if (!composerStillHasText && !hasMedia) {
|
|
178
|
-
return { ok: true, message: 'Tweet posted successfully.' };
|
|
191
|
+
return { ok: true, message: 'Tweet posted successfully.', ...statusUrl() };
|
|
179
192
|
}
|
|
180
193
|
}
|
|
181
194
|
return { ok: false, message: 'Tweet submission did not complete before timeout.' };
|
|
@@ -194,7 +207,7 @@ cli({
|
|
|
194
207
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
|
|
195
208
|
{ name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
|
|
196
209
|
],
|
|
197
|
-
columns: ['status', 'message', 'text'],
|
|
210
|
+
columns: ['status', 'message', 'text', 'id', 'url'],
|
|
198
211
|
func: async (page, kwargs) => {
|
|
199
212
|
if (!page)
|
|
200
213
|
throw new CommandExecutionError('Browser session required for twitter post');
|
|
@@ -231,6 +244,12 @@ cli({
|
|
|
231
244
|
|
|
232
245
|
await page.wait(1);
|
|
233
246
|
const result = await submitTweet(page, text);
|
|
234
|
-
return [{
|
|
247
|
+
return [{
|
|
248
|
+
status: result?.ok ? 'success' : 'failed',
|
|
249
|
+
message: result?.message ?? 'Tweet failed to post.',
|
|
250
|
+
text,
|
|
251
|
+
...(result?.id ? { id: result.id } : {}),
|
|
252
|
+
...(result?.url ? { url: result.url } : {}),
|
|
253
|
+
}];
|
|
235
254
|
}
|
|
236
255
|
});
|
|
@@ -46,6 +46,11 @@ function makePage(evaluateResults = [], overrides = {}) {
|
|
|
46
46
|
describe('twitter post command', () => {
|
|
47
47
|
const getCommand = () => getRegistry().get('twitter/post');
|
|
48
48
|
|
|
49
|
+
it('registers created tweet id/url columns', () => {
|
|
50
|
+
const command = getCommand();
|
|
51
|
+
expect(command?.columns).toEqual(['status', 'message', 'text', 'id', 'url']);
|
|
52
|
+
});
|
|
53
|
+
|
|
49
54
|
it('posts text-only tweet successfully through the current compose route', async () => {
|
|
50
55
|
const command = getCommand();
|
|
51
56
|
const page = makePage([
|
|
@@ -63,6 +68,31 @@ describe('twitter post command', () => {
|
|
|
63
68
|
expect(page.insertText).toHaveBeenCalledWith('hello world');
|
|
64
69
|
});
|
|
65
70
|
|
|
71
|
+
it('returns the created tweet URL from the success toast when available', async () => {
|
|
72
|
+
const command = getCommand();
|
|
73
|
+
const page = makePage([
|
|
74
|
+
{ ok: true },
|
|
75
|
+
{ ok: true },
|
|
76
|
+
{ ok: true },
|
|
77
|
+
{
|
|
78
|
+
ok: true,
|
|
79
|
+
message: 'Tweet posted successfully.',
|
|
80
|
+
id: '2054239044884693381',
|
|
81
|
+
url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const result = await command.func(page, { text: 'with url' });
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual([{
|
|
88
|
+
status: 'success',
|
|
89
|
+
message: 'Tweet posted successfully.',
|
|
90
|
+
text: 'with url',
|
|
91
|
+
id: '2054239044884693381',
|
|
92
|
+
url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
|
|
93
|
+
}]);
|
|
94
|
+
});
|
|
95
|
+
|
|
66
96
|
it('returns failed when text area not found', async () => {
|
|
67
97
|
const command = getCommand();
|
|
68
98
|
const page = makePage([
|
package/clis/twitter/profile.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { resolveTwitterQueryId } from './shared.js';
|
|
3
|
+
import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
5
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
6
6
|
cli({
|
|
@@ -17,18 +17,26 @@ cli({
|
|
|
17
17
|
],
|
|
18
18
|
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
|
|
19
19
|
func: async (page, kwargs) => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const rawUsername = String(kwargs.username ?? '').trim();
|
|
21
|
+
let username = normalizeTwitterScreenName(rawUsername);
|
|
22
|
+
if (rawUsername && !username) {
|
|
23
|
+
throw new ArgumentError('twitter profile username must be a valid Twitter/X handle', 'Example: opencli twitter profile @jack');
|
|
24
|
+
}
|
|
25
|
+
// If no username, detect the logged-in user.
|
|
26
|
+
// Bridge wraps primitive page.evaluate returns as { session, data:<value> };
|
|
27
|
+
// unwrap so the href string is usable downstream.
|
|
22
28
|
if (!username) {
|
|
23
29
|
await page.goto('https://x.com/home');
|
|
24
30
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
25
|
-
const href = await page.evaluate(`() => {
|
|
31
|
+
const href = unwrapBrowserResult(await page.evaluate(`() => {
|
|
26
32
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
27
33
|
return link ? link.getAttribute('href') : null;
|
|
28
|
-
}`);
|
|
29
|
-
if (!href)
|
|
34
|
+
}`));
|
|
35
|
+
if (!href || typeof href !== 'string')
|
|
36
|
+
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
37
|
+
username = normalizeTwitterScreenName(href);
|
|
38
|
+
if (!username)
|
|
30
39
|
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
31
|
-
username = href.replace('/', '');
|
|
32
40
|
}
|
|
33
41
|
// Navigate directly to the user's profile page (gives us cookie context)
|
|
34
42
|
await page.goto(`https://x.com/${username}`);
|
|
@@ -0,0 +1,39 @@
|
|
|
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 './profile.js';
|
|
5
|
+
|
|
6
|
+
describe('twitter profile command', () => {
|
|
7
|
+
it('rejects invalid explicit usernames before navigation', async () => {
|
|
8
|
+
const command = getRegistry().get('twitter/profile');
|
|
9
|
+
const page = {
|
|
10
|
+
goto: vi.fn(),
|
|
11
|
+
wait: vi.fn(),
|
|
12
|
+
getCookies: vi.fn(),
|
|
13
|
+
evaluate: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
await expect(command.func(page, { username: 'viewer/extra' })).rejects.toBeInstanceOf(ArgumentError);
|
|
17
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
18
|
+
expect(page.getCookies).not.toHaveBeenCalled();
|
|
19
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects route-like AppTabBar hrefs instead of navigating to that route profile', async () => {
|
|
23
|
+
const command = getRegistry().get('twitter/profile');
|
|
24
|
+
const page = {
|
|
25
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
getCookies: vi.fn(),
|
|
28
|
+
evaluate: vi.fn(async (script) => {
|
|
29
|
+
if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
|
|
30
|
+
throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
35
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
36
|
+
expect(page.goto).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(page.getCookies).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
});
|
package/clis/twitter/reply.js
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
resolveImagePath,
|
|
11
11
|
} from './utils.js';
|
|
12
12
|
|
|
13
|
+
const COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"]';
|
|
14
|
+
const SUBMIT_POLL_MS = 500;
|
|
15
|
+
const SUBMIT_TIMEOUT_MS = 15_000;
|
|
16
|
+
|
|
13
17
|
function buildReplyComposerUrl(rawUrl) {
|
|
14
18
|
// Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
|
|
15
19
|
// (silent: matched `/status/1234567` on substring `/status/123` and
|
|
@@ -19,7 +23,36 @@ function buildReplyComposerUrl(rawUrl) {
|
|
|
19
23
|
return `https://x.com/compose/post?in_reply_to=${target.id}`;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
function isPromiseCollectedError(err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
28
|
+
return msg.includes('Promise was collected');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function openReplyComposer(page, rawUrl) {
|
|
32
|
+
await page.goto(buildReplyComposerUrl(rawUrl), { waitUntil: 'load', settleMs: 2500 });
|
|
33
|
+
try {
|
|
34
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
|
|
35
|
+
return { ok: true };
|
|
36
|
+
} catch {
|
|
37
|
+
// X sometimes leaves /compose/post?in_reply_to=<id> on the Home
|
|
38
|
+
// timeline behind a loading dialog. Fall back to the canonical tweet
|
|
39
|
+
// page and click the visible Reply action there.
|
|
40
|
+
await page.goto(rawUrl, { waitUntil: 'load', settleMs: 2500 });
|
|
41
|
+
const clicked = await page.evaluate(`(() => {
|
|
42
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
43
|
+
const buttons = Array.from(document.querySelectorAll('[data-testid="reply"]'));
|
|
44
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
45
|
+
if (!btn) return { ok: false, message: 'Could not find the reply button on the target tweet.' };
|
|
46
|
+
btn.click();
|
|
47
|
+
return { ok: true };
|
|
48
|
+
})()`);
|
|
49
|
+
if (!clicked?.ok) return clicked;
|
|
50
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function insertReplyText(page, text) {
|
|
23
56
|
return page.evaluate(`(async () => {
|
|
24
57
|
try {
|
|
25
58
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
@@ -44,23 +77,109 @@ async function submitReply(page, text) {
|
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
await new Promise(r => setTimeout(r, 1000));
|
|
80
|
+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
81
|
+
const actual = box.innerText || box.textContent || '';
|
|
82
|
+
if (!normalize(actual).includes(normalize(textToInsert))) {
|
|
83
|
+
return { ok: false, message: 'Could not verify reply text in the composer after typing.', actualText: actual };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { ok: false, message: e.toString() };
|
|
88
|
+
}
|
|
89
|
+
})()`);
|
|
90
|
+
}
|
|
47
91
|
|
|
92
|
+
async function clickReplyButton(page) {
|
|
93
|
+
return page.evaluate(`(() => {
|
|
94
|
+
try {
|
|
95
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
48
96
|
const buttons = Array.from(
|
|
49
97
|
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
50
98
|
);
|
|
51
|
-
const btn = buttons.find((el) => visible(el) && !el.disabled);
|
|
99
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
52
100
|
if (!btn) {
|
|
53
101
|
return { ok: false, message: 'Reply button is disabled or not found.' };
|
|
54
102
|
}
|
|
55
103
|
|
|
56
104
|
btn.click();
|
|
57
|
-
return { ok: true
|
|
105
|
+
return { ok: true };
|
|
58
106
|
} catch (e) {
|
|
59
107
|
return { ok: false, message: e.toString() };
|
|
60
108
|
}
|
|
61
109
|
})()`);
|
|
62
110
|
}
|
|
63
111
|
|
|
112
|
+
async function detectReplySent(page) {
|
|
113
|
+
return page.evaluate(`(() => {
|
|
114
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
115
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
116
|
+
.filter((el) => visible(el));
|
|
117
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
118
|
+
if (!successToast) return { ok: false };
|
|
119
|
+
const link = successToast.querySelector('a[href*="/status/"]');
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
message: 'Reply posted successfully.',
|
|
123
|
+
url: link?.href || link?.getAttribute('href') || undefined
|
|
124
|
+
};
|
|
125
|
+
})()`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function waitForReplySent(page, text) {
|
|
129
|
+
const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
|
|
130
|
+
try {
|
|
131
|
+
return await page.evaluate(`(async () => {
|
|
132
|
+
const expected = ${JSON.stringify(text)};
|
|
133
|
+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
134
|
+
const expectedText = normalize(expected);
|
|
135
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
136
|
+
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
137
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
|
|
138
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
139
|
+
.filter((el) => visible(el));
|
|
140
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
141
|
+
if (successToast) {
|
|
142
|
+
const link = successToast.querySelector('a[href*="/status/"]');
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
message: 'Reply posted successfully.',
|
|
146
|
+
url: link?.href || link?.getAttribute('href') || undefined
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
150
|
+
if (alert) return { ok: false, message: (alert.textContent || 'Reply failed to post.').trim() };
|
|
151
|
+
|
|
152
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
153
|
+
const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText));
|
|
154
|
+
if (!composerStillHasText) return { ok: true, message: 'Reply posted successfully.' };
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, message: 'Reply submission did not complete before timeout.' };
|
|
157
|
+
})()`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// X may route the SPA immediately after click, making CDP collect the
|
|
160
|
+
// polling promise even though the reply was submitted. If the page now
|
|
161
|
+
// shows the success toast, report success instead of a false negative.
|
|
162
|
+
if (!isPromiseCollectedError(err)) throw err;
|
|
163
|
+
await page.wait(2);
|
|
164
|
+
const recovered = await detectReplySent(page);
|
|
165
|
+
if (recovered?.ok) return recovered;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function submitReply(page, text) {
|
|
171
|
+
const typed = await insertReplyText(page, text);
|
|
172
|
+
if (!typed?.ok) return typed;
|
|
173
|
+
let clicked;
|
|
174
|
+
try {
|
|
175
|
+
clicked = await clickReplyButton(page);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (!isPromiseCollectedError(err)) throw err;
|
|
178
|
+
}
|
|
179
|
+
if (clicked && !clicked.ok) return clicked;
|
|
180
|
+
return waitForReplySent(page, text);
|
|
181
|
+
}
|
|
182
|
+
|
|
64
183
|
cli({
|
|
65
184
|
site: 'twitter',
|
|
66
185
|
name: 'reply',
|
|
@@ -75,7 +194,7 @@ cli({
|
|
|
75
194
|
{ name: 'image', help: 'Optional local image path to attach to the reply' },
|
|
76
195
|
{ name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
|
|
77
196
|
],
|
|
78
|
-
columns: ['status', 'message', 'text'],
|
|
197
|
+
columns: ['status', 'message', 'text', 'url'],
|
|
79
198
|
func: async (page, kwargs) => {
|
|
80
199
|
if (!page)
|
|
81
200
|
throw new CommandExecutionError('Browser session required for twitter reply');
|
|
@@ -92,21 +211,24 @@ cli({
|
|
|
92
211
|
localImagePath = downloaded.absPath;
|
|
93
212
|
cleanupDir = downloaded.cleanupDir;
|
|
94
213
|
}
|
|
95
|
-
// Dedicated composer is more reliable than the inline
|
|
96
|
-
|
|
97
|
-
|
|
214
|
+
// Dedicated composer is normally more reliable than the inline
|
|
215
|
+
// tweet page reply box, but X occasionally leaves that route on the
|
|
216
|
+
// Home timeline behind a loading dialog. openReplyComposer falls
|
|
217
|
+
// back to the target tweet's visible Reply action.
|
|
218
|
+
const composer = await openReplyComposer(page, kwargs.url);
|
|
219
|
+
if (!composer?.ok) {
|
|
220
|
+
return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }];
|
|
221
|
+
}
|
|
98
222
|
if (localImagePath) {
|
|
99
223
|
await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
100
224
|
await attachComposerImage(page, localImagePath);
|
|
101
225
|
}
|
|
102
226
|
const result = await submitReply(page, kwargs.text);
|
|
103
|
-
if (result.ok) {
|
|
104
|
-
await page.wait(3); // Wait for network submission to complete
|
|
105
|
-
}
|
|
106
227
|
return [{
|
|
107
228
|
status: result.ok ? 'success' : 'failed',
|
|
108
229
|
message: result.message,
|
|
109
230
|
text: kwargs.text,
|
|
231
|
+
...(result.url ? { url: result.url } : {}),
|
|
110
232
|
...(kwargs.image ? { image: kwargs.image } : {}),
|
|
111
233
|
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
|
|
112
234
|
}];
|
|
@@ -119,4 +241,5 @@ cli({
|
|
|
119
241
|
});
|
|
120
242
|
export const __test__ = {
|
|
121
243
|
buildReplyComposerUrl,
|
|
244
|
+
isPromiseCollectedError,
|
|
122
245
|
};
|