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.

Enhancement: Add backup Twitter credentials, auto-switching, and improved pacing

jack d2236320 60694d85

+84 -24
+18 -3
public/index.html
··· 366 366 try { 367 367 await axios.post('/api/twitter-config', { 368 368 authToken: formData.get('authToken'), 369 - ct0: formData.get('ct0') 369 + ct0: formData.get('ct0'), 370 + backupAuthToken: formData.get('backupAuthToken'), 371 + backupCt0: formData.get('backupCt0') 370 372 }, { headers: { Authorization: `Bearer ${token}` } }); 371 373 alert('Twitter config updated!'); 372 374 fetchData(); ··· 661 663 <div className="accordion-body bg-light bg-opacity-50"> 662 664 <form onSubmit={updateTwitter}> 663 665 <div className="mb-3"> 664 - <label className="form-label small fw-bold text-muted">Auth Token</label> 666 + <label className="form-label small fw-bold text-muted">Primary Auth Token</label> 665 667 <input name="authToken" defaultValue={twitterConfig.authToken} className="form-control form-control-sm" placeholder="auth_token" required /> 666 668 </div> 667 669 <div className="mb-3"> 668 - <label className="form-label small fw-bold text-muted">CT0</label> 670 + <label className="form-label small fw-bold text-muted">Primary CT0</label> 669 671 <input name="ct0" defaultValue={twitterConfig.ct0} className="form-control form-control-sm" placeholder="ct0" required /> 670 672 </div> 673 + 674 + <div className="border-top my-3 pt-3"> 675 + <h6 className="small text-uppercase text-muted fw-bold mb-3">Backup Credentials (Optional)</h6> 676 + <div className="mb-3"> 677 + <label className="form-label small fw-bold text-muted">Backup Auth Token</label> 678 + <input name="backupAuthToken" defaultValue={twitterConfig.backupAuthToken} className="form-control form-control-sm" placeholder="auth_token" /> 679 + </div> 680 + <div className="mb-3"> 681 + <label className="form-label small fw-bold text-muted">Backup CT0</label> 682 + <input name="backupCt0" defaultValue={twitterConfig.backupCt0} className="form-control form-control-sm" placeholder="ct0" /> 683 + </div> 684 + </div> 685 + 671 686 <button className="btn btn-primary btn-sm w-100">Save Credentials</button> 672 687 </form> 673 688 </div>
+2
src/config-manager.ts
··· 10 10 export interface TwitterConfig { 11 11 authToken: string; 12 12 ct0: string; 13 + backupAuthToken?: string; 14 + backupCt0?: string; 13 15 } 14 16 15 17 export interface WebUser {
+62 -19
src/index.ts
··· 197 197 198 198 let scraper: Scraper | null = null; 199 199 let currentTwitterCookies = { authToken: '', ct0: '' }; 200 + let useBackupCredentials = false; 200 201 201 - async function getTwitterScraper(): Promise<Scraper | null> { 202 + async function getTwitterScraper(forceReset = false): Promise<Scraper | null> { 202 203 const config = getConfig(); 203 - if (!config.twitter.authToken || !config.twitter.ct0) return null; 204 + let authToken = config.twitter.authToken; 205 + let ct0 = config.twitter.ct0; 206 + 207 + // Use backup if toggled 208 + if (useBackupCredentials && config.twitter.backupAuthToken && config.twitter.backupCt0) { 209 + authToken = config.twitter.backupAuthToken; 210 + ct0 = config.twitter.backupCt0; 211 + } 212 + 213 + if (!authToken || !ct0) return null; 204 214 205 - // Re-initialize if config changed or not yet initialized 206 - if (!scraper || 207 - currentTwitterCookies.authToken !== config.twitter.authToken || 208 - currentTwitterCookies.ct0 !== config.twitter.ct0) { 209 - 215 + // Re-initialize if config changed, not yet initialized, or forced reset 216 + if ( 217 + !scraper || 218 + forceReset || 219 + currentTwitterCookies.authToken !== authToken || 220 + currentTwitterCookies.ct0 !== ct0 221 + ) { 222 + console.log(`🔄 Initializing Twitter scraper with ${useBackupCredentials ? 'BACKUP' : 'PRIMARY'} credentials...`); 210 223 scraper = new Scraper(); 211 224 await scraper.setCookies([ 212 - `auth_token=${config.twitter.authToken}`, 213 - `ct0=${config.twitter.ct0}` 225 + `auth_token=${authToken}`, 226 + `ct0=${ct0}` 214 227 ]); 215 228 216 229 currentTwitterCookies = { 217 - authToken: config.twitter.authToken, 218 - ct0: config.twitter.ct0 230 + authToken: authToken, 231 + ct0: ct0 219 232 }; 220 233 } 221 234 return scraper; 222 235 } 223 236 237 + async function switchCredentials() { 238 + const config = getConfig(); 239 + if (config.twitter.backupAuthToken && config.twitter.backupCt0) { 240 + useBackupCredentials = !useBackupCredentials; 241 + console.log(`⚠️ Switching to ${useBackupCredentials ? 'BACKUP' : 'PRIMARY'} Twitter credentials...`); 242 + await getTwitterScraper(true); 243 + return true; 244 + } 245 + console.log("⚠️ No backup credentials available to switch to."); 246 + return false; 247 + } 248 + 224 249 function mapScraperTweetToLocalTweet(scraperTweet: ScraperTweet): Tweet { 225 250 const raw = scraperTweet.__raw_UNSTABLE; 226 251 if (!raw) { ··· 710 735 return tweets; 711 736 } catch (e: any) { 712 737 retries--; 713 - const isRetryable = e.message?.includes('ServiceUnavailable') || e.message?.includes('Timeout') || e.message?.includes('429'); 738 + const isRetryable = e.message?.includes('ServiceUnavailable') || e.message?.includes('Timeout') || e.message?.includes('429') || e.message?.includes('401'); 714 739 715 - if (retries > 0 && isRetryable) { 716 - console.warn(`⚠️ Error fetching tweets for ${username} (${e.message}). Retrying in 5s...`); 717 - await new Promise(r => setTimeout(r, 5000)); 718 - continue; 740 + if (isRetryable) { 741 + console.warn(`⚠️ Error fetching tweets for ${username} (${e.message}).`); 742 + 743 + // Attempt credential switch if we have backups 744 + if (await switchCredentials()) { 745 + console.log(`🔄 Retrying with new credentials...`); 746 + continue; // Retry loop with new credentials 747 + } 748 + 749 + if (retries > 0) { 750 + console.log(`Waiting 5s before retry...`); 751 + await new Promise(r => setTimeout(r, 5000)); 752 + continue; 753 + } 719 754 } 720 755 721 756 console.warn(`Error fetching tweets for ${username}:`, e); ··· 1118 1153 } 1119 1154 } 1120 1155 1121 - const wait = 10000; 1156 + // Add a random delay between 5s and 15s to be more human-like 1157 + const wait = Math.floor(Math.random() * 10000) + 5000; 1122 1158 console.log(`[${twitterUsername}] 😴 Pacing: Waiting ${wait / 1000}s before next tweet.`); 1123 1159 updateAppStatus({ state: 'pacing', message: `Pacing: Waiting ${wait / 1000}s...` }); 1124 1160 await new Promise((r) => setTimeout(r, wait)); ··· 1276 1312 })(); 1277 1313 1278 1314 activeTasks.set(mapping.id, task); 1315 + return task; // Return task promise for await in main loop 1279 1316 } 1280 1317 1281 1318 import { ··· 1366 1403 1367 1404 // Run if scheduled OR backfill requested 1368 1405 if (isScheduledRun || hasPendingBackfill) { 1369 - runAccountTask(mapping, hasPendingBackfill, options.dryRun); 1406 + // Await the task to ensure we don't bombard twitter 1407 + await runAccountTask(mapping, hasPendingBackfill, options.dryRun); 1408 + 1409 + // Random delay between 2-5 seconds between accounts 1410 + const accountDelay = Math.floor(Math.random() * 3000) + 2000; 1411 + console.log(`[Scheduler] ⏳ Waiting ${accountDelay}ms before next account...`); 1412 + await new Promise(r => setTimeout(r, accountDelay)); 1370 1413 } 1371 1414 } 1372 1415 ··· 1375 1418 } 1376 1419 } 1377 1420 1378 - main(); 1421 + main();
+2 -2
src/server.ts
··· 211 211 }); 212 212 213 213 app.post('/api/twitter-config', authenticateToken, requireAdmin, (req, res) => { 214 - const { authToken, ct0 } = req.body; 214 + const { authToken, ct0, backupAuthToken, backupCt0 } = req.body; 215 215 const config = getConfig(); 216 - config.twitter = { authToken, ct0 }; 216 + config.twitter = { authToken, ct0, backupAuthToken, backupCt0 }; 217 217 saveConfig(config); 218 218 res.json({ success: true }); 219 219 });