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.
···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.
1515+ * **Human-like Pacing:** Randomly waits (1-4s) between posts to behave more like a real user and avoid spam detection.
1616+ * **Auto-Healing:** Automatically rotates internal Twitter Query IDs if they expire, ensuring the tool keeps working 24/7 without manual intervention.
1617* **Threads & Replies:**
1718 * **Perfect Threading:** If you write a thread (reply to yourself) on Twitter, it appears as a threaded conversation on Bluesky.
1819 * **Clean Feed:** Automatically filters out your replies to *other* people, keeping your Bluesky timeline focused on your original content.
+61-47
index.js
···77const { TwitterClient } = require('@steipete/bird/dist/lib/twitter-client');
88const franc = require('franc-min');
99const iso6391 = require('iso-639-1');
1010+const { exec } = require('child_process');
10111112// Configuration
1213const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN;
···147148 return "me";
148149}
149150151151+function getRandomDelay(min = 1000, max = 4000) {
152152+ return Math.floor(Math.random() * (max - min + 1) + min);
153153+}
154154+155155+function refreshQueryIds() {
156156+ return new Promise((resolve) => {
157157+ console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI...");
158158+ exec('./node_modules/.bin/bird query-ids --fresh', (error, stdout, stderr) => {
159159+ if (error) {
160160+ console.error(`Error refreshing IDs: ${error.message}`);
161161+ console.error(`Stderr: ${stderr}`);
162162+ } else {
163163+ console.log("✅ Query IDs refreshed successfully.");
164164+ }
165165+ resolve();
166166+ });
167167+ });
168168+}
169169+170170+/**
171171+ * Wraps twitter.search with auto-recovery for stale Query IDs
172172+ */
173173+async function safeSearch(query, limit) {
174174+ try {
175175+ const result = await twitter.search(query, limit);
176176+ // Sometimes it returns success: false but no throw
177177+ if (!result.success && result.error &&
178178+ (result.error.toString().includes('GraphQL') || result.error.toString().includes('404'))) {
179179+ throw new Error(result.error);
180180+ }
181181+ return result;
182182+ } catch (err) {
183183+ console.warn(`Search encountered an error: ${err.message || err}`);
184184+ if (err.message && (err.message.includes('GraphQL') || err.message.includes('404') || err.message.includes('Bad Guest Token'))) {
185185+ await refreshQueryIds();
186186+ console.log("Retrying search...");
187187+ return await twitter.search(query, limit);
188188+ }
189189+ return { success: false, error: err };
190190+ }
191191+}
192192+150193// --- Main Processing Logic ---
151194152152-async function processTweets(tweets, delayBetweenPosts = 1000) {
195195+async function processTweets(tweets) {
153196 // Ensure chronological order
154197 tweets.reverse();
155198···162205 // --- Filter Replies (unless we are maintaining a thread) ---
163206 // If it's a reply, but the parent IS in our DB, we want to post it as a reply.
164207 // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets).
165165- // User asked: "if i do it on twitter... it should continue out a thread".
166208167209 const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id;
168210 const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id;
···223265 }
224266225267 // Aspect Ratio Extraction
226226- // Twitter gives sizes: { large: { w, h, resize }, ... }
227268 let aspectRatio = undefined;
228269 if (media.sizes?.large) {
229270 aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h };
···233274234275 if (media.type === 'photo') {
235276 const url = media.media_url_https;
236236- // console.log(`Downloading image: ${url}`);
237277 try {
238278 const { buffer, mimeType } = await downloadMedia(url);
239279 const blob = await uploadToBluesky(buffer, mimeType);
···251291252292 if (mp4s.length > 0) {
253293 const videoUrl = mp4s[0].url;
254254- // console.log(`Downloading video: ${videoUrl}`);
255294 try {
256295 const { buffer, mimeType } = await downloadMedia(videoUrl);
257296···284323 if (tweet.is_quote_status && tweet.quoted_status_id_str) {
285324 const quoteId = tweet.quoted_status_id_str;
286325 if (processedTweets[quoteId] && !processedTweets[quoteId].migrated) {
287287- // We have the quoted tweet in our history!
288326 const ref = processedTweets[quoteId];
289327 quoteEmbed = {
290328 $type: 'app.bsky.embed.record',
···293331 cid: ref.cid
294332 }
295333 };
296296- // Remove the quote URL from text if present (usually at the end)
297297- // Twitter API usually includes the quote URL in entities.urls, so it might be expanded already.
298298- // We should find the url that points to the tweet and remove it.
299299- // A simple heuristic: remove the last url if it looks like a twitter link to the quote.
300334 }
301335 }
302336···312346 createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString()
313347 };
314348315315- // Attach Embeds (Complex Logic for handling Media + Quote)
349349+ // Attach Embeds
316350 if (videoBlob) {
317317- // Video + Quote is not natively supported in one simple embed field yet in standard way without recordWithMedia?
318318- // Actually recordWithMedia supports Images + Record. Does it support Video + Record?
319319- // Currently app.bsky.embed.video is standalone.
320320- // If we have video AND quote, we might have to drop the quote embed or just link it.
321321- // For now: Prioritize Video.
322351 postRecord.embed = {
323352 $type: 'app.bsky.embed.video',
324353 video: videoBlob,
···331360 };
332361333362 if (quoteEmbed) {
334334- // Media + Quote -> app.bsky.embed.recordWithMedia
335363 postRecord.embed = {
336364 $type: 'app.bsky.embed.recordWithMedia',
337365 media: imagesEmbed,
···357385 const response = await agent.post(postRecord);
358386 // console.log(`Posted: ${tweetId}`);
359387360360- // Save with Threading Info
361388 const newEntry = {
362389 uri: response.uri,
363390 cid: response.cid,
···367394 processedTweets[tweetId] = newEntry;
368395 saveProcessedTweets();
369396370370- // Pacing
371371- if (delayBetweenPosts > 0) {
372372- // Min delay + random jitter (0-500ms)
373373- const sleepTime = delayBetweenPosts + Math.floor(Math.random() * 500);
374374- // console.log(`Sleeping ${sleepTime}ms...`);
375375- await new Promise(r => setTimeout(r, sleepTime));
376376- }
397397+ // Random Pacing (1s - 4s)
398398+ const sleepTime = getRandomDelay(1000, 4000);
399399+ // console.log(`Sleeping ${sleepTime}ms...`);
400400+ await new Promise(r => setTimeout(r, sleepTime));
377401378402 } catch (err) {
379403 console.error(`Failed to post ${tweetId}:`, err);
···387411 try {
388412 const username = await getUsername();
389413390390- // We still filter replies at source to save API calls,
391391- // but our processTweets logic now handles "threading" if we accidentally fetch a reply
392392- // (or if we remove the filter later).
393393- // Current requirement: "filter replies" but "continue thread".
394394- // If we filter replies in search, we WON'T see our own replies to thread them.
395395- // So we MUST remove -filter:replies from the search if we want to support threading.
396396- // BUT user said "it's also posting all my replies which i don't want... it should only crosspost original Tweets".
397397- // AND "if i do it on twitter... it should continue out a thread".
398398-399399- // Solution: Fetch EVERYTHING (no -filter:replies), but in `processTweets`,
400400- // ONLY post if it is NOT a reply OR if it is a reply to a KNOWN parent in `processedTweets`.
401401-402402- const query = `from:${username}`; // Removed -filter:replies to allow threading checks
403403- const result = await twitter.search(query, 30); // Fetch a few more to be safe
414414+ // Use safeSearch with auto-refresh for IDs
415415+ const query = `from:${username}`;
416416+ const result = await safeSearch(query, 30);
404417405418 if (!result.success) {
406419 console.error("Failed to fetch tweets:", result.error);
···410423 const tweets = result.tweets || [];
411424 if (tweets.length === 0) return;
412425413413- await processTweets(tweets, 1000); // 1s delay for live checks
426426+ await processTweets(tweets);
414427415428 } catch (err) {
416429 console.error("Error in checkAndPost:", err);
···429442 const seenIds = new Set();
430443431444 while (keepGoing) {
432432- // We fetch everything (including replies) so we can thread them if valid
433433- let query = `from:${username}`;
445445+ let query = `from:${username}`;
434446 if (maxId) {
435447 query += ` max_id:${maxId}`;
436448 }
437449438450 console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`);
439451440440- const result = await twitter.search(query, count);
452452+ const result = await safeSearch(query, count);
441453442454 if (!result.success) {
443455 console.error("Fetch failed:", result.error);
···472484 console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`);
473485474486 if (allFoundTweets.length > 0) {
475475- console.log("Starting processing (Oldest -> Newest) with pacing...");
476476- // 1 seconds delay average for human-like backfill
477477- await processTweets(allFoundTweets, 1000);
487487+ console.log("Starting processing (Oldest -> Newest) with random pacing...");
488488+ await processTweets(allFoundTweets);
478489 console.log("History import complete.");
479490 } else {
480491 console.log("Nothing new to import.");
···501512 process.exit(0);
502513 }
503514515515+ // Refresh IDs on startup just to be safe/fresh
516516+ // await refreshQueryIds();
517517+504518 await checkAndPost();
505519506520 console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`);
507521 cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, checkAndPost);
508508-})();
522522+})();
+1-1
package.json
···11{
22 "name": "tweets-2-bsky",
33- "version": "1.0.2",
33+ "version": "1.0.3",
44 "description": "A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images.",
55 "main": "index.js",
66 "scripts": {