import { type HTMLElement, parseHTML } from "linkedom"; import { ToolAbortError } from "../../tools/tool-errors"; import type { RenderResult, SpecialHandler } from "./types"; import { buildResult, loadPage } from "./types"; const NITTER_INSTANCES = [ "nitter.privacyredirect.com", "nitter.tiekoetter.com", "nitter.poast.org", "nitter.woodland.cafe", ]; /** * Handle Twitter/X URLs via Nitter */ export const handleTwitter: SpecialHandler = async ( url: string, timeout: number, signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); if (!["twitter.com", "x.com", "www.twitter.com", "www.x.com"].includes(parsed.hostname)) { return null; } const fetchedAt = new Date().toISOString(); // Try Nitter instances for (const instance of NITTER_INSTANCES) { const nitterUrl = `https://${instance}${parsed.pathname}`; const result = await loadPage(nitterUrl, { timeout: Math.min(timeout, 10), signal }); if (result.ok && result.content.length > 500) { // Parse the Nitter HTML const doc = parseHTML(result.content).document; // Extract tweet content const tweetContent = doc.querySelector(".tweet-content")?.textContent?.trim(); const fullname = doc.querySelector(".fullname")?.textContent?.trim(); const username = doc.querySelector(".username")?.textContent?.trim(); const date = doc.querySelector(".tweet-date a")?.textContent?.trim(); const stats = doc.querySelector(".tweet-stats")?.textContent?.trim(); if (tweetContent) { let md = `# Tweet by ${fullname || "Unknown"} (${username || "@?"})\n\n`; if (date) md += `*${date}*\n\n`; md += `${tweetContent}\n\n`; if (stats) md += `---\n${stats.replace(/\s+/g, " ")}\n`; // Check for replies/thread const replies = Array.from(doc.querySelectorAll(".timeline-item .tweet-content")) as HTMLElement[]; if (replies.length > 1) { md += `\n---\n\n## Thread/Replies\n\n`; for (const reply of replies.slice(1, 10)) { const replyUser = reply.parentElement?.querySelector(".username")?.textContent?.trim(); md += `**${replyUser || "@?"}**: ${reply.textContent?.trim()}\n\n`; } } return buildResult(md, { url, finalUrl: nitterUrl, method: "twitter-nitter", fetchedAt, notes: [`Via Nitter: ${instance}`], }); } } } } catch { if (signal?.aborted) { throw new ToolAbortError(); } } if (signal?.aborted) { throw new ToolAbortError(); } // X.com blocks all bots - return a helpful error instead of falling through return { url, finalUrl: url, contentType: "text/plain", method: "twitter-blocked", content: "Twitter/X blocks automated access. Nitter instances were unavailable.\n\nTry:\n- Opening the link in a browser\n- Using a different Nitter instance manually\n- Checking if the tweet is available via an archive service", fetchedAt: new Date().toISOString(), truncated: false, notes: ["X.com blocks bots; Nitter instances unavailable"], }; };