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.
···81 : normalized;
8283 return [
84- 'Write alt text (1-2 sentences).',
85 'Describe only what is visible.',
86- 'Use context to identify people/places/objects if relevant.',
87- 'Include key names for search.',
88 'No hashtags or emojis.',
89 `Context: "${trimmed}"`,
90 ].join(' ');
···81 : normalized;
8283 return [
84+ 'Write one alt text description (1-2 sentences).',
85 'Describe only what is visible.',
86+ 'Use context to identify people/places/objects if relevant for search.',
87+ 'Return only the alt text with no labels, quotes, or options.',
88 'No hashtags or emojis.',
89 `Context: "${trimmed}"`,
90 ].join(' ');
+70-2
src/index.ts
···545 return text.replace(/\s+$/g, '').trim();
546}
54700000000000000000000000000000000000000000000000000000548async function fetchSyndicationMedia(tweetUrl: string): Promise<{ images: string[] }> {
549 try {
550 const normalized = tweetUrl.replace('twitter.com', 'x.com');
···1252 tweets: Tweet[],
1253 dryRun = false,
1254 sharedProcessedMap?: ProcessedTweetsMap,
01255): Promise<void> {
1256 // Filter tweets to ensure they're actually from this user
1257 const filteredTweets = tweets.filter((t) => {
···1264 }
1265 return true;
1266 });
00012671268 // Maintain a local map that updates in real-time for intra-batch replies
1269 const localProcessedMap: ProcessedTweetsMap =
···13551356 if (parentAuthor?.toLowerCase() === twitterUsername.toLowerCase()) {
1357 console.log(`[${twitterUsername}] 🔄 Parent is ours (@${parentAuthor}). Backfilling parent first...`);
01358 // Recursively process the parent
1359- await processTweets(agent, twitterUsername, bskyIdentifier, [parentTweet], dryRun, localProcessedMap);
0000000013601361 // Check if it was saved
1362 const savedParent = dbService.getTweet(replyStatusId, bskyIdentifier);
···1407 }
14081409 // Removed early dryRun continue to allow verifying logic
0014101411 let text = tweetText
1412 .replace(/&/g, '&')
···1502 if (!altText) {
1503 console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`);
1504 // Use original tweet text for context, not the modified/cleaned one
1505- altText = await generateAltText(buffer, mimeType, tweetText);
1506 if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`);
1507 }
1508
···545 return text.replace(/\s+$/g, '').trim();
546}
547548+function getTweetText(tweet: Tweet): string {
549+ return tweet.full_text || tweet.text || '';
550+}
551+552+function normalizeContextText(text: string): string {
553+ return text.replace(/\s+/g, ' ').trim();
554+}
555+556+function addTweetsToMap(tweetMap: Map<string, Tweet>, tweets: Tweet[]): void {
557+ for (const tweet of tweets) {
558+ const tweetId = tweet.id_str || tweet.id;
559+ if (!tweetId) continue;
560+ tweetMap.set(String(tweetId), tweet);
561+ }
562+}
563+564+function buildThreadContext(tweet: Tweet, tweetMap: Map<string, Tweet>, maxHops = 8): string {
565+ const parts: string[] = [];
566+ const visited = new Set<string>();
567+ let current: Tweet | undefined = tweet;
568+569+ for (let hops = 0; hops < maxHops; hops++) {
570+ const parentId = current?.in_reply_to_status_id_str || current?.in_reply_to_status_id;
571+ if (!parentId) break;
572+ const parentKey = String(parentId);
573+ if (visited.has(parentKey)) break;
574+ visited.add(parentKey);
575+576+ const parentTweet = tweetMap.get(parentKey);
577+ if (!parentTweet) break;
578+579+ const parentText = normalizeContextText(getTweetText(parentTweet));
580+ if (parentText) parts.push(parentText);
581+582+ current = parentTweet;
583+ }
584+585+ if (parts.length === 0) return '';
586+ return parts.reverse().join(' | ');
587+}
588+589+function buildAltTextContext(tweet: Tweet, tweetText: string, tweetMap: Map<string, Tweet>): string {
590+ const threadContext = buildThreadContext(tweet, tweetMap);
591+ const currentText = normalizeContextText(tweetText);
592+593+ if (threadContext && currentText) {
594+ return `Thread above: ${threadContext}. Current tweet: ${currentText}`;
595+ }
596+597+ if (threadContext) return `Thread above: ${threadContext}.`;
598+ return currentText;
599+}
600+601async function fetchSyndicationMedia(tweetUrl: string): Promise<{ images: string[] }> {
602 try {
603 const normalized = tweetUrl.replace('twitter.com', 'x.com');
···1305 tweets: Tweet[],
1306 dryRun = false,
1307 sharedProcessedMap?: ProcessedTweetsMap,
1308+ sharedTweetMap?: Map<string, Tweet>,
1309): Promise<void> {
1310 // Filter tweets to ensure they're actually from this user
1311 const filteredTweets = tweets.filter((t) => {
···1318 }
1319 return true;
1320 });
1321+1322+ const tweetMap = sharedTweetMap ?? new Map<string, Tweet>();
1323+ addTweetsToMap(tweetMap, filteredTweets);
13241325 // Maintain a local map that updates in real-time for intra-batch replies
1326 const localProcessedMap: ProcessedTweetsMap =
···14121413 if (parentAuthor?.toLowerCase() === twitterUsername.toLowerCase()) {
1414 console.log(`[${twitterUsername}] 🔄 Parent is ours (@${parentAuthor}). Backfilling parent first...`);
1415+ addTweetsToMap(tweetMap, [parentTweet]);
1416 // Recursively process the parent
1417+ await processTweets(
1418+ agent,
1419+ twitterUsername,
1420+ bskyIdentifier,
1421+ [parentTweet],
1422+ dryRun,
1423+ localProcessedMap,
1424+ tweetMap,
1425+ );
14261427 // Check if it was saved
1428 const savedParent = dbService.getTweet(replyStatusId, bskyIdentifier);
···1473 }
14741475 // Removed early dryRun continue to allow verifying logic
1476+1477+ const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap);
14781479 let text = tweetText
1480 .replace(/&/g, '&')
···1570 if (!altText) {
1571 console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`);
1572 // Use original tweet text for context, not the modified/cleaned one
1573+ altText = await generateAltText(buffer, mimeType, altTextContext);
1574 if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`);
1575 }
1576