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.

v1.0.2: Add language detection and improve pacing

jack c1c7bd05 233d140d

+90 -10
+3
README.md
··· 10 10 * **Videos:** Downloads videos from Twitter and uploads them natively to Bluesky (up to 100MB). 11 11 * **Images:** Uploads high-resolution images with the **correct aspect ratio** (no weird cropping). 12 12 * **Links:** Automatically removes `t.co` tracking links and expands them to their real destinations. 13 + * **Smart Features:** 14 + * **Language Detection:** Automatically detects the language of your tweet (e.g., English, Japanese) and tags the Bluesky post correctly. 15 + * **Human-like Pacing:** Adds a small delay between posts to prevent spam detection and rate limits. 13 16 * **Threads & Replies:** 14 17 * **Perfect Threading:** If you write a thread (reply to yourself) on Twitter, it appears as a threaded conversation on Bluesky. 15 18 * **Clean Feed:** Automatically filters out your replies to *other* people, keeping your Bluesky timeline focused on your original content.
+23 -6
index.js
··· 5 5 const axios = require('axios'); 6 6 const cron = require('node-cron'); 7 7 const { TwitterClient } = require('@steipete/bird/dist/lib/twitter-client'); 8 + const franc = require('franc-min'); 9 + const iso6391 = require('iso-639-1'); 8 10 9 11 // Configuration 10 12 const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; ··· 81 83 82 84 // --- Helper Functions --- 83 85 86 + function detectLanguage(text) { 87 + if (!text || text.trim().length === 0) return ['en']; 88 + try { 89 + const code3 = franc(text); 90 + if (code3 === 'und') return ['en']; // Undetermined 91 + const code2 = iso6391.getCode(code3); 92 + return code2 ? [code2] : ['en']; 93 + } catch (e) { 94 + return ['en']; 95 + } 96 + } 97 + 84 98 async function expandUrl(shortUrl) { 85 99 try { 86 100 const response = await axios.head(shortUrl, { ··· 135 149 136 150 // --- Main Processing Logic --- 137 151 138 - async function processTweets(tweets, delayBetweenPosts = 0) { 152 + async function processTweets(tweets, delayBetweenPosts = 1000) { 139 153 // Ensure chronological order 140 154 tweets.reverse(); 141 155 ··· 289 303 // --- 4. Construct Post --- 290 304 const rt = new RichText({ text }); 291 305 await rt.detectFacets(agent); 306 + const detectedLangs = detectLanguage(text); 292 307 293 308 const postRecord = { 294 309 text: rt.text, 295 310 facets: rt.facets, 311 + langs: detectedLangs, 296 312 createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString() 297 313 }; 298 314 ··· 353 369 354 370 // Pacing 355 371 if (delayBetweenPosts > 0) { 356 - const sleepTime = Math.random() * delayBetweenPosts + 2000; // Min 2s + random 357 - // console.log(`Sleeping ${Math.floor(sleepTime)}ms...`); 372 + // Min delay + random jitter (0-500ms) 373 + const sleepTime = delayBetweenPosts + Math.floor(Math.random() * 500); 374 + // console.log(`Sleeping ${sleepTime}ms...`); 358 375 await new Promise(r => setTimeout(r, sleepTime)); 359 376 } 360 377 ··· 393 410 const tweets = result.tweets || []; 394 411 if (tweets.length === 0) return; 395 412 396 - await processTweets(tweets, 0); // No extra delay for live checks 413 + await processTweets(tweets, 1000); // 1s delay for live checks 397 414 398 415 } catch (err) { 399 416 console.error("Error in checkAndPost:", err); ··· 456 473 457 474 if (allFoundTweets.length > 0) { 458 475 console.log("Starting processing (Oldest -> Newest) with pacing..."); 459 - // 5 seconds delay average for human-like backfill 460 - await processTweets(allFoundTweets, 5000); 476 + // 1 seconds delay average for human-like backfill 477 + await processTweets(allFoundTweets, 1000); 461 478 console.log("History import complete."); 462 479 } else { 463 480 console.log("Nothing new to import.");
+61 -3
package-lock.json
··· 1 1 { 2 2 "name": "tweets-2-bsky", 3 - "version": "1.0.0", 3 + "version": "1.0.1", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tweets-2-bsky", 9 - "version": "1.0.0", 10 - "license": "ISC", 9 + "version": "1.0.1", 10 + "license": "MIT", 11 11 "dependencies": { 12 12 "@atproto/api": "^0.18.9", 13 13 "@steipete/bird": "^0.4.0", 14 14 "axios": "^1.13.2", 15 15 "dotenv": "^17.2.3", 16 + "franc-min": "^6.2.0", 17 + "iso-639-1": "^3.1.5", 16 18 "node-cron": "^4.2.1" 17 19 } 18 20 }, ··· 147 149 "node": ">= 0.4" 148 150 } 149 151 }, 152 + "node_modules/collapse-white-space": { 153 + "version": "2.1.0", 154 + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", 155 + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", 156 + "license": "MIT", 157 + "funding": { 158 + "type": "github", 159 + "url": "https://github.com/sponsors/wooorm" 160 + } 161 + }, 150 162 "node_modules/combined-stream": { 151 163 "version": "1.0.8", 152 164 "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", ··· 284 296 "node": ">= 6" 285 297 } 286 298 }, 299 + "node_modules/franc-min": { 300 + "version": "6.2.0", 301 + "resolved": "https://registry.npmjs.org/franc-min/-/franc-min-6.2.0.tgz", 302 + "integrity": "sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==", 303 + "license": "MIT", 304 + "dependencies": { 305 + "trigram-utils": "^2.0.0" 306 + }, 307 + "funding": { 308 + "type": "github", 309 + "url": "https://github.com/sponsors/wooorm" 310 + } 311 + }, 287 312 "node_modules/function-bind": { 288 313 "version": "1.1.2", 289 314 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", ··· 381 406 "node": ">= 0.4" 382 407 } 383 408 }, 409 + "node_modules/iso-639-1": { 410 + "version": "3.1.5", 411 + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", 412 + "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==", 413 + "license": "MIT", 414 + "engines": { 415 + "node": ">=6.0" 416 + } 417 + }, 384 418 "node_modules/iso-datestring-validator": { 385 419 "version": "2.2.2", 386 420 "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", ··· 444 478 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 445 479 "license": "(Apache-2.0 AND MIT)" 446 480 }, 481 + "node_modules/n-gram": { 482 + "version": "2.0.2", 483 + "resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz", 484 + "integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==", 485 + "license": "MIT", 486 + "funding": { 487 + "type": "github", 488 + "url": "https://github.com/sponsors/wooorm" 489 + } 490 + }, 447 491 "node_modules/node-cron": { 448 492 "version": "4.2.1", 449 493 "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", ··· 466 510 "license": "MIT", 467 511 "bin": { 468 512 "tlds": "bin.js" 513 + } 514 + }, 515 + "node_modules/trigram-utils": { 516 + "version": "2.0.1", 517 + "resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz", 518 + "integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==", 519 + "license": "MIT", 520 + "dependencies": { 521 + "collapse-white-space": "^2.0.0", 522 + "n-gram": "^2.0.0" 523 + }, 524 + "funding": { 525 + "type": "github", 526 + "url": "https://github.com/sponsors/wooorm" 469 527 } 470 528 }, 471 529 "node_modules/tslib": {
+3 -1
package.json
··· 1 1 { 2 2 "name": "tweets-2-bsky", 3 - "version": "1.0.1", 3 + "version": "1.0.2", 4 4 "description": "A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images.", 5 5 "main": "index.js", 6 6 "scripts": { ··· 22 22 "@steipete/bird": "^0.4.0", 23 23 "axios": "^1.13.2", 24 24 "dotenv": "^17.2.3", 25 + "franc-min": "^6.2.0", 26 + "iso-639-1": "^3.1.2", 25 27 "node-cron": "^4.2.1" 26 28 } 27 29 }