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/thread-markdown.ts
1type ThreadLike = {2requestedId?: string;3rootId?: string;4tweets?: unknown[];5totalTweets?: number;6user?: any;7};89type TweetPhoto = {10src: string;11alt?: string;12};1314type TweetVideo = {15url: string;16poster?: string;17alt?: string;18type?: string;19};2021export type ThreadTweetsMarkdownOptions = {22username?: string;23headingLevel?: number;24startIndex?: number;25includeTweetUrls?: boolean;26};2728export type ThreadMarkdownOptions = ThreadTweetsMarkdownOptions & {29includeHeader?: boolean;30title?: string;31sourceUrl?: string;32};3334function coerceThread(value: unknown): ThreadLike | null {35if (!value || typeof value !== "object") return null;36const candidate = value as ThreadLike;37if (!Array.isArray(candidate.tweets)) return null;38return candidate;39}4041function escapeMarkdownAlt(text: string): string {42return text.replace(/[\[\]]/g, "\\$&");43}4445function normalizeAlt(text?: string | null): string {46const trimmed = text?.trim();47if (!trimmed) return "";48return trimmed.replace(/\s+/g, " ");49}5051function parseTweetText(tweet: any): string {52const noteText = tweet?.note_tweet?.note_tweet_results?.result?.text;53const legacyText = tweet?.legacy?.full_text ?? tweet?.legacy?.text ?? "";54return (noteText ?? legacyText ?? "").trim();55}5657function parsePhotos(tweet: any): TweetPhoto[] {58const media = tweet?.legacy?.extended_entities?.media ?? [];59return media60.reduce((acc: TweetPhoto[], item: any) => {61if (item?.type !== "photo") {62return acc;63}64const src = item.media_url_https ?? item.media_url;65if (!src) {66return acc;67}68const alt = normalizeAlt(item.ext_alt_text);69acc.push({ src, alt });70return acc;71}, [])72.filter((photo) => Boolean(photo.src));73}7475function parseVideos(tweet: any): TweetVideo[] {76const media = tweet?.legacy?.extended_entities?.media ?? [];77return media78.reduce((acc: TweetVideo[], item: any) => {79if (!item?.type || !["animated_gif", "video"].includes(item.type)) {80return acc;81}82const variants = item?.video_info?.variants ?? [];83const sources = variants84.map((variant: any) => ({85contentType: variant?.content_type,86url: variant?.url,87bitrate: variant?.bitrate ?? 0,88}))89.filter((variant: any) => Boolean(variant.url));9091const videoSources = sources.filter((variant: any) =>92String(variant.contentType ?? "").includes("video")93);94const sorted = (videoSources.length > 0 ? videoSources : sources).sort(95(a: any, b: any) => (b.bitrate ?? 0) - (a.bitrate ?? 0)96);97const best = sorted[0];98if (!best?.url) {99return acc;100}101const alt = normalizeAlt(item.ext_alt_text);102acc.push({103url: best.url,104poster: item.media_url_https ?? item.media_url ?? undefined,105alt,106type: item.type,107});108return acc;109}, [])110.filter((video) => Boolean(video.url));111}112113function unwrapTweetResult(result: any): any {114if (!result) return null;115if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {116return result.tweet;117}118return result;119}120121function resolveTweetId(tweet: any): string | undefined {122return tweet?.legacy?.id_str ?? tweet?.rest_id;123}124125function buildTweetUrl(username: string | undefined, tweetId: string | undefined): string | null {126if (!tweetId) return null;127if (username) {128return `https://x.com/${username}/status/${tweetId}`;129}130return `https://x.com/i/web/status/${tweetId}`;131}132133function formatTweetMarkdown(134tweet: any,135index: number,136options: ThreadTweetsMarkdownOptions137): string[] {138const headingLevel = options.headingLevel ?? 2;139const includeTweetUrls = options.includeTweetUrls ?? true;140const headingPrefix = "#".repeat(Math.min(Math.max(headingLevel, 1), 6));141const tweetId = resolveTweetId(tweet);142const tweetUrl = includeTweetUrls ? buildTweetUrl(options.username, tweetId) : null;143144const lines: string[] = [];145lines.push(`${headingPrefix} ${index}`);146if (tweetUrl) {147lines.push(tweetUrl);148}149lines.push("");150151const text = parseTweetText(tweet);152const photos = parsePhotos(tweet);153const videos = parseVideos(tweet);154const quoted = unwrapTweetResult(tweet?.quoted_status_result?.result);155156const bodyLines: string[] = [];157if (text) {158bodyLines.push(...text.split(/\r?\n/));159}160161const quotedLines = formatQuotedTweetMarkdown(quoted);162if (quotedLines.length > 0) {163if (bodyLines.length > 0) bodyLines.push("");164bodyLines.push(...quotedLines);165}166167const photoLines = photos.map((photo) => {168const alt = photo.alt ? escapeMarkdownAlt(photo.alt) : "";169return ``;170});171if (photoLines.length > 0) {172if (bodyLines.length > 0) bodyLines.push("");173bodyLines.push(...photoLines);174}175176const videoLines: string[] = [];177for (const video of videos) {178if (video.poster) {179const alt = video.alt ? escapeMarkdownAlt(video.alt) : "video";180videoLines.push(``);181}182videoLines.push(`[${video.type ?? "video"}](${video.url})`);183}184if (videoLines.length > 0) {185if (bodyLines.length > 0) bodyLines.push("");186bodyLines.push(...videoLines);187}188189if (bodyLines.length === 0) {190bodyLines.push("_No text or media._");191}192193lines.push(...bodyLines);194return lines;195}196197function formatQuotedTweetMarkdown(quoted: any): string[] {198if (!quoted) return [];199const quotedUser = quoted?.core?.user_results?.result?.legacy;200const quotedUsername = quotedUser?.screen_name;201const quotedName = quotedUser?.name;202const quotedAuthor =203quotedUsername && quotedName204? `${quotedName} (@${quotedUsername})`205: quotedUsername206? `@${quotedUsername}`207: quotedName ?? "Unknown";208209const quotedId = resolveTweetId(quoted);210const quotedUrl =211buildTweetUrl(quotedUsername, quotedId) ??212(quotedId ? `https://x.com/i/web/status/${quotedId}` : "unavailable");213214const quotedText = parseTweetText(quoted);215const lines: string[] = [];216lines.push(`Author: ${quotedAuthor}`);217lines.push(`URL: ${quotedUrl}`);218if (quotedText) {219lines.push("", ...quotedText.split(/\r?\n/));220} else {221lines.push("", "(no content)");222}223224return lines.map((line) => `> ${line}`.trimEnd());225}226227export function formatThreadTweetsMarkdown(228tweets: unknown[],229options: ThreadTweetsMarkdownOptions = {}230): string {231const lines: string[] = [];232const startIndex = options.startIndex ?? 1;233if (!Array.isArray(tweets) || tweets.length === 0) {234return "";235}236237tweets.forEach((tweet, index) => {238if (lines.length > 0) {239lines.push("");240}241lines.push(...formatTweetMarkdown(tweet, startIndex + index, options));242});243244return lines.join("\n").trimEnd();245}246247export function formatThreadMarkdown(248thread: unknown,249options: ThreadMarkdownOptions = {}250): string {251const candidate = coerceThread(thread);252if (!candidate) {253return `\`\`\`json\n${JSON.stringify(thread, null, 2)}\n\`\`\``;254}255256const tweets = candidate.tweets ?? [];257const firstTweet = tweets[0] as any;258const user = candidate.user ?? firstTweet?.core?.user_results?.result?.legacy;259const username = user?.screen_name;260const name = user?.name;261262const includeHeader = options.includeHeader ?? true;263const lines: string[] = [];264if (includeHeader) {265if (options.title) {266lines.push(`# ${options.title}`);267} else if (username) {268lines.push(`# Thread by @${username}${name ? ` (${name})` : ""}`);269} else {270lines.push("# Thread");271}272273const sourceUrl = options.sourceUrl ?? buildTweetUrl(username, candidate.rootId ?? candidate.requestedId);274if (sourceUrl) {275lines.push(`Source: ${sourceUrl}`);276}277if (typeof candidate.totalTweets === "number") {278lines.push(`Tweets: ${candidate.totalTweets}`);279}280}281282const tweetMarkdown = formatThreadTweetsMarkdown(tweets, {283...options,284username,285});286287if (tweetMarkdown) {288if (lines.length > 0) {289lines.push("");290}291lines.push(tweetMarkdown);292}293294return lines.join("\n").trimEnd();295}296