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.
···2233A powerful, set-and-forget tool to mirror your Twitter/X account to Bluesky.
4455-## Features
55+## Why this tool?
66+77+Most crossposters are either paid services or lack key features. This tool is designed for power users who want a perfect mirror:
6879* **Smart Media Handling:**
88- * Uploads **High-Res Images** with correct aspect ratios.
99- * Uploads **Videos** (up to 100MB) directly to Bluesky.
1010- * Links back to the original tweet for media that cannot be uploaded.
1111- * Removes `t.co` links from text for a clean look.
1212-* **Threading & Replies:**
1313- * Automatically threads your posts on Bluesky if you reply to yourself on Twitter.
1414- * Filters out replies to others (keeps your feed clean).
1515- * Supports **Quote Tweets** (embeds the quoted post if it was also crossposted).
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+* **Threads & Replies:**
1414+ * **Perfect Threading:** If you write a thread (reply to yourself) on Twitter, it appears as a threaded conversation on Bluesky.
1515+ * **Clean Feed:** Automatically filters out your replies to *other* people, keeping your Bluesky timeline focused on your original content.
1616+ * **Quotes:** Smartly handles Quote Tweets, embedding the quoted post if available.
1617* **History Import:**
1717- * `--import-history` command to backfill your timeline.
1818- * Posts in chronological order (Oldest → Newest).
1919- * Preserves original timestamps.
2020- * Human-like pacing to avoid rate limits/bans.
1818+ * Backfill your entire tweet history chronologically (Oldest → Newest).
1919+ * Preserves original timestamps on posts.
2020+ * Uses human-like pacing to avoid rate limits.
2121* **Safety:**
2222- * Configurable "Target User" so you can use a "burner" account's cookies to scrape your main account (avoids risk to your main account).
2222+ * Designed to use **Alt Account Cookies**. You can use a burner account to fetch tweets, protecting your main account from suspension risks.
23232424## Setup
2525···33333434### 2. Configuration (`.env`)
35353636-Copy the `.env` file and fill in your details:
3636+Create a `.env` file in the project folder:
37373838```bash
3939-# Twitter Cookies (See below)
4040-TWITTER_AUTH_TOKEN=...
4141-TWITTER_CT0=...
3939+# --- Twitter Configuration ---
4040+# 1. Log in to x.com (RECOMMENDED: Use a separate "burner" account!)
4141+# 2. Open Developer Tools (F12) -> Application -> Cookies
4242+# 3. Copy the values for 'auth_token' and 'ct0'
4343+TWITTER_AUTH_TOKEN=d03...
4444+TWITTER_CT0=e1a...
42454343-# OPTIONAL: Use a separate account's cookies to fetch tweets
4444-# This is SAFER. Log in with an alt account, get cookies,
4545-# and set the username of the account you want to copy here.
4646+# The username of the account you want to MIRROR (e.g., your main account)
4747+# If left empty, it tries to mirror the account you logged in with (NOT RECOMMENDED).
4648TWITTER_TARGET_USERNAME=jack
47494848-# Bluesky Credentials
4949-BLUESKY_IDENTIFIER=user.bsky.social
5050-BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx # App Password
5050+# --- Bluesky Configuration ---
5151+BLUESKY_IDENTIFIER=jack.bsky.social
5252+# Generate an App Password in Bluesky Settings -> Privacy & Security
5353+BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
5454+5555+# --- Optional ---
5656+CHECK_INTERVAL_MINUTES=5
5757+# BLUESKY_SERVICE_URL=https://bsky.social (Change for custom PDS)
5158```
52595353-**How to get Twitter Cookies:**
5454-1. Log in to [x.com](https://x.com) (preferably with an alt account).
5555-2. Press `F12` -> **Application** (tab) -> **Cookies** (sidebar).
5656-3. Copy the values for `auth_token` and `ct0`.
6060+**⚠️ Safety Tip:**
6161+Twitter is strict about scraping. **Do not use your main account's cookies.**
6262+1. Create a fresh Twitter account (or use an old alt).
6363+2. Log in with that alt account in your browser.
6464+3. Grab the cookies (`auth_token`, `ct0`) from that alt account.
6565+4. Set `TWITTER_TARGET_USERNAME` to your **main** account's handle (e.g., `elonmusk`).
6666+The tool will use the alt account to "view" your main account's profile and copy the tweets.
57675868### 3. Usage
59696060-**Run 24/7 (Daemon Mode):**
6161-Checks every 5 minutes.
7070+**Start the Crossposter (Run 24/7):**
7171+Checks for new tweets every 5 minutes.
6272```bash
6373node index.js
6474```
6565-*Tip: Use `pm2` to keep it running on a server.*
66756776**Import History:**
7777+Migrate your old tweets. This runs once and stops.
6878```bash
6979node index.js --import-history
7080```
71817272-## Running on a VPS (Ubuntu/Linux)
8282+## Running on a Server (VPS)
73837474-To run this continuously:
8484+To keep this running 24/7 on a Linux server (e.g., Ubuntu):
75857676-1. **Install PM2:**
8686+1. **Install PM2 (Process Manager):**
7787 ```bash
7888 sudo npm install -g pm2
7989 ```
8080-2. **Start the script:**
9090+2. **Start the tool:**
8191 ```bash
8292 pm2 start index.js --name "twitter-mirror"
8393 ```
8484-3. **Save startup config:**
9494+3. **Check logs:**
9595+ ```bash
9696+ pm2 logs twitter-mirror
9797+ ```
9898+4. **Enable startup on reboot:**
8599 ```bash
86100 pm2 startup
87101 pm2 save
8888- ```102102+ ```
+9-5
index.js
···6565 mapped.extended_entities = result.legacy.extended_entities;
6666 mapped.quoted_status_id_str = result.legacy.quoted_status_id_str;
6767 mapped.is_quote_status = result.legacy.is_quote_status;
6868+ mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str;
6969+ mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str;
6870 }
6971 return mapped;
7072 }
···148150 // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets).
149151 // User asked: "if i do it on twitter... it should continue out a thread".
150152151151- const isReply = tweet.in_reply_to_status_id || tweet.in_reply_to_user_id || (tweet.full_text || tweet.text || "").trim().startsWith('@');
153153+ const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id;
154154+ const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id;
155155+ const isReply = !!replyStatusId || !!replyUserId || (tweet.full_text || tweet.text || "").trim().startsWith('@');
156156+152157 let replyParentInfo = null;
153158154159 if (isReply) {
155155- const parentId = tweet.in_reply_to_status_id;
156156- if (parentId && processedTweets[parentId] && !processedTweets[parentId].migrated) {
160160+ if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId].migrated) {
157161 // We have the parent! We can thread this.
158158- console.log(`Threading reply to ${parentId}`);
159159- replyParentInfo = processedTweets[parentId];
162162+ console.log(`Threading reply to ${replyStatusId}`);
163163+ replyParentInfo = processedTweets[replyStatusId];
160164 } else {
161165 // Reply to unknown or external -> Skip
162166 console.log(`Skipping reply: ${tweetId}`);