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.
···1010 * **Videos:** Downloads videos from Twitter and uploads them natively to Bluesky (up to 100MB).
1111 * **Images:** Uploads high-resolution images with the **correct aspect ratio** (no weird cropping).
1212 * **Links:** Automatically removes `t.co` tracking links and expands them to their real destinations.
1313+* **Smart Features:**
1414+ * **Language Detection:** Automatically detects the language of your tweet (e.g., English, Japanese) and tags the Bluesky post correctly.
1515+ * **Human-like Pacing:** Adds a small delay between posts to prevent spam detection and rate limits.
1316* **Threads & Replies:**
1417 * **Perfect Threading:** If you write a thread (reply to yourself) on Twitter, it appears as a threaded conversation on Bluesky.
1518 * **Clean Feed:** Automatically filters out your replies to *other* people, keeping your Bluesky timeline focused on your original content.
+23-6
index.js
···55const axios = require('axios');
66const cron = require('node-cron');
77const { TwitterClient } = require('@steipete/bird/dist/lib/twitter-client');
88+const franc = require('franc-min');
99+const iso6391 = require('iso-639-1');
810911// Configuration
1012const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN;
···81838284// --- Helper Functions ---
83858686+function detectLanguage(text) {
8787+ if (!text || text.trim().length === 0) return ['en'];
8888+ try {
8989+ const code3 = franc(text);
9090+ if (code3 === 'und') return ['en']; // Undetermined
9191+ const code2 = iso6391.getCode(code3);
9292+ return code2 ? [code2] : ['en'];
9393+ } catch (e) {
9494+ return ['en'];
9595+ }
9696+}
9797+8498async function expandUrl(shortUrl) {
8599 try {
86100 const response = await axios.head(shortUrl, {
···135149136150// --- Main Processing Logic ---
137151138138-async function processTweets(tweets, delayBetweenPosts = 0) {
152152+async function processTweets(tweets, delayBetweenPosts = 1000) {
139153 // Ensure chronological order
140154 tweets.reverse();
141155···289303 // --- 4. Construct Post ---
290304 const rt = new RichText({ text });
291305 await rt.detectFacets(agent);
306306+ const detectedLangs = detectLanguage(text);
292307293308 const postRecord = {
294309 text: rt.text,
295310 facets: rt.facets,
311311+ langs: detectedLangs,
296312 createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString()
297313 };
298314···353369354370 // Pacing
355371 if (delayBetweenPosts > 0) {
356356- const sleepTime = Math.random() * delayBetweenPosts + 2000; // Min 2s + random
357357- // console.log(`Sleeping ${Math.floor(sleepTime)}ms...`);
372372+ // Min delay + random jitter (0-500ms)
373373+ const sleepTime = delayBetweenPosts + Math.floor(Math.random() * 500);
374374+ // console.log(`Sleeping ${sleepTime}ms...`);
358375 await new Promise(r => setTimeout(r, sleepTime));
359376 }
360377···393410 const tweets = result.tweets || [];
394411 if (tweets.length === 0) return;
395412396396- await processTweets(tweets, 0); // No extra delay for live checks
413413+ await processTweets(tweets, 1000); // 1s delay for live checks
397414398415 } catch (err) {
399416 console.error("Error in checkAndPost:", err);
···456473457474 if (allFoundTweets.length > 0) {
458475 console.log("Starting processing (Oldest -> Newest) with pacing...");
459459- // 5 seconds delay average for human-like backfill
460460- await processTweets(allFoundTweets, 5000);
476476+ // 1 seconds delay average for human-like backfill
477477+ await processTweets(allFoundTweets, 1000);
461478 console.log("History import complete.");
462479 } else {
463480 console.log("Nothing new to import.");