diff --git a/adapters/twitter/followers.yaml b/adapters/twitter/followers.yaml
index 118ac87..88480c3 100644
--- a/adapters/twitter/followers.yaml
+++ b/adapters/twitter/followers.yaml
@@ -44,20 +44,14 @@ pipeline:
pattern: Followers
collect: false
- # Step 5: Click the followers link (SPA navigation, preserves interceptor)
+ # Step 5: Navigate specifically to the full followers timeline.
+ # Avoid /verified_followers: it can legitimately be empty even when the
+ # account has ordinary followers. This mirrors current OpenCLI behavior.
- evaluate: |
- (async () => {
- const target = data;
- const selectors = [
- 'a[href="/' + target + '/verified_followers"]',
- 'a[href="/' + target + '/followers"]',
- ];
- for (const sel of selectors) {
- const link = document.querySelector(sel);
- if (link) { link.click(); return true; }
- }
- // Fallback: try navigating directly via SPA
- window.location.href = 'https://x.com/' + target + '/followers';
+ (() => {
+ const targetPath = '/' + data + '/followers';
+ window.history.pushState({}, '', targetPath);
+ window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
return true;
})()
@@ -77,26 +71,26 @@ pipeline:
const seen = new Map();
for (const req of requests) {
try {
- let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
- if (!instructions) continue;
- let addEntries = instructions.find(i => i.type === 'TimelineAddEntries')
- || instructions.find(i => i.entries && Array.isArray(i.entries));
- if (!addEntries) continue;
- for (const entry of addEntries.entries) {
- if (!entry.entryId.startsWith('user-')) continue;
- const item = entry.content?.itemContent?.user_results?.result;
- if (!item || item.__typename !== 'User') continue;
- const core = item.core || {};
- const legacy = item.legacy || {};
- const sn = core.screen_name || legacy.screen_name || 'unknown';
- if (!seen.has(sn)) {
- seen.set(sn, true);
- results.push({
- screen_name: sn,
- name: core.name || legacy.name || 'unknown',
- bio: legacy.description || item.profile_bio?.description || '',
- followers: legacy.followers_count || legacy.normal_followers_count || 0
- });
+ let instructions = req.data?.user?.result?.timeline_v2?.timeline?.instructions
+ || req.data?.user?.result?.timeline?.timeline?.instructions
+ || [];
+ for (const instruction of instructions) {
+ for (const entry of instruction.entries || []) {
+ if (!entry.entryId.startsWith('user-')) continue;
+ const item = entry.content?.itemContent?.user_results?.result;
+ if (!item || item.__typename !== 'User') continue;
+ const core = item.core || {};
+ const legacy = item.legacy || {};
+ const sn = core.screen_name || legacy.screen_name || 'unknown';
+ if (!seen.has(sn)) {
+ seen.set(sn, true);
+ results.push({
+ screen_name: sn,
+ name: core.name || legacy.name || 'unknown',
+ bio: legacy.description || item.profile_bio?.description || '',
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
+ });
+ }
}
}
} catch {}
diff --git a/adapters/twitter/following.yaml b/adapters/twitter/following.yaml
index 9aced8e..589348b 100644
--- a/adapters/twitter/following.yaml
+++ b/adapters/twitter/following.yaml
@@ -71,26 +71,26 @@ pipeline:
const seen = new Map();
for (const req of requests) {
try {
- let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
- if (!instructions) continue;
- let addEntries = instructions.find(i => i.type === 'TimelineAddEntries')
- || instructions.find(i => i.entries && Array.isArray(i.entries));
- if (!addEntries) continue;
- for (const entry of addEntries.entries) {
- if (!entry.entryId.startsWith('user-')) continue;
- const item = entry.content?.itemContent?.user_results?.result;
- if (!item || item.__typename !== 'User') continue;
- const core = item.core || {};
- const legacy = item.legacy || {};
- const sn = core.screen_name || legacy.screen_name || 'unknown';
- if (!seen.has(sn)) {
- seen.set(sn, true);
- results.push({
- screen_name: sn,
- name: core.name || legacy.name || 'unknown',
- bio: legacy.description || item.profile_bio?.description || '',
- followers: legacy.followers_count || legacy.normal_followers_count || 0
- });
+ let instructions = req.data?.user?.result?.timeline_v2?.timeline?.instructions
+ || req.data?.user?.result?.timeline?.timeline?.instructions
+ || [];
+ for (const instruction of instructions) {
+ for (const entry of instruction.entries || []) {
+ if (!entry.entryId.startsWith('user-')) continue;
+ const item = entry.content?.itemContent?.user_results?.result;
+ if (!item || item.__typename !== 'User') continue;
+ const core = item.core || {};
+ const legacy = item.legacy || {};
+ const sn = core.screen_name || legacy.screen_name || 'unknown';
+ if (!seen.has(sn)) {
+ seen.set(sn, true);
+ results.push({
+ screen_name: sn,
+ name: core.name || legacy.name || 'unknown',
+ bio: legacy.description || item.profile_bio?.description || '',
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
+ });
+ }
}
}
} catch {}
diff --git a/adapters/twitter/notifications.yaml b/adapters/twitter/notifications.yaml
index 6090b05..e5bb8fa 100644
--- a/adapters/twitter/notifications.yaml
+++ b/adapters/twitter/notifications.yaml
@@ -32,8 +32,8 @@ pipeline:
- wait: 5
- scroll:
- times: 2
- delayMs: 2000
+ count: 2
+ delay: 2000
- collect:
parse: |
diff --git a/adapters/twitter/reply.yaml b/adapters/twitter/reply.yaml
index cb9579c..0cde6b1 100644
--- a/adapters/twitter/reply.yaml
+++ b/adapters/twitter/reply.yaml
@@ -22,32 +22,97 @@ args:
columns: [status, message, text]
pipeline:
+ # Open a known-safe X page first, then parse the caller-supplied URL in JS.
+ # Navigating directly to args.url is rejected by some AutoCLI/extension paths
+ # before the browser ever reaches X.
- navigate:
- url: ${{ args.url }}
- settleMs: 5000
+ url: https://x.com/home
+ settleMs: 2500
+
+ - evaluate: |
+ (() => {
+ const first = String(${{ args.url | json }} || '').trim();
+ const second = String(${{ args.text | json }} || '').trim();
+ const statusRe = /^https?:\/\/(?:www\.)?(?:x\.com|twitter\.com|mobile\.twitter\.com)\/(?:[^/]+|i)\/status\/(\d+)(?:[/?#].*)?$/i;
+ const match = first.match(statusRe) || second.match(statusRe);
+ if (!match) throw new Error('Reply requires one x.com or twitter.com status URL argument.');
+ return match[1];
+ })()
+
+ - navigate:
+ url: "https://x.com/compose/post?in_reply_to=${{ data }}"
+ settleMs: 2500
- evaluate: |
(async () => {
try {
- const replyText = ${{ args.text | json }};
- const box = document.querySelector('[data-testid="tweetTextarea_0"]');
- if (box) {
- box.focus();
- document.execCommand('insertText', false, replyText);
- } else {
- return [{ status: 'failed', message: 'Could not find the reply text area. Are you logged in?', text: replyText }];
- }
-
- await new Promise(r => setTimeout(r, 1000));
-
- const btn = document.querySelector('[data-testid="tweetButtonInline"]');
- if (btn && !btn.disabled) {
- btn.click();
- await new Promise(r => setTimeout(r, 3000));
- return [{ status: 'success', message: 'Reply posted successfully.', text: replyText }];
- } else {
+ const first = String(${{ args.url | json }} || '').trim();
+ const second = String(${{ args.text | json }} || '').trim();
+ const statusRe = /^https?:\/\/(?:www\.)?(?:x\.com|twitter\.com|mobile\.twitter\.com)\/(?:[^/]+|i)\/status\/\d+(?:[/?#].*)?$/i;
+ const replyText = statusRe.test(first) ? second : first;
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
+
+ let box = null;
+ for (let i = 0; i < 30; i++) {
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
+ box = boxes.find(visible) || boxes[0] || null;
+ if (box) break;
+ await sleep(500);
+ }
+ if (!box) {
+ return [{ status: 'failed', message: 'Could not open the reply composer. Are you logged in?', text: replyText }];
+ }
+
+ box.focus();
+ if (!document.execCommand('insertText', false, replyText)) {
+ const transfer = new DataTransfer();
+ transfer.setData('text/plain', replyText);
+ box.dispatchEvent(new ClipboardEvent('paste', {
+ clipboardData: transfer,
+ bubbles: true,
+ cancelable: true,
+ }));
+ }
+ await sleep(1000);
+
+ const normalize = (s) => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
+ const actual = box.innerText || box.textContent || '';
+ if (!normalize(actual).includes(normalize(replyText))) {
+ return [{ status: 'failed', message: 'Could not verify reply text in the composer after typing.', text: replyText }];
+ }
+
+ for (const toast of Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))) {
+ if (visible(toast)) toast.setAttribute('data-autocli-before-reply-toast', 'true');
+ }
+
+ let button = null;
+ for (let i = 0; i < 30; i++) {
+ const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'));
+ button = buttons.find(el => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true') || null;
+ if (button) break;
+ await sleep(500);
+ }
+ if (!button) {
return [{ status: 'failed', message: 'Reply button is disabled or not found.', text: replyText }];
}
+ button.click();
+
+ for (let i = 0; i < 30; i++) {
+ await sleep(500);
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
+ .filter(el => visible(el) && !el.hasAttribute('data-autocli-before-reply-toast'));
+ const success = toasts.find(el => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
+ if (success) {
+ return [{ status: 'success', message: 'Reply posted successfully.', text: replyText }];
+ }
+ const failure = toasts.find(el => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
+ if (failure) {
+ return [{ status: 'failed', message: (failure.textContent || 'Reply failed to post.').trim(), text: replyText }];
+ }
+ }
+
+ return [{ status: 'failed', message: 'Reply submission could not be confirmed. Check the tweet before retrying; the reply may already be live.', text: replyText }];
} catch (e) {
return [{ status: 'failed', message: e.toString(), text: ${{ args.text | json }} }];
}
diff --git a/crates/autocli-pipeline/src/steps/browser.rs b/crates/autocli-pipeline/src/steps/browser.rs
index 89dbf3e..f9e2aba 100644
--- a/crates/autocli-pipeline/src/steps/browser.rs
+++ b/crates/autocli-pipeline/src/steps/browser.rs
@@ -597,8 +597,8 @@ impl StepHandler for CollectStep {
let js = format!(
r#"(() => {{
const args = {args_json};
- const requests = window.__opencli_intercepted || [];
- window.__opencli_intercepted = [];
+ const requests = window.__autocli_intercepted || [];
+ window.__autocli_intercepted = [];
const parseFn = {parse_fn};
return parseFn(requests);
}})()"#
@@ -643,6 +643,7 @@ mod tests {
struct MockPage {
goto_url: std::sync::Mutex