296 lines
8.0 KiB
TypeScript
296 lines
8.0 KiB
TypeScript
type ThreadLike = {
|
|
requestedId?: string;
|
|
rootId?: string;
|
|
tweets?: unknown[];
|
|
totalTweets?: number;
|
|
user?: any;
|
|
};
|
|
|
|
type TweetPhoto = {
|
|
src: string;
|
|
alt?: string;
|
|
};
|
|
|
|
type TweetVideo = {
|
|
url: string;
|
|
poster?: string;
|
|
alt?: string;
|
|
type?: string;
|
|
};
|
|
|
|
export type ThreadTweetsMarkdownOptions = {
|
|
username?: string;
|
|
headingLevel?: number;
|
|
startIndex?: number;
|
|
includeTweetUrls?: boolean;
|
|
};
|
|
|
|
export type ThreadMarkdownOptions = ThreadTweetsMarkdownOptions & {
|
|
includeHeader?: boolean;
|
|
title?: string;
|
|
sourceUrl?: string;
|
|
};
|
|
|
|
function coerceThread(value: unknown): ThreadLike | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const candidate = value as ThreadLike;
|
|
if (!Array.isArray(candidate.tweets)) return null;
|
|
return candidate;
|
|
}
|
|
|
|
function escapeMarkdownAlt(text: string): string {
|
|
return text.replace(/[\[\]]/g, "\\$&");
|
|
}
|
|
|
|
function normalizeAlt(text?: string | null): string {
|
|
const trimmed = text?.trim();
|
|
if (!trimmed) return "";
|
|
return trimmed.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function parseTweetText(tweet: any): string {
|
|
const noteText = tweet?.note_tweet?.note_tweet_results?.result?.text;
|
|
const legacyText = tweet?.legacy?.full_text ?? tweet?.legacy?.text ?? "";
|
|
return (noteText ?? legacyText ?? "").trim();
|
|
}
|
|
|
|
function parsePhotos(tweet: any): TweetPhoto[] {
|
|
const media = tweet?.legacy?.extended_entities?.media ?? [];
|
|
return media
|
|
.reduce((acc: TweetPhoto[], item: any) => {
|
|
if (item?.type !== "photo") {
|
|
return acc;
|
|
}
|
|
const src = item.media_url_https ?? item.media_url;
|
|
if (!src) {
|
|
return acc;
|
|
}
|
|
const alt = normalizeAlt(item.ext_alt_text);
|
|
acc.push({ src, alt });
|
|
return acc;
|
|
}, [])
|
|
.filter((photo) => Boolean(photo.src));
|
|
}
|
|
|
|
function parseVideos(tweet: any): TweetVideo[] {
|
|
const media = tweet?.legacy?.extended_entities?.media ?? [];
|
|
return media
|
|
.reduce((acc: TweetVideo[], item: any) => {
|
|
if (!item?.type || !["animated_gif", "video"].includes(item.type)) {
|
|
return acc;
|
|
}
|
|
const variants = item?.video_info?.variants ?? [];
|
|
const sources = variants
|
|
.map((variant: any) => ({
|
|
contentType: variant?.content_type,
|
|
url: variant?.url,
|
|
bitrate: variant?.bitrate ?? 0,
|
|
}))
|
|
.filter((variant: any) => Boolean(variant.url));
|
|
|
|
const videoSources = sources.filter((variant: any) =>
|
|
String(variant.contentType ?? "").includes("video")
|
|
);
|
|
const sorted = (videoSources.length > 0 ? videoSources : sources).sort(
|
|
(a: any, b: any) => (b.bitrate ?? 0) - (a.bitrate ?? 0)
|
|
);
|
|
const best = sorted[0];
|
|
if (!best?.url) {
|
|
return acc;
|
|
}
|
|
const alt = normalizeAlt(item.ext_alt_text);
|
|
acc.push({
|
|
url: best.url,
|
|
poster: item.media_url_https ?? item.media_url ?? undefined,
|
|
alt,
|
|
type: item.type,
|
|
});
|
|
return acc;
|
|
}, [])
|
|
.filter((video) => Boolean(video.url));
|
|
}
|
|
|
|
function unwrapTweetResult(result: any): any {
|
|
if (!result) return null;
|
|
if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {
|
|
return result.tweet;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveTweetId(tweet: any): string | undefined {
|
|
return tweet?.legacy?.id_str ?? tweet?.rest_id;
|
|
}
|
|
|
|
function buildTweetUrl(username: string | undefined, tweetId: string | undefined): string | null {
|
|
if (!tweetId) return null;
|
|
if (username) {
|
|
return `https://x.com/${username}/status/${tweetId}`;
|
|
}
|
|
return `https://x.com/i/web/status/${tweetId}`;
|
|
}
|
|
|
|
function formatTweetMarkdown(
|
|
tweet: any,
|
|
index: number,
|
|
options: ThreadTweetsMarkdownOptions
|
|
): string[] {
|
|
const headingLevel = options.headingLevel ?? 2;
|
|
const includeTweetUrls = options.includeTweetUrls ?? true;
|
|
const headingPrefix = "#".repeat(Math.min(Math.max(headingLevel, 1), 6));
|
|
const tweetId = resolveTweetId(tweet);
|
|
const tweetUrl = includeTweetUrls ? buildTweetUrl(options.username, tweetId) : null;
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`${headingPrefix} ${index}`);
|
|
if (tweetUrl) {
|
|
lines.push(tweetUrl);
|
|
}
|
|
lines.push("");
|
|
|
|
const text = parseTweetText(tweet);
|
|
const photos = parsePhotos(tweet);
|
|
const videos = parseVideos(tweet);
|
|
const quoted = unwrapTweetResult(tweet?.quoted_status_result?.result);
|
|
|
|
const bodyLines: string[] = [];
|
|
if (text) {
|
|
bodyLines.push(...text.split(/\r?\n/));
|
|
}
|
|
|
|
const quotedLines = formatQuotedTweetMarkdown(quoted);
|
|
if (quotedLines.length > 0) {
|
|
if (bodyLines.length > 0) bodyLines.push("");
|
|
bodyLines.push(...quotedLines);
|
|
}
|
|
|
|
const photoLines = photos.map((photo) => {
|
|
const alt = photo.alt ? escapeMarkdownAlt(photo.alt) : "";
|
|
return ``;
|
|
});
|
|
if (photoLines.length > 0) {
|
|
if (bodyLines.length > 0) bodyLines.push("");
|
|
bodyLines.push(...photoLines);
|
|
}
|
|
|
|
const videoLines: string[] = [];
|
|
for (const video of videos) {
|
|
if (video.poster) {
|
|
const alt = video.alt ? escapeMarkdownAlt(video.alt) : "video";
|
|
videoLines.push(``);
|
|
}
|
|
videoLines.push(`[${video.type ?? "video"}](${video.url})`);
|
|
}
|
|
if (videoLines.length > 0) {
|
|
if (bodyLines.length > 0) bodyLines.push("");
|
|
bodyLines.push(...videoLines);
|
|
}
|
|
|
|
if (bodyLines.length === 0) {
|
|
bodyLines.push("_No text or media._");
|
|
}
|
|
|
|
lines.push(...bodyLines);
|
|
return lines;
|
|
}
|
|
|
|
function formatQuotedTweetMarkdown(quoted: any): string[] {
|
|
if (!quoted) return [];
|
|
const quotedUser = quoted?.core?.user_results?.result?.legacy;
|
|
const quotedUsername = quotedUser?.screen_name;
|
|
const quotedName = quotedUser?.name;
|
|
const quotedAuthor =
|
|
quotedUsername && quotedName
|
|
? `${quotedName} (@${quotedUsername})`
|
|
: quotedUsername
|
|
? `@${quotedUsername}`
|
|
: quotedName ?? "Unknown";
|
|
|
|
const quotedId = resolveTweetId(quoted);
|
|
const quotedUrl =
|
|
buildTweetUrl(quotedUsername, quotedId) ??
|
|
(quotedId ? `https://x.com/i/web/status/${quotedId}` : "unavailable");
|
|
|
|
const quotedText = parseTweetText(quoted);
|
|
const lines: string[] = [];
|
|
lines.push(`Author: ${quotedAuthor}`);
|
|
lines.push(`URL: ${quotedUrl}`);
|
|
if (quotedText) {
|
|
lines.push("", ...quotedText.split(/\r?\n/));
|
|
} else {
|
|
lines.push("", "(no content)");
|
|
}
|
|
|
|
return lines.map((line) => `> ${line}`.trimEnd());
|
|
}
|
|
|
|
export function formatThreadTweetsMarkdown(
|
|
tweets: unknown[],
|
|
options: ThreadTweetsMarkdownOptions = {}
|
|
): string {
|
|
const lines: string[] = [];
|
|
const startIndex = options.startIndex ?? 1;
|
|
if (!Array.isArray(tweets) || tweets.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
tweets.forEach((tweet, index) => {
|
|
if (lines.length > 0) {
|
|
lines.push("");
|
|
}
|
|
lines.push(...formatTweetMarkdown(tweet, startIndex + index, options));
|
|
});
|
|
|
|
return lines.join("\n").trimEnd();
|
|
}
|
|
|
|
export function formatThreadMarkdown(
|
|
thread: unknown,
|
|
options: ThreadMarkdownOptions = {}
|
|
): string {
|
|
const candidate = coerceThread(thread);
|
|
if (!candidate) {
|
|
return `\`\`\`json\n${JSON.stringify(thread, null, 2)}\n\`\`\``;
|
|
}
|
|
|
|
const tweets = candidate.tweets ?? [];
|
|
const firstTweet = tweets[0] as any;
|
|
const user = candidate.user ?? firstTweet?.core?.user_results?.result?.legacy;
|
|
const username = user?.screen_name;
|
|
const name = user?.name;
|
|
|
|
const includeHeader = options.includeHeader ?? true;
|
|
const lines: string[] = [];
|
|
if (includeHeader) {
|
|
if (options.title) {
|
|
lines.push(`# ${options.title}`);
|
|
} else if (username) {
|
|
lines.push(`# Thread by @${username}${name ? ` (${name})` : ""}`);
|
|
} else {
|
|
lines.push("# Thread");
|
|
}
|
|
|
|
const sourceUrl = options.sourceUrl ?? buildTweetUrl(username, candidate.rootId ?? candidate.requestedId);
|
|
if (sourceUrl) {
|
|
lines.push(`Source: ${sourceUrl}`);
|
|
}
|
|
if (typeof candidate.totalTweets === "number") {
|
|
lines.push(`Tweets: ${candidate.totalTweets}`);
|
|
}
|
|
}
|
|
|
|
const tweetMarkdown = formatThreadTweetsMarkdown(tweets, {
|
|
...options,
|
|
username,
|
|
});
|
|
|
|
if (tweetMarkdown) {
|
|
if (lines.length > 0) {
|
|
lines.push("");
|
|
}
|
|
lines.push(tweetMarkdown);
|
|
}
|
|
|
|
return lines.join("\n").trimEnd();
|
|
}
|