@jackwener/opencli 1.7.13 → 1.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-manifest.json +112 -0
- package/clis/twitter/quote.js +139 -0
- package/clis/twitter/quote.test.js +106 -0
- package/clis/twitter/retweet.js +99 -0
- package/clis/twitter/retweet.test.js +69 -0
- package/clis/twitter/shared.js +38 -0
- package/clis/twitter/shared.test.js +28 -1
- package/clis/twitter/unlike.js +87 -0
- package/clis/twitter/unlike.test.js +72 -0
- package/clis/twitter/unretweet.js +99 -0
- package/clis/twitter/unretweet.test.js +69 -0
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser.test.js +18 -0
- package/dist/src/cli.test.js +91 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +156 -5
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -22760,6 +22760,40 @@
|
|
|
22760
22760
|
"sourceFile": "twitter/profile.js",
|
|
22761
22761
|
"navigateBefore": "https://x.com"
|
|
22762
22762
|
},
|
|
22763
|
+
{
|
|
22764
|
+
"site": "twitter",
|
|
22765
|
+
"name": "quote",
|
|
22766
|
+
"description": "Quote-tweet a specific tweet with your own text",
|
|
22767
|
+
"access": "write",
|
|
22768
|
+
"domain": "x.com",
|
|
22769
|
+
"strategy": "ui",
|
|
22770
|
+
"browser": true,
|
|
22771
|
+
"args": [
|
|
22772
|
+
{
|
|
22773
|
+
"name": "url",
|
|
22774
|
+
"type": "string",
|
|
22775
|
+
"required": true,
|
|
22776
|
+
"positional": true,
|
|
22777
|
+
"help": "The URL of the tweet to quote"
|
|
22778
|
+
},
|
|
22779
|
+
{
|
|
22780
|
+
"name": "text",
|
|
22781
|
+
"type": "string",
|
|
22782
|
+
"required": true,
|
|
22783
|
+
"positional": true,
|
|
22784
|
+
"help": "The text content of your quote"
|
|
22785
|
+
}
|
|
22786
|
+
],
|
|
22787
|
+
"columns": [
|
|
22788
|
+
"status",
|
|
22789
|
+
"message",
|
|
22790
|
+
"text"
|
|
22791
|
+
],
|
|
22792
|
+
"type": "js",
|
|
22793
|
+
"modulePath": "twitter/quote.js",
|
|
22794
|
+
"sourceFile": "twitter/quote.js",
|
|
22795
|
+
"navigateBefore": true
|
|
22796
|
+
},
|
|
22763
22797
|
{
|
|
22764
22798
|
"site": "twitter",
|
|
22765
22799
|
"name": "reply",
|
|
@@ -22855,6 +22889,32 @@
|
|
|
22855
22889
|
"sourceFile": "twitter/reply-dm.js",
|
|
22856
22890
|
"navigateBefore": true
|
|
22857
22891
|
},
|
|
22892
|
+
{
|
|
22893
|
+
"site": "twitter",
|
|
22894
|
+
"name": "retweet",
|
|
22895
|
+
"description": "Retweet a specific tweet",
|
|
22896
|
+
"access": "write",
|
|
22897
|
+
"domain": "x.com",
|
|
22898
|
+
"strategy": "ui",
|
|
22899
|
+
"browser": true,
|
|
22900
|
+
"args": [
|
|
22901
|
+
{
|
|
22902
|
+
"name": "url",
|
|
22903
|
+
"type": "string",
|
|
22904
|
+
"required": true,
|
|
22905
|
+
"positional": true,
|
|
22906
|
+
"help": "The URL of the tweet to retweet"
|
|
22907
|
+
}
|
|
22908
|
+
],
|
|
22909
|
+
"columns": [
|
|
22910
|
+
"status",
|
|
22911
|
+
"message"
|
|
22912
|
+
],
|
|
22913
|
+
"type": "js",
|
|
22914
|
+
"modulePath": "twitter/retweet.js",
|
|
22915
|
+
"sourceFile": "twitter/retweet.js",
|
|
22916
|
+
"navigateBefore": true
|
|
22917
|
+
},
|
|
22858
22918
|
{
|
|
22859
22919
|
"site": "twitter",
|
|
22860
22920
|
"name": "search",
|
|
@@ -23139,6 +23199,58 @@
|
|
|
23139
23199
|
"sourceFile": "twitter/unfollow.js",
|
|
23140
23200
|
"navigateBefore": true
|
|
23141
23201
|
},
|
|
23202
|
+
{
|
|
23203
|
+
"site": "twitter",
|
|
23204
|
+
"name": "unlike",
|
|
23205
|
+
"description": "Remove a like from a specific tweet",
|
|
23206
|
+
"access": "write",
|
|
23207
|
+
"domain": "x.com",
|
|
23208
|
+
"strategy": "ui",
|
|
23209
|
+
"browser": true,
|
|
23210
|
+
"args": [
|
|
23211
|
+
{
|
|
23212
|
+
"name": "url",
|
|
23213
|
+
"type": "string",
|
|
23214
|
+
"required": true,
|
|
23215
|
+
"positional": true,
|
|
23216
|
+
"help": "The URL of the tweet to unlike"
|
|
23217
|
+
}
|
|
23218
|
+
],
|
|
23219
|
+
"columns": [
|
|
23220
|
+
"status",
|
|
23221
|
+
"message"
|
|
23222
|
+
],
|
|
23223
|
+
"type": "js",
|
|
23224
|
+
"modulePath": "twitter/unlike.js",
|
|
23225
|
+
"sourceFile": "twitter/unlike.js",
|
|
23226
|
+
"navigateBefore": true
|
|
23227
|
+
},
|
|
23228
|
+
{
|
|
23229
|
+
"site": "twitter",
|
|
23230
|
+
"name": "unretweet",
|
|
23231
|
+
"description": "Undo a retweet on a specific tweet",
|
|
23232
|
+
"access": "write",
|
|
23233
|
+
"domain": "x.com",
|
|
23234
|
+
"strategy": "ui",
|
|
23235
|
+
"browser": true,
|
|
23236
|
+
"args": [
|
|
23237
|
+
{
|
|
23238
|
+
"name": "url",
|
|
23239
|
+
"type": "string",
|
|
23240
|
+
"required": true,
|
|
23241
|
+
"positional": true,
|
|
23242
|
+
"help": "The URL of the tweet to unretweet"
|
|
23243
|
+
}
|
|
23244
|
+
],
|
|
23245
|
+
"columns": [
|
|
23246
|
+
"status",
|
|
23247
|
+
"message"
|
|
23248
|
+
],
|
|
23249
|
+
"type": "js",
|
|
23250
|
+
"modulePath": "twitter/unretweet.js",
|
|
23251
|
+
"sourceFile": "twitter/unretweet.js",
|
|
23252
|
+
"navigateBefore": true
|
|
23253
|
+
},
|
|
23142
23254
|
{
|
|
23143
23255
|
"site": "uisdc",
|
|
23144
23256
|
"name": "news",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
function extractTweetId(url) {
|
|
6
|
+
return parseTweetUrl(url).id;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildQuoteComposerUrl(url) {
|
|
10
|
+
// Twitter/X quote-tweet compose URL: the `url` param attaches the source
|
|
11
|
+
// tweet as a quoted card. Validating tweet-id shape early surfaces obvious
|
|
12
|
+
// typos before any browser interaction.
|
|
13
|
+
const parsed = parseTweetUrl(url);
|
|
14
|
+
return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function submitQuote(page, text, tweetId) {
|
|
18
|
+
return page.evaluate(`(async () => {
|
|
19
|
+
try {
|
|
20
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
21
|
+
const getStatusId = (href) => {
|
|
22
|
+
try {
|
|
23
|
+
const match = new URL(href, window.location.origin).pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/);
|
|
24
|
+
return match?.[1] || null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
30
|
+
const box = boxes.find(visible) || boxes[0];
|
|
31
|
+
if (!box) {
|
|
32
|
+
return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
box.focus();
|
|
36
|
+
const textToInsert = ${JSON.stringify(text)};
|
|
37
|
+
const tweetId = ${JSON.stringify(tweetId)};
|
|
38
|
+
// execCommand('insertText') is more reliable with Twitter's Draft.js editor.
|
|
39
|
+
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
40
|
+
// Fallback to paste event if execCommand fails.
|
|
41
|
+
const dataTransfer = new DataTransfer();
|
|
42
|
+
dataTransfer.setData('text/plain', textToInsert);
|
|
43
|
+
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
44
|
+
clipboardData: dataTransfer,
|
|
45
|
+
bubbles: true,
|
|
46
|
+
cancelable: true,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
51
|
+
|
|
52
|
+
// Confirm the quoted card is rendered before submitting; otherwise we may
|
|
53
|
+
// accidentally post a plain tweet without the quote attachment.
|
|
54
|
+
let cardAttempts = 0;
|
|
55
|
+
let hasQuoteCard = false;
|
|
56
|
+
while (cardAttempts < 20) {
|
|
57
|
+
hasQuoteCard = Array.from(document.querySelectorAll('a[href*="/status/"]'))
|
|
58
|
+
.some((link) => getStatusId(link.href) === tweetId);
|
|
59
|
+
if (hasQuoteCard) break;
|
|
60
|
+
await new Promise(r => setTimeout(r, 250));
|
|
61
|
+
cardAttempts++;
|
|
62
|
+
}
|
|
63
|
+
if (!hasQuoteCard) {
|
|
64
|
+
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const buttons = Array.from(
|
|
68
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
69
|
+
);
|
|
70
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
71
|
+
if (!btn) {
|
|
72
|
+
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
btn.click();
|
|
76
|
+
|
|
77
|
+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
78
|
+
const expectedText = normalize(textToInsert);
|
|
79
|
+
for (let i = 0; i < 30; i++) {
|
|
80
|
+
await new Promise(r => setTimeout(r, 500));
|
|
81
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
82
|
+
.filter((el) => visible(el));
|
|
83
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
84
|
+
if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
85
|
+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
86
|
+
if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
|
|
87
|
+
|
|
88
|
+
const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
89
|
+
const composerStillHasText = visibleBoxes.some((box) =>
|
|
90
|
+
normalize(box.innerText || box.textContent || '').includes(expectedText)
|
|
91
|
+
);
|
|
92
|
+
if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
93
|
+
}
|
|
94
|
+
return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, message: e.toString() };
|
|
97
|
+
}
|
|
98
|
+
})()`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cli({
|
|
102
|
+
site: 'twitter',
|
|
103
|
+
name: 'quote',
|
|
104
|
+
access: 'write',
|
|
105
|
+
description: 'Quote-tweet a specific tweet with your own text',
|
|
106
|
+
domain: 'x.com',
|
|
107
|
+
strategy: Strategy.UI,
|
|
108
|
+
browser: true,
|
|
109
|
+
args: [
|
|
110
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
|
|
111
|
+
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
|
|
112
|
+
],
|
|
113
|
+
columns: ['status', 'message', 'text'],
|
|
114
|
+
func: async (page, kwargs) => {
|
|
115
|
+
if (!page)
|
|
116
|
+
throw new CommandExecutionError('Browser session required for twitter quote');
|
|
117
|
+
|
|
118
|
+
// Dedicated composer is more reliable than the inline quote-tweet button.
|
|
119
|
+
const target = parseTweetUrl(kwargs.url);
|
|
120
|
+
await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
|
|
121
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
122
|
+
|
|
123
|
+
const result = await submitQuote(page, kwargs.text, target.id);
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
// Wait for network submission to complete
|
|
126
|
+
await page.wait(3);
|
|
127
|
+
}
|
|
128
|
+
return [{
|
|
129
|
+
status: result.ok ? 'success' : 'failed',
|
|
130
|
+
message: result.message,
|
|
131
|
+
text: kwargs.text,
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const __test__ = {
|
|
137
|
+
buildQuoteComposerUrl,
|
|
138
|
+
extractTweetId,
|
|
139
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import { __test__ } from './quote.js';
|
|
5
|
+
import './quote.js';
|
|
6
|
+
import { createPageMock } from '../test-utils.js';
|
|
7
|
+
|
|
8
|
+
describe('twitter quote helpers', () => {
|
|
9
|
+
it('extracts tweet ids from both user and i/status URLs', () => {
|
|
10
|
+
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
|
|
11
|
+
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
|
|
15
|
+
const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
|
|
16
|
+
// The full source URL is round-tripped via encodeURIComponent — decoding it
|
|
17
|
+
// back must yield the original URL. This guards against accidental drops of
|
|
18
|
+
// query parameters or fragment characters in future refactors.
|
|
19
|
+
const parsed = new URL(composeUrl);
|
|
20
|
+
expect(parsed.origin + parsed.pathname).toBe('https://x.com/compose/post');
|
|
21
|
+
expect(parsed.searchParams.get('url')).toBe('https://x.com/alice/status/2040254679301718161?s=20');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects malformed URLs before any browser interaction', () => {
|
|
25
|
+
expect(() => __test__.buildQuoteComposerUrl('https://x.com/alice/home')).toThrow(/Could not extract tweet ID/);
|
|
26
|
+
expect(() => __test__.buildQuoteComposerUrl('not a url')).toThrow(/Invalid tweet URL/);
|
|
27
|
+
expect(() => __test__.buildQuoteComposerUrl('https://evil.com/?next=https://x.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('twitter quote command', () => {
|
|
32
|
+
it('navigates to the quote composer and reports success when the script confirms', async () => {
|
|
33
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
34
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
35
|
+
const page = createPageMock([
|
|
36
|
+
{ ok: true, message: 'Quote tweet posted successfully.' },
|
|
37
|
+
]);
|
|
38
|
+
const result = await cmd.func(page, {
|
|
39
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
40
|
+
text: 'great take',
|
|
41
|
+
});
|
|
42
|
+
expect(page.goto).toHaveBeenCalledWith(
|
|
43
|
+
'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
|
|
44
|
+
{ waitUntil: 'load', settleMs: 2500 },
|
|
45
|
+
);
|
|
46
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
47
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 3);
|
|
48
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
49
|
+
// Quote-attachment guard: the script must verify the quoted card rendered
|
|
50
|
+
// before submitting; otherwise we'd silently post a plain tweet without
|
|
51
|
+
// the quote attachment.
|
|
52
|
+
expect(script).toContain('Quote target did not render');
|
|
53
|
+
expect(script).toContain('document.execCommand');
|
|
54
|
+
expect(script).toContain('tweetButton');
|
|
55
|
+
expect(script).toContain('getStatusId(link.href) === tweetId');
|
|
56
|
+
expect(script).toContain('Quote tweet submission did not complete before timeout');
|
|
57
|
+
expect(script).toContain('[role="alert"], [data-testid="toast"]');
|
|
58
|
+
expect(result).toEqual([
|
|
59
|
+
{
|
|
60
|
+
status: 'success',
|
|
61
|
+
message: 'Quote tweet posted successfully.',
|
|
62
|
+
text: 'great take',
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns a failed row when the quote target fails to render', async () => {
|
|
68
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
69
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
70
|
+
const page = createPageMock([
|
|
71
|
+
{ ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' },
|
|
72
|
+
]);
|
|
73
|
+
const result = await cmd.func(page, {
|
|
74
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
75
|
+
text: 'orphaned quote',
|
|
76
|
+
});
|
|
77
|
+
expect(result).toEqual([
|
|
78
|
+
{
|
|
79
|
+
status: 'failed',
|
|
80
|
+
message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.',
|
|
81
|
+
text: 'orphaned quote',
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
// Only the textarea wait should run when ok is false (no extra 3s post-submit wait).
|
|
85
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
89
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
90
|
+
await expect(cmd.func(undefined, {
|
|
91
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
92
|
+
text: 'hi',
|
|
93
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
97
|
+
const cmd = getRegistry().get('twitter/quote');
|
|
98
|
+
const page = createPageMock([]);
|
|
99
|
+
await expect(cmd.func(page, {
|
|
100
|
+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
101
|
+
text: 'hi',
|
|
102
|
+
})).rejects.toThrow(ArgumentError);
|
|
103
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
104
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'retweet',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Retweet a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to retweet' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter retweet');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const tweetId = ${JSON.stringify(target.id)};
|
|
26
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
|
|
27
|
+
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
28
|
+
try {
|
|
29
|
+
const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
|
|
30
|
+
return match?.[1] === tweetId;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// Poll for the tweet to render
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
let retweetBtn = null;
|
|
39
|
+
let unretweetBtn = null;
|
|
40
|
+
let targetArticle = null;
|
|
41
|
+
|
|
42
|
+
while (attempts < 20) {
|
|
43
|
+
targetArticle = findTargetArticle();
|
|
44
|
+
unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
|
|
45
|
+
retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
|
|
46
|
+
|
|
47
|
+
if (unretweetBtn || retweetBtn) break;
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 500));
|
|
50
|
+
attempts++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Already retweeted: idempotent success
|
|
54
|
+
if (unretweetBtn) {
|
|
55
|
+
return { ok: true, message: 'Tweet is already retweeted.' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!retweetBtn) {
|
|
59
|
+
return { ok: false, message: 'Could not find the Retweet button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 1: click Retweet button → opens menu
|
|
63
|
+
retweetBtn.click();
|
|
64
|
+
|
|
65
|
+
// Step 2: wait for the confirm menu item to appear, then click it
|
|
66
|
+
let confirmBtn = null;
|
|
67
|
+
for (let i = 0; i < 20; i++) {
|
|
68
|
+
await new Promise(r => setTimeout(r, 250));
|
|
69
|
+
confirmBtn = document.querySelector('[data-testid="retweetConfirm"]');
|
|
70
|
+
if (confirmBtn) break;
|
|
71
|
+
}
|
|
72
|
+
if (!confirmBtn) {
|
|
73
|
+
return { ok: false, message: 'Retweet menu opened but the confirm option did not appear.' };
|
|
74
|
+
}
|
|
75
|
+
confirmBtn.click();
|
|
76
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
77
|
+
|
|
78
|
+
// Verify success by checking if the 'unretweet' button appeared
|
|
79
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
80
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="unretweet"]');
|
|
81
|
+
if (verifyBtn) {
|
|
82
|
+
return { ok: true, message: 'Tweet successfully retweeted.' };
|
|
83
|
+
} else {
|
|
84
|
+
return { ok: false, message: 'Retweet action was initiated but UI did not update as expected.' };
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { ok: false, message: e.toString() };
|
|
88
|
+
}
|
|
89
|
+
})()`);
|
|
90
|
+
if (result.ok) {
|
|
91
|
+
// Wait for the retweet network request to be processed
|
|
92
|
+
await page.wait(2);
|
|
93
|
+
}
|
|
94
|
+
return [{
|
|
95
|
+
status: result.ok ? 'success' : 'failed',
|
|
96
|
+
message: result.message
|
|
97
|
+
}];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './retweet.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter retweet command', () => {
|
|
8
|
+
it('clicks the retweet button then the confirm menu item and reports success', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully retweeted.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
20
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Two-step UI flow must be present:
|
|
22
|
+
// 1) click the retweet button
|
|
23
|
+
// 2) wait for and click the confirm menu item (data-testid="retweetConfirm")
|
|
24
|
+
expect(script).toContain('retweetBtn.click()');
|
|
25
|
+
expect(script).toContain("document.querySelector('[data-testid=\"retweetConfirm\"]')");
|
|
26
|
+
expect(script).toContain('confirmBtn.click()');
|
|
27
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
28
|
+
expect(script).toContain('match?.[1] === tweetId');
|
|
29
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
30
|
+
// Idempotency probe: when already retweeted ([data-testid="unretweet"] present),
|
|
31
|
+
// the script returns ok:true with an "already retweeted" message.
|
|
32
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
{ status: 'success', message: 'Tweet successfully retweeted.' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a failed row when the confirm menu item never appears', async () => {
|
|
39
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
40
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
41
|
+
const page = createPageMock([
|
|
42
|
+
{ ok: false, message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
43
|
+
]);
|
|
44
|
+
const result = await cmd.func(page, {
|
|
45
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual([
|
|
48
|
+
{ status: 'failed', message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
49
|
+
]);
|
|
50
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
54
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
55
|
+
await expect(cmd.func(undefined, {
|
|
56
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
57
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
61
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
62
|
+
const page = createPageMock([]);
|
|
63
|
+
await expect(cmd.func(page, {
|
|
64
|
+
url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
65
|
+
})).rejects.toThrow(ArgumentError);
|
|
66
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
67
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
package/clis/twitter/shared.js
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
2
|
+
|
|
1
3
|
const QUERY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
4
|
+
const TWEET_PATH_PATTERN = /^\/(?:[^/]+|i)\/status\/(\d+)\/?$/;
|
|
5
|
+
const TWEET_HOSTS = new Set(['x.com', 'twitter.com']);
|
|
6
|
+
|
|
7
|
+
function isTwitterHost(hostname) {
|
|
8
|
+
return TWEET_HOSTS.has(hostname)
|
|
9
|
+
|| hostname.endsWith('.x.com')
|
|
10
|
+
|| hostname.endsWith('.twitter.com');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseTweetUrl(rawUrl) {
|
|
14
|
+
const value = String(rawUrl ?? '').trim();
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new ArgumentError('twitter tweet URL cannot be empty', 'Example: opencli twitter retweet https://x.com/jack/status/20');
|
|
17
|
+
}
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ArgumentError(`Invalid tweet URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
24
|
+
}
|
|
25
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
26
|
+
if (parsed.protocol !== 'https:' || !isTwitterHost(hostname)) {
|
|
27
|
+
throw new ArgumentError(`Invalid tweet URL host: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
28
|
+
}
|
|
29
|
+
const match = parsed.pathname.match(TWEET_PATH_PATTERN);
|
|
30
|
+
if (!match?.[1]) {
|
|
31
|
+
throw new ArgumentError(`Could not extract tweet ID from URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
id: match[1],
|
|
35
|
+
url: parsed.toString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
2
39
|
export function sanitizeQueryId(resolved, fallbackId) {
|
|
3
40
|
return typeof resolved === 'string' && QUERY_ID_PATTERN.test(resolved) ? resolved : fallbackId;
|
|
4
41
|
}
|
|
@@ -65,4 +102,5 @@ export function extractMedia(legacy) {
|
|
|
65
102
|
export const __test__ = {
|
|
66
103
|
sanitizeQueryId,
|
|
67
104
|
extractMedia,
|
|
105
|
+
parseTweetUrl,
|
|
68
106
|
};
|
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { __test__ } from './shared.js';
|
|
3
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
4
|
|
|
4
|
-
const { extractMedia } = __test__;
|
|
5
|
+
const { extractMedia, parseTweetUrl } = __test__;
|
|
6
|
+
|
|
7
|
+
describe('twitter parseTweetUrl', () => {
|
|
8
|
+
it('accepts exact Twitter/X tweet URLs and preserves query parameters', () => {
|
|
9
|
+
expect(parseTweetUrl('https://x.com/alice/status/2040254679301718161?s=20')).toEqual({
|
|
10
|
+
id: '2040254679301718161',
|
|
11
|
+
url: 'https://x.com/alice/status/2040254679301718161?s=20',
|
|
12
|
+
});
|
|
13
|
+
expect(parseTweetUrl('https://mobile.twitter.com/i/status/2040318731105313143')).toEqual({
|
|
14
|
+
id: '2040318731105313143',
|
|
15
|
+
url: 'https://mobile.twitter.com/i/status/2040318731105313143',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects non-https, off-domain, host-suffix, embedded, and path-suffix URLs', () => {
|
|
20
|
+
const invalid = [
|
|
21
|
+
'http://x.com/alice/status/2040254679301718161',
|
|
22
|
+
'https://evil.com/alice/status/2040254679301718161',
|
|
23
|
+
'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
24
|
+
'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
25
|
+
'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
26
|
+
];
|
|
27
|
+
for (const url of invalid) {
|
|
28
|
+
expect(() => parseTweetUrl(url)).toThrow(ArgumentError);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
5
32
|
|
|
6
33
|
describe('twitter extractMedia', () => {
|
|
7
34
|
it('returns false + empty list when legacy has no media', () => {
|