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.1: Fix threading bugs, improve media handling, update docs

jack 233d140d ed02266c

+73 -47
+51 -37
README.md
··· 2 2 3 3 A powerful, set-and-forget tool to mirror your Twitter/X account to Bluesky. 4 4 5 - ## Features 5 + ## Why this tool? 6 + 7 + Most crossposters are either paid services or lack key features. This tool is designed for power users who want a perfect mirror: 6 8 7 9 * **Smart Media Handling:** 8 - * Uploads **High-Res Images** with correct aspect ratios. 9 - * Uploads **Videos** (up to 100MB) directly to Bluesky. 10 - * Links back to the original tweet for media that cannot be uploaded. 11 - * Removes `t.co` links from text for a clean look. 12 - * **Threading & Replies:** 13 - * Automatically threads your posts on Bluesky if you reply to yourself on Twitter. 14 - * Filters out replies to others (keeps your feed clean). 15 - * Supports **Quote Tweets** (embeds the quoted post if it was also crossposted). 10 + * **Videos:** Downloads videos from Twitter and uploads them natively to Bluesky (up to 100MB). 11 + * **Images:** Uploads high-resolution images with the **correct aspect ratio** (no weird cropping). 12 + * **Links:** Automatically removes `t.co` tracking links and expands them to their real destinations. 13 + * **Threads & Replies:** 14 + * **Perfect Threading:** If you write a thread (reply to yourself) on Twitter, it appears as a threaded conversation on Bluesky. 15 + * **Clean Feed:** Automatically filters out your replies to *other* people, keeping your Bluesky timeline focused on your original content. 16 + * **Quotes:** Smartly handles Quote Tweets, embedding the quoted post if available. 16 17 * **History Import:** 17 - * `--import-history` command to backfill your timeline. 18 - * Posts in chronological order (Oldest → Newest). 19 - * Preserves original timestamps. 20 - * Human-like pacing to avoid rate limits/bans. 18 + * Backfill your entire tweet history chronologically (Oldest → Newest). 19 + * Preserves original timestamps on posts. 20 + * Uses human-like pacing to avoid rate limits. 21 21 * **Safety:** 22 - * Configurable "Target User" so you can use a "burner" account's cookies to scrape your main account (avoids risk to your main account). 22 + * Designed to use **Alt Account Cookies**. You can use a burner account to fetch tweets, protecting your main account from suspension risks. 23 23 24 24 ## Setup 25 25 ··· 33 33 34 34 ### 2. Configuration (`.env`) 35 35 36 - Copy the `.env` file and fill in your details: 36 + Create a `.env` file in the project folder: 37 37 38 38 ```bash 39 - # Twitter Cookies (See below) 40 - TWITTER_AUTH_TOKEN=... 41 - TWITTER_CT0=... 39 + # --- Twitter Configuration --- 40 + # 1. Log in to x.com (RECOMMENDED: Use a separate "burner" account!) 41 + # 2. Open Developer Tools (F12) -> Application -> Cookies 42 + # 3. Copy the values for 'auth_token' and 'ct0' 43 + TWITTER_AUTH_TOKEN=d03... 44 + TWITTER_CT0=e1a... 42 45 43 - # OPTIONAL: Use a separate account's cookies to fetch tweets 44 - # This is SAFER. Log in with an alt account, get cookies, 45 - # and set the username of the account you want to copy here. 46 + # The username of the account you want to MIRROR (e.g., your main account) 47 + # If left empty, it tries to mirror the account you logged in with (NOT RECOMMENDED). 46 48 TWITTER_TARGET_USERNAME=jack 47 49 48 - # Bluesky Credentials 49 - BLUESKY_IDENTIFIER=user.bsky.social 50 - BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx # App Password 50 + # --- Bluesky Configuration --- 51 + BLUESKY_IDENTIFIER=jack.bsky.social 52 + # Generate an App Password in Bluesky Settings -> Privacy & Security 53 + BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx 54 + 55 + # --- Optional --- 56 + CHECK_INTERVAL_MINUTES=5 57 + # BLUESKY_SERVICE_URL=https://bsky.social (Change for custom PDS) 51 58 ``` 52 59 53 - **How to get Twitter Cookies:** 54 - 1. Log in to [x.com](https://x.com) (preferably with an alt account). 55 - 2. Press `F12` -> **Application** (tab) -> **Cookies** (sidebar). 56 - 3. Copy the values for `auth_token` and `ct0`. 60 + **⚠️ Safety Tip:** 61 + Twitter is strict about scraping. **Do not use your main account's cookies.** 62 + 1. Create a fresh Twitter account (or use an old alt). 63 + 2. Log in with that alt account in your browser. 64 + 3. Grab the cookies (`auth_token`, `ct0`) from that alt account. 65 + 4. Set `TWITTER_TARGET_USERNAME` to your **main** account's handle (e.g., `elonmusk`). 66 + The tool will use the alt account to "view" your main account's profile and copy the tweets. 57 67 58 68 ### 3. Usage 59 69 60 - **Run 24/7 (Daemon Mode):** 61 - Checks every 5 minutes. 70 + **Start the Crossposter (Run 24/7):** 71 + Checks for new tweets every 5 minutes. 62 72 ```bash 63 73 node index.js 64 74 ``` 65 - *Tip: Use `pm2` to keep it running on a server.* 66 75 67 76 **Import History:** 77 + Migrate your old tweets. This runs once and stops. 68 78 ```bash 69 79 node index.js --import-history 70 80 ``` 71 81 72 - ## Running on a VPS (Ubuntu/Linux) 82 + ## Running on a Server (VPS) 73 83 74 - To run this continuously: 84 + To keep this running 24/7 on a Linux server (e.g., Ubuntu): 75 85 76 - 1. **Install PM2:** 86 + 1. **Install PM2 (Process Manager):** 77 87 ```bash 78 88 sudo npm install -g pm2 79 89 ``` 80 - 2. **Start the script:** 90 + 2. **Start the tool:** 81 91 ```bash 82 92 pm2 start index.js --name "twitter-mirror" 83 93 ``` 84 - 3. **Save startup config:** 94 + 3. **Check logs:** 95 + ```bash 96 + pm2 logs twitter-mirror 97 + ``` 98 + 4. **Enable startup on reboot:** 85 99 ```bash 86 100 pm2 startup 87 101 pm2 save 88 - ``` 102 + ```
+9 -5
index.js
··· 65 65 mapped.extended_entities = result.legacy.extended_entities; 66 66 mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; 67 67 mapped.is_quote_status = result.legacy.is_quote_status; 68 + mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; 69 + mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; 68 70 } 69 71 return mapped; 70 72 } ··· 148 150 // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets). 149 151 // User asked: "if i do it on twitter... it should continue out a thread". 150 152 151 - const isReply = tweet.in_reply_to_status_id || tweet.in_reply_to_user_id || (tweet.full_text || tweet.text || "").trim().startsWith('@'); 153 + const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; 154 + const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; 155 + const isReply = !!replyStatusId || !!replyUserId || (tweet.full_text || tweet.text || "").trim().startsWith('@'); 156 + 152 157 let replyParentInfo = null; 153 158 154 159 if (isReply) { 155 - const parentId = tweet.in_reply_to_status_id; 156 - if (parentId && processedTweets[parentId] && !processedTweets[parentId].migrated) { 160 + if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId].migrated) { 157 161 // We have the parent! We can thread this. 158 - console.log(`Threading reply to ${parentId}`); 159 - replyParentInfo = processedTweets[parentId]; 162 + console.log(`Threading reply to ${replyStatusId}`); 163 + replyParentInfo = processedTweets[replyStatusId]; 160 164 } else { 161 165 // Reply to unknown or external -> Skip 162 166 console.log(`Skipping reply: ${tweetId}`);
+13 -5
package.json
··· 1 1 { 2 2 "name": "tweets-2-bsky", 3 - "version": "1.0.0", 3 + "version": "1.0.1", 4 + "description": "A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images.", 4 5 "main": "index.js", 5 6 "scripts": { 6 - "test": "echo \"Error: no test specified\" && exit 1" 7 + "start": "node index.js", 8 + "import": "node index.js --import-history" 7 9 }, 8 - "keywords": [], 10 + "keywords": [ 11 + "bluesky", 12 + "twitter", 13 + "crosspost", 14 + "migration", 15 + "thread", 16 + "video" 17 + ], 9 18 "author": "", 10 - "license": "ISC", 11 - "description": "", 19 + "license": "MIT", 12 20 "dependencies": { 13 21 "@atproto/api": "^0.18.9", 14 22 "@steipete/bird": "^0.4.0",