A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
···8181 : normalized;
82828383 return [
8484- 'Write alt text (1-2 sentences).',
8484+ 'Write one alt text description (1-2 sentences).',
8585 'Describe only what is visible.',
8686- 'Use context to identify people/places/objects if relevant.',
8787- 'Include key names for search.',
8686+ 'Use context to identify people/places/objects if relevant for search.',
8787+ 'Return only the alt text with no labels, quotes, or options.',
8888 'No hashtags or emojis.',
8989 `Context: "${trimmed}"`,
9090 ].join(' ');
+70-2
src/index.ts
···545545 return text.replace(/\s+$/g, '').trim();
546546}
547547548548+function getTweetText(tweet: Tweet): string {
549549+ return tweet.full_text || tweet.text || '';
550550+}
551551+552552+function normalizeContextText(text: string): string {
553553+ return text.replace(/\s+/g, ' ').trim();
554554+}
555555+556556+function addTweetsToMap(tweetMap: Map<string, Tweet>, tweets: Tweet[]): void {
557557+ for (const tweet of tweets) {
558558+ const tweetId = tweet.id_str || tweet.id;
559559+ if (!tweetId) continue;
560560+ tweetMap.set(String(tweetId), tweet);
561561+ }
562562+}
563563+564564+function buildThreadContext(tweet: Tweet, tweetMap: Map<string, Tweet>, maxHops = 8): string {
565565+ const parts: string[] = [];
566566+ const visited = new Set<string>();
567567+ let current: Tweet | undefined = tweet;
568568+569569+ for (let hops = 0; hops < maxHops; hops++) {
570570+ const parentId = current?.in_reply_to_status_id_str || current?.in_reply_to_status_id;
571571+ if (!parentId) break;
572572+ const parentKey = String(parentId);
573573+ if (visited.has(parentKey)) break;
574574+ visited.add(parentKey);
575575+576576+ const parentTweet = tweetMap.get(parentKey);
577577+ if (!parentTweet) break;
578578+579579+ const parentText = normalizeContextText(getTweetText(parentTweet));
580580+ if (parentText) parts.push(parentText);
581581+582582+ current = parentTweet;
583583+ }
584584+585585+ if (parts.length === 0) return '';
586586+ return parts.reverse().join(' | ');
587587+}
588588+589589+function buildAltTextContext(tweet: Tweet, tweetText: string, tweetMap: Map<string, Tweet>): string {
590590+ const threadContext = buildThreadContext(tweet, tweetMap);
591591+ const currentText = normalizeContextText(tweetText);
592592+593593+ if (threadContext && currentText) {
594594+ return `Thread above: ${threadContext}. Current tweet: ${currentText}`;
595595+ }
596596+597597+ if (threadContext) return `Thread above: ${threadContext}.`;
598598+ return currentText;
599599+}
600600+548601async function fetchSyndicationMedia(tweetUrl: string): Promise<{ images: string[] }> {
549602 try {
550603 const normalized = tweetUrl.replace('twitter.com', 'x.com');
···12521305 tweets: Tweet[],
12531306 dryRun = false,
12541307 sharedProcessedMap?: ProcessedTweetsMap,
13081308+ sharedTweetMap?: Map<string, Tweet>,
12551309): Promise<void> {
12561310 // Filter tweets to ensure they're actually from this user
12571311 const filteredTweets = tweets.filter((t) => {
···12641318 }
12651319 return true;
12661320 });
13211321+13221322+ const tweetMap = sharedTweetMap ?? new Map<string, Tweet>();
13231323+ addTweetsToMap(tweetMap, filteredTweets);
1267132412681325 // Maintain a local map that updates in real-time for intra-batch replies
12691326 const localProcessedMap: ProcessedTweetsMap =
···1355141213561413 if (parentAuthor?.toLowerCase() === twitterUsername.toLowerCase()) {
13571414 console.log(`[${twitterUsername}] 🔄 Parent is ours (@${parentAuthor}). Backfilling parent first...`);
14151415+ addTweetsToMap(tweetMap, [parentTweet]);
13581416 // Recursively process the parent
13591359- await processTweets(agent, twitterUsername, bskyIdentifier, [parentTweet], dryRun, localProcessedMap);
14171417+ await processTweets(
14181418+ agent,
14191419+ twitterUsername,
14201420+ bskyIdentifier,
14211421+ [parentTweet],
14221422+ dryRun,
14231423+ localProcessedMap,
14241424+ tweetMap,
14251425+ );
1360142613611427 // Check if it was saved
13621428 const savedParent = dbService.getTweet(replyStatusId, bskyIdentifier);
···14071473 }
1408147414091475 // Removed early dryRun continue to allow verifying logic
14761476+14771477+ const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap);
1410147814111479 let text = tweetText
14121480 .replace(/&/g, '&')
···15021570 if (!altText) {
15031571 console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`);
15041572 // Use original tweet text for context, not the modified/cleaned one
15051505- altText = await generateAltText(buffer, mimeType, tweetText);
15731573+ altText = await generateAltText(buffer, mimeType, altTextContext);
15061574 if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`);
15071575 }
15081576