Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -23244,7 +23244,9 @@
"columns": [
"status",
"message",
"text"
"text",
"id",
"url"
],
"type": "js",
"modulePath": "twitter/post.js",
Expand Down Expand Up @@ -23460,7 +23462,7 @@
"description": "Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X's search operators",
"access": "read",
"domain": "x.com",
"strategy": "intercept",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand Down Expand Up @@ -23553,7 +23555,7 @@
"type": "js",
"modulePath": "twitter/search.js",
"sourceFile": "twitter/search.js",
"navigateBefore": true,
"navigateBefore": "https://x.com",
"siteSession": "persistent"
},
{
Expand Down
27 changes: 23 additions & 4 deletions clis/twitter/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,25 @@ async function submitTweet(page, text) {
const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const expectedText = normalize(expected);
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const statusUrl = (root = document) => {
const links = Array.from(root.querySelectorAll('a[href*="/status/"]'));
for (const link of links) {
const href = link.href || link.getAttribute('href') || '';
if (!href) continue;
try {
const url = new URL(href, window.location.origin);
const match = url.pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)/);
if (match) return { url: url.href, id: match[1] };
} catch {}
}
return {};
};
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
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: 'Tweet posted successfully.' };
if (successToast) return { ok: true, message: 'Tweet posted successfully.', ...statusUrl(successToast) };
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 || 'Tweet failed to post.').trim() };

Expand All @@ -175,7 +188,7 @@ async function submitTweet(page, text) {
const hasMedia = !!document.querySelector('[data-testid="attachments"], [data-testid="tweetPhoto"]')
|| document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length > 0;
if (!composerStillHasText && !hasMedia) {
return { ok: true, message: 'Tweet posted successfully.' };
return { ok: true, message: 'Tweet posted successfully.', ...statusUrl() };
}
}
return { ok: false, message: 'Tweet submission did not complete before timeout.' };
Expand All @@ -194,7 +207,7 @@ cli({
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
{ name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
],
columns: ['status', 'message', 'text'],
columns: ['status', 'message', 'text', 'id', 'url'],
func: async (page, kwargs) => {
if (!page)
throw new CommandExecutionError('Browser session required for twitter post');
Expand Down Expand Up @@ -231,6 +244,12 @@ cli({

await page.wait(1);
const result = await submitTweet(page, text);
return [{ status: result?.ok ? 'success' : 'failed', message: result?.message ?? 'Tweet failed to post.', text }];
return [{
status: result?.ok ? 'success' : 'failed',
message: result?.message ?? 'Tweet failed to post.',
text,
...(result?.id ? { id: result.id } : {}),
...(result?.url ? { url: result.url } : {}),
}];
}
});
30 changes: 30 additions & 0 deletions clis/twitter/post.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ function makePage(evaluateResults = [], overrides = {}) {
describe('twitter post command', () => {
const getCommand = () => getRegistry().get('twitter/post');

it('registers created tweet id/url columns', () => {
const command = getCommand();
expect(command?.columns).toEqual(['status', 'message', 'text', 'id', 'url']);
});

it('posts text-only tweet successfully through the current compose route', async () => {
const command = getCommand();
const page = makePage([
Expand All @@ -63,6 +68,31 @@ describe('twitter post command', () => {
expect(page.insertText).toHaveBeenCalledWith('hello world');
});

it('returns the created tweet URL from the success toast when available', async () => {
const command = getCommand();
const page = makePage([
{ ok: true },
{ ok: true },
{ ok: true },
{
ok: true,
message: 'Tweet posted successfully.',
id: '2054239044884693381',
url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
},
]);

const result = await command.func(page, { text: 'with url' });

expect(result).toEqual([{
status: 'success',
message: 'Tweet posted successfully.',
text: 'with url',
id: '2054239044884693381',
url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
}]);
});

it('returns failed when text area not found', async () => {
const command = getCommand();
const page = makePage([
Expand Down
Loading
Loading