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.ts
1import { fetchTweetDetail } from "./graphql.js";23type TweetEntry = {4tweet: any;5user?: any;6};78type ParsedEntries = {9entries: TweetEntry[];10moreCursor?: string;11topCursor?: string;12bottomCursor?: string;13};1415type ThreadResult = {16requestedId: string;17rootId: string;18tweets: any[];19totalTweets: number;20user?: any;21responses?: unknown[];22};2324function unwrapTweetResult(result: any): any {25if (!result) return null;26if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {27return result.tweet;28}29return result;30}3132function extractTweetEntry(itemContent: any): TweetEntry | null {33const result = itemContent?.tweet_results?.result;34if (!result) return null;35const resolved = unwrapTweetResult(result?.tweet ?? result);36if (!resolved) return null;37const user = resolved?.core?.user_results?.result?.legacy;38return { tweet: resolved, user };39}4041function parseInstruction(instruction?: any): ParsedEntries {42const { entries: entities, moduleItems } = instruction || {};43const entries: TweetEntry[] = [];44let moreCursor: string | undefined;45let topCursor: string | undefined;46let bottomCursor: string | undefined;4748const parseItems = (items: any[]) => {49items?.forEach((item) => {50const itemContent = item?.item?.itemContent ?? item?.itemContent;51if (!itemContent) {52return;53}5455if (56itemContent.cursorType &&57["ShowMore", "ShowMoreThreads"].includes(itemContent.cursorType) &&58itemContent.itemType === "TimelineTimelineCursor"59) {60moreCursor = itemContent.value;61return;62}6364const entry = extractTweetEntry(itemContent);65if (entry) {66entries.push(entry);67}68});69};7071if (moduleItems) {72parseItems(moduleItems);73}7475for (const entity of entities ?? []) {76if (entity?.content?.clientEventInfo?.component === "you_might_also_like") {77continue;78}7980const { itemContent, items, cursorType, entryType, value } = entity?.content ?? {};81if (cursorType === "Bottom" && entryType === "TimelineTimelineCursor") {82bottomCursor = value;83}8485if (86itemContent?.cursorType === "Bottom" &&87itemContent?.itemType === "TimelineTimelineCursor"88) {89bottomCursor = bottomCursor ?? itemContent?.value;90}9192if (cursorType === "Top" && entryType === "TimelineTimelineCursor") {93topCursor = topCursor ?? value;94}9596if (itemContent) {97const entry = extractTweetEntry(itemContent);98if (entry) {99entries.push(entry);100}101if (102itemContent.cursorType &&103["ShowMore", "ShowMoreThreads"].includes(itemContent.cursorType) &&104itemContent.itemType === "TimelineTimelineCursor"105) {106moreCursor = moreCursor ?? itemContent.value;107}108109if (itemContent.cursorType === "Top" && itemContent.itemType === "TimelineTimelineCursor") {110topCursor = topCursor ?? itemContent.value;111}112}113114if (items) {115parseItems(items);116}117}118119return { entries, moreCursor, topCursor, bottomCursor };120}121122function parseTweetsAndToken(response: any): ParsedEntries {123const instruction =124response?.data?.threaded_conversation_with_injections_v2?.instructions?.find(125(ins: any) => ins?.type === "TimelineAddEntries" || ins?.type === "TimelineAddToModule"126) ??127response?.data?.threaded_conversation_with_injections?.instructions?.find(128(ins: any) => ins?.type === "TimelineAddEntries" || ins?.type === "TimelineAddToModule"129);130131return parseInstruction(instruction);132}133134function toTimestamp(value: string | undefined): number {135if (!value) return 0;136const parsed = Date.parse(value);137return Number.isNaN(parsed) ? 0 : parsed;138}139140export async function fetchTweetThread(141tweetId: string,142cookieMap: Record<string, string>,143includeResponses = false144): Promise<ThreadResult | null> {145const responses: unknown[] = [];146const res = await fetchTweetDetail(tweetId, cookieMap);147if (includeResponses) {148responses.push(res);149}150151let { entries, moreCursor, topCursor, bottomCursor } = parseTweetsAndToken(res);152if (!entries.length) {153const errorMessage = res?.errors?.[0]?.message;154if (errorMessage) {155throw new Error(errorMessage);156}157return null;158}159160let allEntries = entries.slice();161const root = allEntries.find((entry) => entry.tweet?.legacy?.id_str === tweetId);162if (!root) {163throw new Error("Can not fetch the root tweet");164}165166let rootEntry = root.tweet.legacy;167168const isSameThread = (entry: TweetEntry) => {169const tweet = entry.tweet?.legacy;170if (!tweet) return false;171return (172tweet.user_id_str === rootEntry.user_id_str &&173tweet.conversation_id_str === rootEntry.conversation_id_str &&174(tweet.id_str === rootEntry.id_str ||175tweet.in_reply_to_user_id_str === rootEntry.user_id_str ||176tweet.in_reply_to_status_id_str === rootEntry.conversation_id_str ||177!tweet.in_reply_to_user_id_str)178);179};180181const inThread = (items: TweetEntry[]) => items.some(isSameThread);182183let hasThread = inThread(entries);184let maxRequestCount = 1000;185let topHasThread = true;186187while (topCursor && topHasThread && maxRequestCount > 0) {188const newRes = await fetchTweetDetail(tweetId, cookieMap, topCursor);189if (includeResponses) {190responses.push(newRes);191}192193const parsed = parseTweetsAndToken(newRes);194topHasThread = inThread(parsed.entries);195topCursor = parsed.topCursor;196allEntries = parsed.entries.concat(allEntries);197maxRequestCount--;198}199200async function checkMoreTweets(focalId: string) {201while (moreCursor && hasThread && maxRequestCount > 0) {202const newRes = await fetchTweetDetail(focalId, cookieMap, moreCursor);203if (includeResponses) {204responses.push(newRes);205}206207const parsed = parseTweetsAndToken(newRes);208moreCursor = parsed.moreCursor;209bottomCursor = bottomCursor ?? parsed.bottomCursor;210211hasThread = inThread(parsed.entries);212allEntries = allEntries.concat(parsed.entries);213maxRequestCount--;214}215216if (bottomCursor) {217const newRes = await fetchTweetDetail(focalId, cookieMap, bottomCursor);218if (includeResponses) {219responses.push(newRes);220}221222const parsed = parseTweetsAndToken(newRes);223allEntries = allEntries.concat(parsed.entries);224bottomCursor = undefined;225}226}227228await checkMoreTweets(tweetId);229230const allThreadEntries = allEntries.filter(231(entry) => entry.tweet?.legacy?.id_str === tweetId || isSameThread(entry)232);233const lastEntity = allThreadEntries[allThreadEntries.length - 1];234if (lastEntity?.tweet?.legacy?.id_str) {235const lastRes = await fetchTweetDetail(lastEntity.tweet.legacy.id_str, cookieMap);236if (includeResponses) {237responses.push(lastRes);238}239240const parsed = parseTweetsAndToken(lastRes);241hasThread = inThread(parsed.entries);242allEntries = allEntries.concat(parsed.entries);243moreCursor = parsed.moreCursor;244bottomCursor = parsed.bottomCursor;245maxRequestCount--;246247await checkMoreTweets(lastEntity.tweet.legacy.id_str);248}249250const distinctEntries: TweetEntry[] = [];251const entriesMap = allEntries.reduce((acc, entry) => {252const id = entry.tweet?.legacy?.id_str ?? entry.tweet?.rest_id;253if (id && !acc.has(id)) {254distinctEntries.push(entry);255acc.set(id, entry);256}257return acc;258}, new Map<string, TweetEntry>());259allEntries = distinctEntries;260261while (rootEntry.in_reply_to_status_id_str) {262const parent = entriesMap.get(rootEntry.in_reply_to_status_id_str)?.tweet?.legacy;263if (264parent &&265parent.user_id_str === rootEntry.user_id_str &&266parent.conversation_id_str === rootEntry.conversation_id_str &&267parent.id_str !== rootEntry.id_str268) {269rootEntry = parent;270} else {271break;272}273}274275allEntries = allEntries.sort((a, b) => {276const aTime = toTimestamp(a.tweet?.legacy?.created_at);277const bTime = toTimestamp(b.tweet?.legacy?.created_at);278return aTime - bTime;279});280281const rootIndex = allEntries.findIndex(282(entry) => entry.tweet?.legacy?.id_str === rootEntry.id_str283);284if (rootIndex > 0) {285allEntries = allEntries.slice(rootIndex);286}287288const threadEntries = allEntries.filter(289(entry) => entry.tweet?.legacy?.id_str === tweetId || isSameThread(entry)290);291292if (!threadEntries.length) {293return null;294}295296const tweets = threadEntries.map((entry) => entry.tweet);297const user = threadEntries[0].user ?? threadEntries[0].tweet?.core?.user_results?.result?.legacy;298const result: ThreadResult = {299requestedId: tweetId,300rootId: rootEntry.id_str ?? tweetId,301tweets,302totalTweets: tweets.length,303user,304};305306if (includeResponses) {307result.responses = responses;308}309310return result;311}312