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.

feat: include thread context in alt text

Jack G 376e7a39 ba862fc0

+73 -5
+3 -3
src/ai-manager.ts
··· 81 81 : normalized; 82 82 83 83 return [ 84 - 'Write alt text (1-2 sentences).', 84 + 'Write one alt text description (1-2 sentences).', 85 85 'Describe only what is visible.', 86 - 'Use context to identify people/places/objects if relevant.', 87 - 'Include key names for search.', 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 88 'No hashtags or emojis.', 89 89 `Context: "${trimmed}"`, 90 90 ].join(' ');
+70 -2
src/index.ts
··· 545 545 return text.replace(/\s+$/g, '').trim(); 546 546 } 547 547 548 + 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 + 548 601 async function fetchSyndicationMedia(tweetUrl: string): Promise<{ images: string[] }> { 549 602 try { 550 603 const normalized = tweetUrl.replace('twitter.com', 'x.com'); ··· 1252 1305 tweets: Tweet[], 1253 1306 dryRun = false, 1254 1307 sharedProcessedMap?: ProcessedTweetsMap, 1308 + sharedTweetMap?: Map<string, Tweet>, 1255 1309 ): Promise<void> { 1256 1310 // Filter tweets to ensure they're actually from this user 1257 1311 const filteredTweets = tweets.filter((t) => { ··· 1264 1318 } 1265 1319 return true; 1266 1320 }); 1321 + 1322 + const tweetMap = sharedTweetMap ?? new Map<string, Tweet>(); 1323 + addTweetsToMap(tweetMap, filteredTweets); 1267 1324 1268 1325 // Maintain a local map that updates in real-time for intra-batch replies 1269 1326 const localProcessedMap: ProcessedTweetsMap = ··· 1355 1412 1356 1413 if (parentAuthor?.toLowerCase() === twitterUsername.toLowerCase()) { 1357 1414 console.log(`[${twitterUsername}] 🔄 Parent is ours (@${parentAuthor}). Backfilling parent first...`); 1415 + addTweetsToMap(tweetMap, [parentTweet]); 1358 1416 // Recursively process the parent 1359 - await processTweets(agent, twitterUsername, bskyIdentifier, [parentTweet], dryRun, localProcessedMap); 1417 + await processTweets( 1418 + agent, 1419 + twitterUsername, 1420 + bskyIdentifier, 1421 + [parentTweet], 1422 + dryRun, 1423 + localProcessedMap, 1424 + tweetMap, 1425 + ); 1360 1426 1361 1427 // Check if it was saved 1362 1428 const savedParent = dbService.getTweet(replyStatusId, bskyIdentifier); ··· 1407 1473 } 1408 1474 1409 1475 // Removed early dryRun continue to allow verifying logic 1476 + 1477 + const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap); 1410 1478 1411 1479 let text = tweetText 1412 1480 .replace(/&amp;/g, '&') ··· 1502 1570 if (!altText) { 1503 1571 console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`); 1504 1572 // Use original tweet text for context, not the modified/cleaned one 1505 - altText = await generateAltText(buffer, mimeType, tweetText); 1573 + altText = await generateAltText(buffer, mimeType, altTextContext); 1506 1574 if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`); 1507 1575 } 1508 1576