use tail_fin_common::js::escape_js_string;
use tail_fin_common::page::navigate_and_eval;
use tail_fin_common::parse_action_result;
use tail_fin_common::BrowserSession;
use tail_fin_common::TailFinError;
use crate::types::{ActionResult, MediaItem, Trend};
pub(crate) async fn browser_post(
session: &BrowserSession,
text: &str,
) -> Result<ActionResult, TailFinError> {
session.navigate("https://x.com/compose/tweet").await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = escape_js_string(text);
let js = format!(
r#"(async () => {{
try {{
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
if (!box) return JSON.stringify({{ status: "failed", message: "Could not find tweet composer." }});
box.focus();
const dt = new DataTransfer();
dt.setData('text/plain', "{escaped}");
box.dispatchEvent(new ClipboardEvent('paste', {{ clipboardData: dt, bubbles: true, cancelable: true }}));
await new Promise(r => setTimeout(r, 1000));
const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
if (btn && !btn.disabled) {{ btn.click(); return JSON.stringify({{ status: "success", message: "Tweet posted." }}); }}
return JSON.stringify({{ status: "failed", message: "Tweet button disabled or not found." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
);
parse_action_result(session.eval(&js).await?)
}
pub(crate) async fn browser_like(
session: &BrowserSession,
tweet_url: &str,
) -> Result<ActionResult, TailFinError> {
let js = r#"(async () => {
try {
for (let i = 0; i < 20; i++) {
const unlike = document.querySelector('[data-testid="unlike"]');
if (unlike) return JSON.stringify({ status: "success", message: "Tweet is already liked." });
const btn = document.querySelector('[data-testid="like"]');
if (btn) {
btn.click();
await new Promise(r => setTimeout(r, 1000));
const verify = document.querySelector('[data-testid="unlike"]');
if (verify) return JSON.stringify({ status: "success", message: "Tweet liked." });
return JSON.stringify({ status: "failed", message: "Like action did not register." });
}
await new Promise(r => setTimeout(r, 500));
}
return JSON.stringify({ status: "failed", message: "Like button not found." });
} catch (e) { return JSON.stringify({ status: "failed", message: e.toString() }); }
})()"#;
parse_action_result(navigate_and_eval(session, tweet_url, 2, js).await?)
}
pub(crate) async fn browser_follow(
session: &BrowserSession,
username: &str,
follow: bool,
) -> Result<ActionResult, TailFinError> {
let username = username.trim_start_matches('@');
session
.navigate(&format!("https://x.com/{}", username))
.await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let (target_testid, verify_testid, action_word) = if follow {
("-follow", "-unfollow", "followed")
} else {
("-unfollow", "-follow", "unfollowed")
};
let js = format!(
r#"(async () => {{
try {{
for (let i = 0; i < 20; i++) {{
const btn = document.querySelector('[data-testid$="{target_testid}"]');
if (btn) {{
btn.click();
await new Promise(r => setTimeout(r, 1500));
const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
return JSON.stringify({{ status: "success", message: "Successfully {action_word} @{username}." }});
}}
const verify = document.querySelector('[data-testid$="{verify_testid}"]');
if (verify) return JSON.stringify({{ status: "success", message: "Already {action_word} @{username}." }});
await new Promise(r => setTimeout(r, 500));
}}
return JSON.stringify({{ status: "failed", message: "Button not found." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
);
parse_action_result(session.eval(&js).await?)
}
pub(crate) async fn browser_delete(
session: &BrowserSession,
tweet_url: &str,
) -> Result<ActionResult, TailFinError> {
let js = r#"(async () => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const articles = Array.from(document.querySelectorAll('article'));
if (!articles.length) return JSON.stringify({ status: "failed", message: "Tweet not found on page." });
for (const article of articles) {
const buttons = Array.from(article.querySelectorAll('button,[role="button"]'));
const moreMenu = buttons.find(el => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
if (!moreMenu) continue;
moreMenu.click();
await new Promise(r => setTimeout(r, 1000));
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
const deleteBtn = items.find(item => {
const text = (item.textContent || '').trim();
return text.includes('Delete') && !text.includes('List');
});
if (!deleteBtn) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await new Promise(r => setTimeout(r, 500));
continue;
}
deleteBtn.click();
await new Promise(r => setTimeout(r, 1000));
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) {
confirmBtn.click();
return JSON.stringify({ status: "success", message: "Tweet deleted." });
}
return JSON.stringify({ status: "failed", message: "Delete confirmation did not appear." });
}
return JSON.stringify({ status: "failed", message: "Delete option not found. This tweet may not belong to you." });
} catch (e) { return JSON.stringify({ status: "failed", message: e.toString() }); }
})()"#;
parse_action_result(navigate_and_eval(session, tweet_url, 2, js).await?)
}
pub(crate) async fn browser_block(
session: &BrowserSession,
username: &str,
block: bool,
) -> Result<ActionResult, TailFinError> {
let username = username.trim_start_matches('@');
session
.navigate(&format!("https://x.com/{}", username))
.await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let js = if block {
format!(
r#"(async () => {{
try {{
const blocked = document.querySelector('[data-testid$="-unblock"]');
if (blocked) return JSON.stringify({{ status: "success", message: "Already blocking @{username}." }});
const moreBtn = document.querySelector('[data-testid="userActions"]');
if (!moreBtn) return JSON.stringify({{ status: "failed", message: "User actions menu not found." }});
moreBtn.click();
await new Promise(r => setTimeout(r, 1000));
const items = document.querySelectorAll('[role="menuitem"]');
let blockItem = null;
for (const item of items) {{ if (item.textContent && item.textContent.includes('Block')) {{ blockItem = item; break; }} }}
if (!blockItem) return JSON.stringify({{ status: "failed", message: "Block option not found." }});
blockItem.click();
await new Promise(r => setTimeout(r, 1000));
const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
return JSON.stringify({{ status: "success", message: "Blocked @{username}." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
)
} else {
format!(
r#"(async () => {{
try {{
for (let i = 0; i < 20; i++) {{
const followBtn = document.querySelector('[data-testid="{username}-follow"]');
if (followBtn) return JSON.stringify({{ status: "success", message: "Not blocking @{username} (already unblocked)." }});
const unblockBtn = document.querySelector('[data-testid="{username}-unblock"]') || document.querySelector('[data-testid$="-unblock"]');
if (unblockBtn) {{
unblockBtn.click();
await new Promise(r => setTimeout(r, 1000));
const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
return JSON.stringify({{ status: "success", message: "Unblocked @{username}." }});
}}
await new Promise(r => setTimeout(r, 500));
}}
return JSON.stringify({{ status: "failed", message: "Unblock button not found." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
)
};
parse_action_result(session.eval(&js).await?)
}
pub(crate) async fn browser_reply(
session: &BrowserSession,
tweet_url: &str,
text: &str,
) -> Result<ActionResult, TailFinError> {
let tweet_id = crate::util::extract_tweet_id(tweet_url);
let compose_url = format!("https://x.com/compose/post?in_reply_to={}", tweet_id);
session.navigate(&compose_url).await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let escaped = escape_js_string(text);
let js = format!(
r#"(async () => {{
try {{
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
const box = boxes.find(el => el.offsetParent !== null) || boxes[0];
if (!box) return JSON.stringify({{ status: "failed", message: "Reply text area not found." }});
box.focus();
const dt = new DataTransfer();
dt.setData('text/plain', "{escaped}");
box.dispatchEvent(new ClipboardEvent('paste', {{ clipboardData: dt, bubbles: true, cancelable: true }}));
await new Promise(r => setTimeout(r, 1000));
const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'));
const btn = buttons.find(el => el.offsetParent !== null && !el.disabled);
if (!btn) return JSON.stringify({{ status: "failed", message: "Reply button disabled or not found." }});
btn.click();
return JSON.stringify({{ status: "success", message: "Reply posted." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
);
parse_action_result(session.eval(&js).await?)
}
pub(crate) async fn browser_trending(
session: &BrowserSession,
count: usize,
) -> Result<Vec<Trend>, TailFinError> {
let js = r#"(() => {
const items = [];
const cells = document.querySelectorAll('[data-testid="trend"]');
cells.forEach((cell) => {
const text = cell.textContent || '';
if (text.includes('Promoted')) return;
const container = cell.querySelector(':scope > div');
if (!container) return;
const divs = container.children;
if (divs.length < 2) return;
const topic = divs[1].textContent.trim();
if (!topic) return;
const catText = divs[0].textContent.trim();
const category = catText.replace(/^\d+\s*/, '').replace(/^\xB7\s*/, '').trim();
let tweets = 'N/A';
for (let j = 2; j < divs.length; j++) {
if (divs[j].matches('[data-testid="caret"]') || divs[j].querySelector('[data-testid="caret"]')) continue;
const t = divs[j].textContent.trim();
if (t && /\d/.test(t)) { tweets = t; break; }
}
items.push(JSON.stringify({ rank: items.length + 1, topic, tweets, category }));
});
return '[' + items.join(',') + ']';
})()"#;
let result = navigate_and_eval(session, "https://x.com/explore/tabs/trending", 3, js).await?;
let s = result.as_str().unwrap_or("[]");
let trends: Vec<Trend> = serde_json::from_str(s).unwrap_or_default();
Ok(trends.into_iter().take(count).collect())
}
pub(crate) async fn browser_download(
session: &BrowserSession,
username: &str,
count: usize,
) -> Result<Vec<MediaItem>, TailFinError> {
let username = username.trim_start_matches('@');
session
.navigate(&format!("https://x.com/{}/media", username))
.await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
tail_fin_common::page::scroll_to_load(session, count / 5 + 1, 2000).await?;
let js = r#"(() => {
const media = [];
const seen = new Set();
document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
let src = img.src || '';
src = src.replace(/&name=\w+$/, '&name=large');
if (!src.includes('&name=')) src = src + '&name=large';
if (!seen.has(src)) { seen.add(src); media.push(JSON.stringify({ type: 'image', url: src })); }
});
document.querySelectorAll('video').forEach(video => {
const src = video.src || '';
if (src && !seen.has(src)) { seen.add(src); media.push(JSON.stringify({ type: 'video', url: src })); }
});
return '[' + media.join(',') + ']';
})()"#;
let result = session.eval(js).await?;
let s = result.as_str().unwrap_or("[]");
let media: Vec<MediaItem> = serde_json::from_str(s).unwrap_or_default();
Ok(media.into_iter().take(count).collect())
}
pub(crate) async fn browser_bookmark(
session: &BrowserSession,
tweet_url: &str,
add: bool,
) -> Result<ActionResult, TailFinError> {
let (target, already_state, success_msg, already_msg) = if add {
(
"bookmark",
"removeBookmark",
"Tweet bookmarked.",
"Tweet is already bookmarked.",
)
} else {
(
"removeBookmark",
"bookmark",
"Bookmark removed.",
"Tweet is not bookmarked.",
)
};
let js = format!(
r#"(async () => {{
try {{
for (let i = 0; i < 20; i++) {{
const already = document.querySelector('[data-testid="{already_state}"]');
if (already) return JSON.stringify({{ status: "success", message: "{already_msg}" }});
const btn = document.querySelector('[data-testid="{target}"]');
if (btn) {{
btn.click();
await new Promise(r => setTimeout(r, 1000));
const verify = document.querySelector('[data-testid="{already_state}"]');
if (verify) return JSON.stringify({{ status: "success", message: "{success_msg}" }});
return JSON.stringify({{ status: "failed", message: "Action did not register." }});
}}
await new Promise(r => setTimeout(r, 500));
}}
return JSON.stringify({{ status: "failed", message: "Bookmark button not found." }});
}} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
}})()"#
);
parse_action_result(navigate_and_eval(session, tweet_url, 2, &js).await?)
}
pub(crate) async fn browser_hide_reply(
session: &BrowserSession,
tweet_url: &str,
) -> Result<ActionResult, TailFinError> {
let js = r#"(async () => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const articles = Array.from(document.querySelectorAll('article'));
if (!articles.length) return JSON.stringify({ status: "failed", message: "Tweet not found on page." });
for (const article of articles) {
const buttons = Array.from(article.querySelectorAll('button,[role="button"]'));
const moreMenu = buttons.find(el => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
if (!moreMenu) continue;
moreMenu.click();
await new Promise(r => setTimeout(r, 1000));
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
const hideBtn = items.find(item => {
const text = (item.textContent || '').trim();
return text.includes('Hide reply');
});
if (!hideBtn) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await new Promise(r => setTimeout(r, 500));
continue;
}
hideBtn.click();
await new Promise(r => setTimeout(r, 1000));
return JSON.stringify({ status: "success", message: "Reply hidden." });
}
return JSON.stringify({ status: "failed", message: "Hide reply option not found. You may not own the parent tweet." });
} catch (e) { return JSON.stringify({ status: "failed", message: e.toString() }); }
})()"#;
parse_action_result(navigate_and_eval(session, tweet_url, 2, js).await?)
}
pub(crate) async fn browser_lists(
session: &BrowserSession,
count: usize,
) -> Result<Vec<crate::types::TwitterList>, TailFinError> {
let js = r#"(() => {
const lists = [];
const links = document.querySelectorAll('a[href*="/i/lists/"]');
const seen = new Set();
links.forEach(link => {
const href = link.getAttribute('href') || '';
const match = href.match(/\/i\/lists\/(\d+)/);
if (!match || seen.has(match[1])) return;
seen.add(match[1]);
const container = link.closest('[data-testid="cellInnerDiv"]') || link;
const text = container.textContent || '';
const spans = Array.from(link.querySelectorAll('span'));
const nameSpan = spans.find(s => s.textContent.trim().length > 0 && !s.textContent.includes('members') && !s.textContent.includes('follower'));
const name = nameSpan ? nameSpan.textContent.trim() : '';
if (!name) return;
let memberCount = 0;
let followerCount = 0;
const nums = text.match(/(\d+)\s*members?/i);
if (nums) memberCount = parseInt(nums[1], 10);
const fols = text.match(/(\d+)\s*followers?/i);
if (fols) followerCount = parseInt(fols[1], 10);
const isPrivate = !!container.querySelector('[data-testid="icon-lock"]') ||
text.toLowerCase().includes('private');
lists.push(JSON.stringify({
id: match[1],
name,
member_count: memberCount,
follower_count: followerCount,
is_private: isPrivate
}));
});
return '[' + lists.join(',') + ']';
})()"#;
let result = navigate_and_eval(session, "https://x.com/i/lists", 3, js).await?;
let s = result.as_str().unwrap_or("[]");
let lists: Vec<crate::types::TwitterList> = serde_json::from_str(s).unwrap_or_default();
Ok(lists.into_iter().take(count).collect())
}
pub(crate) async fn browser_reply_dm(
session: &BrowserSession,
text: &str,
count: usize,
) -> Result<Vec<crate::types::DmResult>, TailFinError> {
session.navigate("https://x.com/messages").await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for _ in 0..(count / 5 + 1) {
session
.eval(
"document.querySelector('[data-testid=\"DmScrollerContainer\"]')?.scrollBy(0, 500)",
)
.await?;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
let escaped = escape_js_string(text);
let js = format!(
r#"(async () => {{
const results = [];
const convos = Array.from(document.querySelectorAll('[data-testid="conversation"]')).slice(0, {count});
for (let i = 0; i < convos.length; i++) {{
const convo = convos[i];
const name = convo.textContent.trim().split('\n')[0] || ('Conversation ' + (i + 1));
try {{
convo.click();
await new Promise(r => setTimeout(r, 2000));
const box = document.querySelector('[data-testid="dmComposerTextInput"]');
if (!box) {{
results.push(JSON.stringify({{ conversation: name, status: "skipped", message: "No input box found" }}));
continue;
}}
box.focus();
const dt = new DataTransfer();
dt.setData('text/plain', "{escaped}");
box.dispatchEvent(new ClipboardEvent('paste', {{ clipboardData: dt, bubbles: true, cancelable: true }}));
await new Promise(r => setTimeout(r, 500));
const sendBtn = document.querySelector('[data-testid="dmComposerSendButton"]');
if (sendBtn && !sendBtn.disabled) {{
sendBtn.click();
await new Promise(r => setTimeout(r, 1500));
results.push(JSON.stringify({{ conversation: name, status: "sent", message: "Message sent" }}));
}} else {{
results.push(JSON.stringify({{ conversation: name, status: "failed", message: "Send button not found or disabled" }}));
}}
}} catch (e) {{
results.push(JSON.stringify({{ conversation: name, status: "error", message: e.toString() }}));
}}
}}
return '[' + results.join(',') + ']';
}})()"#
);
let result = session.eval(&js).await?;
let s = result.as_str().unwrap_or("[]");
let dm_results: Vec<crate::types::DmResult> = serde_json::from_str(s).unwrap_or_default();
Ok(dm_results)
}
pub(crate) async fn browser_accept(
session: &BrowserSession,
filter: Option<&str>,
count: usize,
) -> Result<Vec<crate::types::AcceptResult>, TailFinError> {
session.navigate("https://x.com/follower_requests").await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for _ in 0..(count / 5 + 1) {
session
.eval("window.scrollBy(0, window.innerHeight)")
.await?;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
let filter_js = match filter {
Some(f) => {
let keywords: Vec<&str> = f.split(',').map(|s| s.trim()).collect();
let json_arr = serde_json::to_string(&keywords).unwrap_or_else(|_| "[]".into());
format!("const filterKeywords = {};", json_arr)
}
None => "const filterKeywords = null;".into(),
};
let js = format!(
r#"(async () => {{
{filter_js}
const results = [];
const cells = Array.from(document.querySelectorAll('[data-testid="cellInnerDiv"]')).slice(0, {count});
for (const cell of cells) {{
const text = cell.textContent || '';
const nameMatch = text.match(/^@?(\w+)/);
const username = nameMatch ? nameMatch[1] : 'unknown';
if (filterKeywords && !filterKeywords.some(kw => text.toLowerCase().includes(kw.toLowerCase()))) {{
results.push(JSON.stringify({{ username, status: "skipped", message: "Did not match filter" }}));
continue;
}}
const acceptBtn = Array.from(cell.querySelectorAll('button,[role="button"]'))
.find(btn => (btn.textContent || '').trim().toLowerCase().includes('confirm') ||
(btn.textContent || '').trim().toLowerCase().includes('accept'));
if (acceptBtn) {{
acceptBtn.click();
await new Promise(r => setTimeout(r, 1000));
const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 500)); }}
results.push(JSON.stringify({{ username, status: "accepted", message: "Follow request accepted" }}));
}} else {{
results.push(JSON.stringify({{ username, status: "skipped", message: "Accept button not found" }}));
}}
}}
return '[' + results.join(',') + ']';
}})()"#
);
let result = session.eval(&js).await?;
let s = result.as_str().unwrap_or("[]");
let accept_results: Vec<crate::types::AcceptResult> =
serde_json::from_str(s).unwrap_or_default();
Ok(accept_results)
}