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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: enforce per-image alt text output

Jack G feb9e86c 376e7a39

+36 -11
+35 -9
src/ai-manager.ts
··· 47 47 switch (provider) { 48 48 case 'gemini': 49 49 // apiKey is guaranteed by check above 50 - return await callGemini(apiKey!, model || 'models/gemini-2.5-flash', buffer, mimeType, prompt); 50 + return normalizeAltTextOutput( 51 + await callGemini(apiKey!, model || 'models/gemini-2.5-flash', buffer, mimeType, prompt), 52 + ); 51 53 case 'openai': 52 54 case 'custom': 53 - return await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, prompt); 55 + return normalizeAltTextOutput( 56 + await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, prompt), 57 + ); 54 58 case 'anthropic': 55 59 // apiKey is guaranteed by check above 56 - return await callAnthropic( 57 - apiKey!, 58 - model || 'claude-3-5-sonnet-20241022', 59 - baseUrl, 60 - buffer, 61 - mimeType, 62 - prompt, 60 + return normalizeAltTextOutput( 61 + await callAnthropic( 62 + apiKey!, 63 + model || 'claude-3-5-sonnet-20241022', 64 + baseUrl, 65 + buffer, 66 + mimeType, 67 + prompt, 68 + ), 63 69 ); 64 70 default: 65 71 console.warn(`[AI] ⚠️ Unknown provider: ${provider}`); ··· 84 90 'Write one alt text description (1-2 sentences).', 85 91 'Describe only what is visible.', 86 92 'Use context to identify people/places/objects if relevant for search.', 93 + 'Describe only this image; ignore other images in the post.', 87 94 'Return only the alt text with no labels, quotes, or options.', 88 95 'No hashtags or emojis.', 89 96 `Context: "${trimmed}"`, 90 97 ].join(' '); 98 + } 99 + 100 + function normalizeAltTextOutput(output: string | undefined): string | undefined { 101 + if (!output) return undefined; 102 + 103 + let cleaned = output.trim(); 104 + if (!cleaned) return undefined; 105 + 106 + cleaned = cleaned.replace(/^["'“”]+|["'“”]+$/g, '').trim(); 107 + cleaned = cleaned.replace(/^(alt\s*text|description)\s*[:\-]\s*/i, '').trim(); 108 + 109 + const lines = cleaned.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); 110 + if (lines.length > 0) cleaned = lines[0]; 111 + 112 + cleaned = cleaned.replace(/^option\s*\d+\s*[:\-]\s*/i, '').trim(); 113 + cleaned = cleaned.replace(/^[\-\*\d\.\)]+\s*/g, '').trim(); 114 + cleaned = cleaned.replace(/\s+/g, ' ').trim(); 115 + 116 + return cleaned || undefined; 91 117 } 92 118 93 119 async function callGemini(
+1 -2
src/index.ts
··· 1474 1474 1475 1475 // Removed early dryRun continue to allow verifying logic 1476 1476 1477 - const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap); 1478 - 1479 1477 let text = tweetText 1480 1478 .replace(/&amp;/g, '&') 1481 1479 .replace(/&lt;/g, '<') ··· 1570 1568 if (!altText) { 1571 1569 console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`); 1572 1570 // Use original tweet text for context, not the modified/cleaned one 1571 + const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap); 1573 1572 altText = await generateAltText(buffer, mimeType, altTextContext); 1574 1573 if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`); 1575 1574 }