Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Convert X (Twitter) tweets, threads, and articles to Markdown with YAML front matter via reverse-engineered API.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/tweet-to-markdown.ts
1#!/usr/bin/env npx tsx23import * as path from "node:path";4import { fileURLToPath } from "node:url";5import { hasRequiredXCookies, loadXCookies } from "./cookies.js";6import { fetchTweetThread } from "./thread.js";7import { formatArticleMarkdown } from "./markdown.js";8import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";9import { formatThreadTweetsMarkdown } from "./thread-markdown.js";10import { resolveArticleEntityFromTweet } from "./tweet-article.js";1112type TweetToMarkdownOptions = {13log?: (message: string) => void;14};1516function parseArgs(): { url?: string } {17const args = process.argv.slice(2);18let url: string | undefined;1920for (const arg of args) {21if (!arg.startsWith("-") && !url) {22url = arg;23}24}2526return { url };27}2829function normalizeInputUrl(input: string): string {30const trimmed = input.trim();31if (!trimmed) return "";32try {33return new URL(trimmed).toString();34} catch {35return trimmed;36}37}3839function formatScriptCommand(fallback: string): string {40const raw = process.argv[1];41const displayPath = raw42? (() => {43const relative = path.relative(process.cwd(), raw);44return relative && !relative.startsWith("..") ? relative : raw;45})()46: fallback;47const quotedPath = displayPath.includes(" ")48? `"${displayPath.replace(/"/g, '\\"')}"`49: displayPath;50return `npx -y bun ${quotedPath}`;51}5253function parseTweetId(input: string): string | null {54const trimmed = input.trim();55if (!trimmed) return null;56if (/^\d+$/.test(trimmed)) return trimmed;5758try {59const parsed = new URL(trimmed);60const match = parsed.pathname.match(/\/status(?:es)?\/(\d+)/);61if (match?.[1]) return match[1];62} catch {63return null;64}6566return null;67}6869function buildTweetUrl(username: string | undefined, tweetId: string | undefined): string | null {70if (!tweetId) return null;71if (username) {72return `https://x.com/${username}/status/${tweetId}`;73}74return `https://x.com/i/web/status/${tweetId}`;75}7677function formatMetaMarkdown(meta: Record<string, string | number | null | undefined>): string {78const lines = ["---"];79for (const [key, value] of Object.entries(meta)) {80if (value === undefined || value === null || value === "") continue;81if (typeof value === "number") {82lines.push(`${key}: ${value}`);83} else {84lines.push(`${key}: ${JSON.stringify(value)}`);85}86}87lines.push("---");88return lines.join("\n");89}9091function extractTweetText(tweet: any): string {92const noteText = tweet?.note_tweet?.note_tweet_results?.result?.text;93const legacyText = tweet?.legacy?.full_text ?? tweet?.legacy?.text ?? "";94return (noteText ?? legacyText ?? "").trim();95}9697function isOnlyUrl(text: string): boolean {98const trimmed = text.trim();99if (!trimmed) return true;100return /^https?:\/\/\S+$/.test(trimmed);101}102103export async function tweetToMarkdown(104inputUrl: string,105options: TweetToMarkdownOptions = {}106): Promise<string> {107const normalizedUrl = normalizeInputUrl(inputUrl);108const tweetId = parseTweetId(normalizedUrl);109if (!tweetId) {110throw new Error("Invalid tweet url. Example: https://x.com/<user>/status/<tweet_id>");111}112113const log = options.log ?? (() => {});114log("[tweet-to-markdown] Loading cookies...");115const cookieMap = await loadXCookies(log);116if (!hasRequiredXCookies(cookieMap)) {117throw new Error("Missing auth cookies. Provide X_AUTH_TOKEN and X_CT0 or log in via Chrome.");118}119120log(`[tweet-to-markdown] Fetching thread for ${tweetId}...`);121const thread = await fetchTweetThread(tweetId, cookieMap);122if (!thread) {123throw new Error("Failed to fetch thread.");124}125126const tweets = thread.tweets ?? [];127if (tweets.length === 0) {128throw new Error("No tweets found in thread.");129}130131const firstTweet = tweets[0] as any;132const user = thread.user ?? firstTweet?.core?.user_results?.result?.legacy;133const username = user?.screen_name;134const name = user?.name;135const author =136username && name ? `${name} (@${username})` : username ? `@${username}` : name ?? null;137const authorUrl = username ? `https://x.com/${username}` : undefined;138const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();139const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;140141const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);142let coverImage: string | null = null;143let remainingTweets = tweets;144const parts: string[] = [];145146if (articleEntity) {147const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });148const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });149coverImage = articleResult.coverUrl;150const articleMarkdown = articleResult.markdown.trimEnd();151if (articleMarkdown) {152parts.push(articleMarkdown);153const firstTweetText = extractTweetText(firstTweet);154if (isOnlyUrl(firstTweetText)) {155remainingTweets = tweets.slice(1);156}157}158}159160const meta = formatMetaMarkdown({161url: rootUrl,162requestedUrl: requestedUrl,163author,164authorName: name ?? null,165authorUsername: username ?? null,166authorUrl: authorUrl ?? null,167tweetCount: thread.totalTweets ?? tweets.length,168coverImage,169});170171parts.unshift(meta);172173if (remainingTweets.length > 0) {174const hasArticle = parts.length > 1;175if (hasArticle) {176parts.push("## Thread");177}178const tweetMarkdown = formatThreadTweetsMarkdown(remainingTweets, {179username,180headingLevel: hasArticle ? 3 : 2,181startIndex: 1,182includeTweetUrls: true,183});184if (tweetMarkdown) {185parts.push(tweetMarkdown);186}187}188189return parts.join("\n\n").trimEnd();190}191192async function main() {193const { url } = parseArgs();194if (!url) {195console.error("Usage:");196console.error(` ${formatScriptCommand("scripts/tweet-to-markdown.ts")} <tweet url>`);197process.exit(1);198}199200const markdown = await tweetToMarkdown(url, { log: console.log });201console.log(markdown);202}203204const isCliExecution =205process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);206207if (isCliExecution) {208main().catch((error) => {209console.error(error instanceof Error ? error.message : error);210process.exit(1);211});212}213