9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
173 lines
7.9 KiB
JavaScript
173 lines
7.9 KiB
JavaScript
import * as fs from 'node:fs';
|
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
import {
|
|
COMPOSER_FILE_INPUT_SELECTOR,
|
|
attachComposerImage,
|
|
downloadRemoteImage,
|
|
resolveImagePath,
|
|
} from './utils.js';
|
|
|
|
function buildQuoteComposerUrl(url) {
|
|
// Twitter/X quote-tweet compose URL: the `url` param attaches the source
|
|
// tweet as a quoted card. Validating tweet-id shape early surfaces obvious
|
|
// typos before any browser interaction.
|
|
const parsed = parseTweetUrl(url);
|
|
return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
|
|
}
|
|
|
|
async function submitQuote(page, text, tweetId) {
|
|
return page.evaluate(`(async () => {
|
|
try {
|
|
${buildTwitterArticleScopeSource(tweetId)}
|
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
const box = boxes.find(visible) || boxes[0];
|
|
if (!box) {
|
|
return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
|
|
}
|
|
|
|
box.focus();
|
|
const textToInsert = ${JSON.stringify(text)};
|
|
// execCommand('insertText') is more reliable with Twitter's Draft.js editor.
|
|
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
// Fallback to paste event if execCommand fails.
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.setData('text/plain', textToInsert);
|
|
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
clipboardData: dataTransfer,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
|
|
// Confirm the quoted card is rendered before submitting; otherwise
|
|
// we may accidentally post a plain tweet without the quote
|
|
// attachment. The compose page does not wrap the card in an
|
|
// <article>, so we probe the document for any link whose path
|
|
// exactly matches the requested status id (uses __twHasLinkToTarget
|
|
// from buildTwitterArticleScopeSource).
|
|
let cardAttempts = 0;
|
|
let hasQuoteCard = false;
|
|
while (cardAttempts < 20) {
|
|
hasQuoteCard = __twHasLinkToTarget(document);
|
|
if (hasQuoteCard) break;
|
|
await new Promise(r => setTimeout(r, 250));
|
|
cardAttempts++;
|
|
}
|
|
if (!hasQuoteCard) {
|
|
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
|
|
}
|
|
|
|
let btn = null;
|
|
for (let i = 0; i < 30; i++) {
|
|
const buttons = Array.from(
|
|
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
);
|
|
btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
if (btn) break;
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
if (!btn) {
|
|
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
}
|
|
|
|
btn.click();
|
|
|
|
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
const expectedText = normalize(textToInsert);
|
|
for (let i = 0; i < 30; i++) {
|
|
await new Promise(r => setTimeout(r, 500));
|
|
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
.filter((el) => visible(el));
|
|
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
|
|
|
|
const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
const composerStillHasText = visibleBoxes.some((box) =>
|
|
normalize(box.innerText || box.textContent || '').includes(expectedText)
|
|
);
|
|
if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
}
|
|
return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
|
|
} catch (e) {
|
|
return { ok: false, message: e.toString() };
|
|
}
|
|
})()`);
|
|
}
|
|
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'quote',
|
|
access: 'write',
|
|
description: 'Quote-tweet a specific tweet with your own text, optionally with a local or remote image',
|
|
domain: 'x.com',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
|
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
|
|
{ name: 'image', help: 'Optional local image path to attach to the quote tweet' },
|
|
{ name: 'image-url', help: 'Optional remote image URL to download and attach to the quote tweet' },
|
|
],
|
|
columns: ['status', 'message', 'text'],
|
|
func: async (page, kwargs) => {
|
|
if (!page)
|
|
throw new CommandExecutionError('Browser session required for twitter quote');
|
|
if (kwargs.image && kwargs['image-url']) {
|
|
throw new CommandExecutionError('Use either --image or --image-url, not both.');
|
|
}
|
|
|
|
// Validate URL (typed ArgumentError on malformed/off-domain inputs)
|
|
// before any browser interaction or remote image download.
|
|
const target = parseTweetUrl(kwargs.url);
|
|
|
|
let localImagePath;
|
|
let cleanupDir;
|
|
try {
|
|
if (kwargs.image) {
|
|
localImagePath = resolveImagePath(kwargs.image);
|
|
} else if (kwargs['image-url']) {
|
|
const downloaded = await downloadRemoteImage(kwargs['image-url']);
|
|
localImagePath = downloaded.absPath;
|
|
cleanupDir = downloaded.cleanupDir;
|
|
}
|
|
|
|
// Dedicated composer is more reliable than the inline quote-tweet button.
|
|
await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
|
|
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
|
|
if (localImagePath) {
|
|
await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
await attachComposerImage(page, localImagePath);
|
|
}
|
|
|
|
const result = await submitQuote(page, kwargs.text, target.id);
|
|
if (result.ok) {
|
|
// Wait for network submission to complete
|
|
await page.wait(3);
|
|
}
|
|
return [{
|
|
status: result.ok ? 'success' : 'failed',
|
|
message: result.message,
|
|
text: kwargs.text,
|
|
...(kwargs.image ? { image: kwargs.image } : {}),
|
|
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
|
|
}];
|
|
} finally {
|
|
if (cleanupDir) {
|
|
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
export const __test__ = {
|
|
buildQuoteComposerUrl,
|
|
};
|