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/main.ts
1import fs from "node:fs";2import path from "node:path";3import readline from "node:readline";4import process from "node:process";5import { mkdir, readFile, writeFile } from "node:fs/promises";67import { fetchXArticle } from "./graphql.js";8import { formatArticleMarkdown } from "./markdown.js";9import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";10import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";11import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";12import { resolveXToMarkdownConsentPath } from "./paths.js";13import { tweetToMarkdown } from "./tweet-to-markdown.js";1415type CliArgs = {16url: string | null;17output: string | null;18json: boolean;19login: boolean;20downloadMedia: boolean;21help: boolean;22};2324type ConsentRecord = {25version: number;26accepted: boolean;27acceptedAt: string;28disclaimerVersion: string;29};3031const DISCLAIMER_VERSION = "1.0";3233function formatScriptCommand(fallback: string): string {34const raw = process.argv[1];35const displayPath = raw36? (() => {37const relative = path.relative(process.cwd(), raw);38return relative && !relative.startsWith("..") ? relative : raw;39})()40: fallback;41const quotedPath = displayPath.includes(" ")42? `"${displayPath.replace(/"/g, '\\"')}"`43: displayPath;44return `npx -y bun ${quotedPath}`;45}4647function printUsage(exitCode: number): never {48const cmd = formatScriptCommand("scripts/main.ts");49console.log(`X (Twitter) to Markdown5051Usage:52${cmd} <url>53${cmd} --url <url>5455Options:56--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/57--json Output as JSON58--download-media Download images/videos to local ./imgs and ./videos next to markdown59--login Refresh cookies only, then exit60--help, -h Show help6162Examples:63${cmd} https://x.com/username/status/123456789064${cmd} https://x.com/i/article/1234567890 -o ./article.md65${cmd} https://x.com/username/status/1234567890 -o ./out/66${cmd} https://x.com/username/status/1234567890 --download-media67${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'68${cmd} --login69`);70process.exit(exitCode);71}7273function parseArgs(argv: string[]): CliArgs {74const out: CliArgs = {75url: null,76output: null,77json: false,78login: false,79downloadMedia: false,80help: false,81};8283const positional: string[] = [];8485for (let i = 0; i < argv.length; i++) {86const a = argv[i]!;8788if (a === "--help" || a === "-h") {89out.help = true;90continue;91}9293if (a === "--json") {94out.json = true;95continue;96}9798if (a === "--login") {99out.login = true;100continue;101}102103if (a === "--download-media") {104out.downloadMedia = true;105continue;106}107108if (a === "--url") {109const v = argv[++i];110if (!v) throw new Error("Missing value for --url");111out.url = v;112continue;113}114115if (a === "--output" || a === "-o") {116const v = argv[++i];117if (!v) throw new Error(`Missing value for ${a}`);118out.output = v;119continue;120}121122if (a.startsWith("-")) {123throw new Error(`Unknown option: ${a}`);124}125126positional.push(a);127}128129if (!out.url && positional.length > 0) {130out.url = positional[0]!;131}132133return out;134}135136function normalizeInputUrl(input: string): string {137const trimmed = input.trim();138if (!trimmed) return "";139try {140return new URL(trimmed).toString();141} catch {142return trimmed;143}144}145146function parseArticleId(input: string): string | null {147const trimmed = input.trim();148if (!trimmed) return null;149150try {151const parsed = new URL(trimmed);152const match = parsed.pathname.match(/\/(?:i\/)?article\/(\d+)/);153if (match?.[1]) return match[1];154} catch {155return null;156}157158return null;159}160161function parseTweetId(input: string): string | null {162const trimmed = input.trim();163if (!trimmed) return null;164if (/^\d+$/.test(trimmed)) return trimmed;165166try {167const parsed = new URL(trimmed);168const match = parsed.pathname.match(/\/status(?:es)?\/(\d+)/);169if (match?.[1]) return match[1];170} catch {171return null;172}173174return null;175}176177function parseTweetUsername(input: string): string | null {178const trimmed = input.trim();179if (!trimmed) return null;180try {181const parsed = new URL(trimmed);182const match = parsed.pathname.match(/^\/([^/]+)\/status(?:es)?\/\d+/);183if (match?.[1]) return match[1];184} catch {185return null;186}187return null;188}189190function sanitizeSlug(input: string): string {191return input192.trim()193.replace(/^@/, "")194.replace(/[^a-zA-Z0-9_-]+/g, "-")195.replace(/-+/g, "-")196.replace(/^[-_]+|[-_]+$/g, "")197.slice(0, 120);198}199200function extractContentSlug(markdown: string): string {201const headingMatch = markdown.match(/^#\s+(.+)$/m);202if (headingMatch?.[1]) {203return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();204}205const lines = markdown.split("\n");206let inFrontmatter = false;207for (const line of lines) {208if (line === "---") {209inFrontmatter = !inFrontmatter;210continue;211}212if (inFrontmatter) continue;213const trimmed = line.trim();214if (trimmed) {215return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();216}217}218return "untitled";219}220221function resolveSlugAndId(normalizedUrl: string, kind: "tweet" | "article"): { slug: string; idPart: string } {222const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;223const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;224const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;225226const idPart = articleId ?? tweetId ?? String(Date.now());227const userSlug = username ? sanitizeSlug(username) : null;228const slug = userSlug ?? idPart;229return { slug, idPart };230}231232function extractFrontmatterUrls(markdown: string): string[] {233const match = markdown.match(/^---\n([\s\S]*?)\n---/);234if (!match?.[1]) return [];235236const lines = match[1].split("\n");237const urls: string[] = [];238for (const line of lines) {239const m = line.match(/^(url|requestedUrl):\s*["']([^"']+)["']\s*$/);240if (m?.[2]) {241urls.push(m[2]);242}243}244return urls;245}246247function frontmatterMatchesTarget(248markdown: string,249normalizedUrl: string,250kind: "tweet" | "article"251): boolean {252const urls = extractFrontmatterUrls(markdown);253if (urls.length === 0) return false;254255const targetId = kind === "article" ? parseArticleId(normalizedUrl) : parseTweetId(normalizedUrl);256if (!targetId) return false;257258for (const url of urls) {259const candidateId = kind === "article" ? parseArticleId(url) : parseTweetId(url);260if (candidateId && candidateId === targetId) {261return true;262}263}264265return false;266}267268function listMarkdownFiles(dirPath: string): string[] {269try {270return fs271.readdirSync(dirPath)272.filter((name) => name.toLowerCase().endsWith(".md"))273.map((name) => path.join(dirPath, name))274.sort();275} catch {276return [];277}278}279280function resolveExistingMarkdownPath(281normalizedUrl: string,282kind: "tweet" | "article",283argsOutput: string | null284): string | null {285const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);286const candidateDirs = new Set<string>();287const candidateFiles = new Set<string>();288289if (argsOutput) {290const resolved = path.resolve(argsOutput);291const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");292try {293if (fs.existsSync(resolved)) {294const stat = fs.statSync(resolved);295if (stat.isFile()) {296candidateFiles.add(resolved);297} else if (stat.isDirectory()) {298candidateDirs.add(path.join(resolved, slug, idPart));299candidateDirs.add(resolved);300}301} else if (looksDir) {302candidateDirs.add(path.join(resolved, slug, idPart));303}304} catch {305// ignore and continue306}307} else {308candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));309}310311for (const filePath of candidateFiles) {312if (!filePath.toLowerCase().endsWith(".md")) continue;313try {314const markdown = fs.readFileSync(filePath, "utf8");315if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {316return filePath;317}318} catch {319// ignore and continue320}321}322323for (const dirPath of candidateDirs) {324if (!fs.existsSync(dirPath)) continue;325let stat: fs.Stats;326try {327stat = fs.statSync(dirPath);328} catch {329continue;330}331if (!stat.isDirectory()) continue;332333const markdownFiles = listMarkdownFiles(dirPath);334for (const filePath of markdownFiles) {335try {336const markdown = fs.readFileSync(filePath, "utf8");337if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {338return filePath;339}340} catch {341// ignore and continue342}343}344}345346return null;347}348349async function resolveOutputPath(350normalizedUrl: string,351kind: "tweet" | "article",352argsOutput: string | null,353contentSlug: string,354log: (message: string) => void355): Promise<{ outputDir: string; markdownPath: string; slug: string }> {356const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;357const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;358const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;359360const userSlug = username ? sanitizeSlug(username) : null;361const idPart = articleId ?? tweetId ?? String(Date.now());362const slug = userSlug ?? idPart;363364const defaultFileName = `${contentSlug}.md`;365366if (argsOutput) {367const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");368const resolved = path.resolve(argsOutput);369try {370if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {371const outputDir = path.join(resolved, slug, idPart);372await mkdir(outputDir, { recursive: true });373return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };374}375} catch {376// treat as file path377}378379const outputDir = path.dirname(resolved);380await mkdir(outputDir, { recursive: true });381return { outputDir, markdownPath: resolved, slug };382}383384const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);385await mkdir(outputDir, { recursive: true });386return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };387}388389function formatMetaMarkdown(meta: Record<string, string | number | null | undefined>): string {390const lines = ["---"];391for (const [key, value] of Object.entries(meta)) {392if (value === undefined || value === null || value === "") continue;393if (typeof value === "number") {394lines.push(`${key}: ${value}`);395} else {396lines.push(`${key}: ${JSON.stringify(value)}`);397}398}399lines.push("---");400return lines.join("\n");401}402403async function promptYesNo(question: string): Promise<boolean> {404if (!process.stdin.isTTY) return false;405406const rl = readline.createInterface({407input: process.stdin,408output: process.stderr,409});410411try {412const answer = await new Promise<string>((resolve) => rl.question(question, resolve));413const normalized = answer.trim().toLowerCase();414return normalized === "y" || normalized === "yes";415} finally {416rl.close();417}418}419420function isValidConsent(value: unknown): value is ConsentRecord {421if (!value || typeof value !== "object") return false;422const record = value as Partial<ConsentRecord>;423return (424record.accepted === true &&425record.disclaimerVersion === DISCLAIMER_VERSION &&426typeof record.acceptedAt === "string" &&427record.acceptedAt.length > 0428);429}430431async function ensureConsent(log: (message: string) => void): Promise<void> {432const consentPath = resolveXToMarkdownConsentPath();433434try {435if (fs.existsSync(consentPath) && fs.statSync(consentPath).isFile()) {436const raw = await readFile(consentPath, "utf8");437const parsed = JSON.parse(raw) as unknown;438if (isValidConsent(parsed)) {439log(440`⚠️ Warning: Using reverse-engineered X API (not official). Accepted on: ${(parsed as ConsentRecord).acceptedAt}`441);442return;443}444}445} catch {446// fall through to prompt447}448449log(`⚠️ DISCLAIMER450451This tool uses a reverse-engineered X (Twitter) API, NOT an official API.452453Risks:454- May break without notice if X changes their API455- No official support or guarantees456- Account restrictions possible if API usage detected457- Use at your own risk458`);459460if (!process.stdin.isTTY) {461throw new Error(462`Consent required. Run in a TTY or create ${consentPath} with accepted: true and disclaimerVersion: ${DISCLAIMER_VERSION}`463);464}465466const accepted = await promptYesNo("Do you accept these terms and wish to continue? (y/N): ");467if (!accepted) {468throw new Error("User declined the disclaimer. Exiting.");469}470471await mkdir(path.dirname(consentPath), { recursive: true });472const payload: ConsentRecord = {473version: 1,474accepted: true,475acceptedAt: new Date().toISOString(),476disclaimerVersion: DISCLAIMER_VERSION,477};478await writeFile(consentPath, JSON.stringify(payload, null, 2), "utf8");479log(`[x-to-markdown] Consent saved to: ${consentPath}`);480}481482async function convertArticleToMarkdown(483inputUrl: string,484articleId: string,485log: (message: string) => void486): Promise<string> {487log("[x-to-markdown] Loading cookies...");488const cookieMap = await loadXCookies(log);489if (!hasRequiredXCookies(cookieMap)) {490throw new Error("Missing auth cookies. Provide X_AUTH_TOKEN and X_CT0 or log in via Chrome.");491}492493log(`[x-to-markdown] Fetching article ${articleId}...`);494const article = await fetchXArticle(articleId, cookieMap, false);495const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });496const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });497498const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";499const meta = formatMetaMarkdown({500url: `https://x.com/i/article/${articleId}`,501requestedUrl: inputUrl,502title: title || null,503coverImage: coverUrl,504});505506return [meta, body.trimEnd()].filter(Boolean).join("\n\n").trimEnd();507}508509async function main(): Promise<void> {510const args = parseArgs(process.argv.slice(2));511if (args.help) printUsage(0);512if (!args.login && !args.url) printUsage(1);513514const log = (message: string) => console.error(message);515await ensureConsent(log);516517if (args.login) {518log("[x-to-markdown] Refreshing cookies via browser login...");519const cookieMap = await refreshXCookies(log);520if (!hasRequiredXCookies(cookieMap)) {521throw new Error("Missing auth cookies after login. Please ensure you are logged in to X.");522}523log("[x-to-markdown] Cookies refreshed.");524return;525}526527const normalizedUrl = normalizeInputUrl(args.url ?? "");528const articleId = parseArticleId(normalizedUrl);529const tweetId = parseTweetId(normalizedUrl);530if (!articleId && !tweetId) {531throw new Error("Invalid X url. Examples: https://x.com/<user>/status/<id> or https://x.com/i/article/<id>");532}533534const kind = articleId ? ("article" as const) : ("tweet" as const);535536if (args.downloadMedia) {537const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);538if (existingMarkdownPath) {539log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);540const existingMarkdown = await readFile(existingMarkdownPath, "utf8");541const mediaResult = await localizeMarkdownMedia(existingMarkdown, {542markdownPath: existingMarkdownPath,543log,544});545const didLocalize =546mediaResult.downloadedImages > 0 ||547mediaResult.downloadedVideos > 0 ||548mediaResult.markdown !== existingMarkdown;549550if (didLocalize) {551await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");552log(553`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`554);555log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);556557const { slug } = resolveSlugAndId(normalizedUrl, kind);558if (args.json) {559console.log(560JSON.stringify(561{562url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,563requestedUrl: normalizedUrl,564type: kind,565slug,566outputDir: path.dirname(existingMarkdownPath),567markdownPath: existingMarkdownPath,568downloadMedia: true,569downloadedImages: mediaResult.downloadedImages,570downloadedVideos: mediaResult.downloadedVideos,571imageDir: mediaResult.imageDir,572videoDir: mediaResult.videoDir,573},574null,5752576)577);578} else {579console.log(existingMarkdownPath);580}581return;582}583584log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");585}586}587588let markdown =589kind === "article" && articleId590? await convertArticleToMarkdown(normalizedUrl, articleId, log)591: await tweetToMarkdown(normalizedUrl, { log });592593const contentSlug = extractContentSlug(markdown);594const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);595596let mediaResult: LocalizeMarkdownMediaResult | null = null;597598if (args.downloadMedia) {599mediaResult = await localizeMarkdownMedia(markdown, {600markdownPath,601log,602});603markdown = mediaResult.markdown;604log(605`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`606);607}608609await writeFile(markdownPath, markdown, "utf8");610log(`[x-to-markdown] Saved: ${markdownPath}`);611612if (args.json) {613console.log(614JSON.stringify(615{616url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,617requestedUrl: normalizedUrl,618type: kind,619slug,620outputDir,621markdownPath,622downloadMedia: args.downloadMedia,623downloadedImages: mediaResult?.downloadedImages ?? 0,624downloadedVideos: mediaResult?.downloadedVideos ?? 0,625imageDir: mediaResult?.imageDir ?? null,626videoDir: mediaResult?.videoDir ?? null,627},628null,6292630)631);632} else {633console.log(markdownPath);634}635}636637await main().catch((error) => {638console.error(error instanceof Error ? error.message : String(error ?? ""));639process.exit(1);640});641