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: improve error handling for video uploads, redirects, and twitter api errors

jack 3eed0651 5db3d46f

+42 -11
+42 -11
src/index.ts
··· 316 316 async function expandUrl(shortUrl: string): Promise<string> { 317 317 try { 318 318 const response = await axios.head(shortUrl, { 319 - maxRedirects: 10, 319 + maxRedirects: 5, 320 320 validateStatus: (status) => status >= 200 && status < 400, 321 321 }); 322 322 // biome-ignore lint/suspicious/noExplicitAny: axios internal types ··· 325 325 try { 326 326 const response = await axios.get(shortUrl, { 327 327 responseType: 'stream', 328 - maxRedirects: 10, 328 + maxRedirects: 5, 329 329 }); 330 330 response.data.destroy(); 331 331 // biome-ignore lint/suspicious/noExplicitAny: axios internal types 332 332 return (response.request as any)?.res?.responseUrl || shortUrl; 333 - } catch { 333 + } catch (e: any) { 334 + if (e.code === 'ERR_FR_TOO_MANY_REDIRECTS' || e.response?.status === 403 || e.response?.status === 401) { 335 + // Silent fallback for common expansion issues (redirect loops, login walls) 336 + return shortUrl; 337 + } 334 338 return shortUrl; 335 339 } 336 340 } ··· 570 574 'Accept-Language': 'en-US,en;q=0.9', 571 575 }, 572 576 timeout: 10000, 577 + maxRedirects: 5, 573 578 }); 574 579 575 580 const $ = cheerio.load(response.data); ··· 587 592 const { buffer, mimeType } = await downloadMedia(imageUrl); 588 593 thumbBlob = await uploadToBluesky(agent, buffer, mimeType); 589 594 } catch (e) { 590 - console.warn(`Failed to upload thumbnail for ${url}:`, e); 595 + // SIlently fail thumbnail upload 591 596 } 592 597 } 593 598 ··· 608 613 external, 609 614 }; 610 615 611 - } catch (err) { 612 - console.warn(`Failed to fetch embed card for ${url}:`, err); 616 + } catch (err: any) { 617 + if (err.code === 'ERR_FR_TOO_MANY_REDIRECTS') { 618 + // Ignore redirect loops 619 + return null; 620 + } 621 + console.warn(`Failed to fetch embed card for ${url}:`, err.message || err); 613 622 return null; 614 623 } 615 624 } ··· 662 671 663 672 if (!uploadResponse.ok) { 664 673 const errorText = await uploadResponse.text(); 665 - console.error(`[VIDEO] ❌ Server responded with ${uploadResponse.status}: ${errorText}`); 666 674 675 + // Handle specific error cases 667 676 try { 668 677 const errorJson = JSON.parse(errorText); 678 + 679 + // Handle server overload gracefully 680 + if (uploadResponse.status === 503 || errorJson.error === "Server does not have enough capacity to handle uploads") { 681 + console.warn(`[VIDEO] ⚠️ Server overloaded (503). Skipping video upload and falling back to link.`); 682 + throw new Error("VIDEO_FALLBACK_503"); 683 + } 684 + 669 685 if (errorJson.error === "already_exists" && errorJson.jobId) { 670 686 console.log(`[VIDEO] ♻️ Video already exists. Resuming with Job ID: ${errorJson.jobId}`); 671 687 return await pollForVideoProcessing(agent, errorJson.jobId); ··· 675 691 throw new Error("Bluesky Email Unconfirmed - Video Upload Rejected"); 676 692 } 677 693 } catch (e) { 678 - // Not JSON or missing fields, proceed with throwing 694 + if ((e as Error).message === "VIDEO_FALLBACK_503") throw e; 695 + // Not JSON or missing fields, proceed with throwing original error 679 696 } 680 - 697 + 698 + console.error(`[VIDEO] ❌ Server responded with ${uploadResponse.status}: ${errorText}`); 681 699 throw new Error(`Video upload failed: ${uploadResponse.status} ${errorText}`); 682 700 } 683 701 ··· 803 821 retries--; 804 822 const isRetryable = e.message?.includes('ServiceUnavailable') || e.message?.includes('Timeout') || e.message?.includes('429') || e.message?.includes('401'); 805 823 824 + // Check for Twitter Internal Server Error (often returns 400 with specific body) 825 + if (e?.response?.status === 400 && JSON.stringify(e?.response?.data || {}).includes('InternalServerError')) { 826 + console.warn(`⚠️ Twitter Internal Server Error (Transient) for ${username}.`); 827 + // Treat as retryable 828 + if (retries > 0) { 829 + await new Promise(r => setTimeout(r, 5000)); 830 + continue; 831 + } 832 + } 833 + 806 834 if (isRetryable) { 807 835 console.warn(`⚠️ Error fetching tweets for ${username} (${e.message}).`); 808 836 ··· 819 847 } 820 848 } 821 849 822 - console.warn(`Error fetching tweets for ${username}:`, e); 850 + console.warn(`Error fetching tweets for ${username}:`, e.message || e); 823 851 return []; 824 852 } 825 853 } ··· 1054 1082 const tweetUrl = `https://twitter.com/${twitterUsername}/status/${tweetId}`; 1055 1083 if (!text.includes(tweetUrl)) text += `\n\nVideo: ${tweetUrl}`; 1056 1084 } catch (err) { 1057 - console.error(`[${twitterUsername}] ❌ Failed video upload flow:`, (err as Error).message); 1085 + const errMsg = (err as Error).message; 1086 + if (errMsg !== "VIDEO_FALLBACK_503") { 1087 + console.error(`[${twitterUsername}] ❌ Failed video upload flow:`, errMsg); 1088 + } 1058 1089 const tweetUrl = `https://twitter.com/${twitterUsername}/status/${tweetId}`; 1059 1090 if (!text.includes(tweetUrl)) text += `\n\nVideo: ${tweetUrl}`; 1060 1091 }