use night_fury_core::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> {
session.navigate(tweet_url).await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
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(session.eval(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> {
session.navigate(tweet_url).await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
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(session.eval(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> {
session
.navigate("https://x.com/explore/tabs/trending")
.await?;
let _ = session.wait_for_network_idle(15000, 1000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
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 = session.eval(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;
for _ in 0..(count / 5 + 1) {
session
.eval("window.scrollBy(0, window.innerHeight)")
.await?;
tokio::time::sleep(std::time::Duration::from_secs(2)).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())
}
fn escape_js_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn parse_action_result(value: serde_json::Value) -> Result<ActionResult, TailFinError> {
let s = value
.as_str()
.unwrap_or(r#"{"status":"failed","message":"no response"}"#);
Ok(serde_json::from_str(s).unwrap_or(ActionResult {
status: "failed".into(),
message: s.to_string(),
}))
}