@jackwener/opencli 1.7.17 → 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 +10 -8
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +585 -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/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -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/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- 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/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +101 -0
- package/clis/rednote/user.js +55 -0
- 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 +57 -26
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +201 -37
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- 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/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- 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 +32 -22
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +21 -17
- package/dist/src/doctor.test.js +2 -0
- 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
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
|
};
|
|
@@ -13,6 +13,8 @@ describe('twitter reply command', () => {
|
|
|
13
13
|
const cmd = getRegistry().get('twitter/reply');
|
|
14
14
|
expect(cmd?.func).toBeTypeOf('function');
|
|
15
15
|
const page = createPageMock([
|
|
16
|
+
{ ok: true },
|
|
17
|
+
{ ok: true },
|
|
16
18
|
{ ok: true, message: 'Reply posted successfully.' },
|
|
17
19
|
]);
|
|
18
20
|
const result = await cmd.func(page, {
|
|
@@ -38,6 +40,8 @@ describe('twitter reply command', () => {
|
|
|
38
40
|
const setFileInput = vi.fn().mockResolvedValue(undefined);
|
|
39
41
|
const page = createPageMock([
|
|
40
42
|
{ ok: true, previewCount: 1 },
|
|
43
|
+
{ ok: true },
|
|
44
|
+
{ ok: true },
|
|
41
45
|
{ ok: true, message: 'Reply posted successfully.' },
|
|
42
46
|
], {
|
|
43
47
|
setFileInput,
|
|
@@ -74,6 +78,8 @@ describe('twitter reply command', () => {
|
|
|
74
78
|
const setFileInput = vi.fn().mockResolvedValue(undefined);
|
|
75
79
|
const page = createPageMock([
|
|
76
80
|
{ ok: true, previewCount: 1 },
|
|
81
|
+
{ ok: true },
|
|
82
|
+
{ ok: true },
|
|
77
83
|
{ ok: true, message: 'Reply posted successfully.' },
|
|
78
84
|
], {
|
|
79
85
|
setFileInput,
|
|
@@ -102,6 +108,55 @@ describe('twitter reply command', () => {
|
|
|
102
108
|
]);
|
|
103
109
|
vi.unstubAllGlobals();
|
|
104
110
|
});
|
|
111
|
+
it('falls back to the target tweet page when the dedicated composer route does not expose a textarea', async () => {
|
|
112
|
+
const cmd = getRegistry().get('twitter/reply');
|
|
113
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
114
|
+
const wait = vi.fn()
|
|
115
|
+
.mockRejectedValueOnce(new Error('Selector not found: [data-testid="tweetTextarea_0"]'))
|
|
116
|
+
.mockResolvedValue(undefined);
|
|
117
|
+
const page = createPageMock([
|
|
118
|
+
{ ok: true }, // click target tweet page Reply button
|
|
119
|
+
{ ok: true }, // insert reply text
|
|
120
|
+
{ ok: true }, // click composer Reply button
|
|
121
|
+
{ ok: true, message: 'Reply posted successfully.' }, // submit completed
|
|
122
|
+
], { wait });
|
|
123
|
+
|
|
124
|
+
const url = 'https://x.com/_kop6/status/2040254679301718161?s=20';
|
|
125
|
+
const result = await cmd.func(page, { url, text: 'fallback reply' });
|
|
126
|
+
|
|
127
|
+
expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
|
|
128
|
+
expect(page.goto).toHaveBeenNthCalledWith(2, url, { waitUntil: 'load', settleMs: 2500 });
|
|
129
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('[data-testid="reply"]');
|
|
130
|
+
expect(wait).toHaveBeenLastCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
131
|
+
expect(result).toEqual([{ status: 'success', message: 'Reply posted successfully.', text: 'fallback reply' }]);
|
|
132
|
+
});
|
|
133
|
+
it('treats an X success toast as success after a Promise was collected error', async () => {
|
|
134
|
+
const cmd = getRegistry().get('twitter/reply');
|
|
135
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
136
|
+
const evaluate = vi.fn()
|
|
137
|
+
.mockResolvedValueOnce({ ok: true }) // insert reply text
|
|
138
|
+
.mockResolvedValueOnce({ ok: true }) // click Reply
|
|
139
|
+
.mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}'))
|
|
140
|
+
.mockResolvedValueOnce({
|
|
141
|
+
ok: true,
|
|
142
|
+
message: 'Reply posted successfully.',
|
|
143
|
+
url: 'https://x.com/me/status/123',
|
|
144
|
+
});
|
|
145
|
+
const page = createPageMock([], { evaluate });
|
|
146
|
+
|
|
147
|
+
const result = await cmd.func(page, {
|
|
148
|
+
url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
|
|
149
|
+
text: 'toast recovery',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(page.wait).toHaveBeenCalledWith(2);
|
|
153
|
+
expect(result).toEqual([{
|
|
154
|
+
status: 'success',
|
|
155
|
+
message: 'Reply posted successfully.',
|
|
156
|
+
text: 'toast recovery',
|
|
157
|
+
url: 'https://x.com/me/status/123',
|
|
158
|
+
}]);
|
|
159
|
+
});
|
|
105
160
|
it('rejects using --image and --image-url together', async () => {
|
|
106
161
|
const cmd = getRegistry().get('twitter/reply');
|
|
107
162
|
expect(cmd?.func).toBeTypeOf('function');
|