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.
···23A powerful, set-and-forget tool to mirror your Twitter/X account to Bluesky.
45-## Features
0067* **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).
16* **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.
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).
2324## Setup
25···3334### 2. Configuration (`.env`)
3536-Copy the `.env` file and fill in your details:
3738```bash
39-# Twitter Cookies (See below)
40-TWITTER_AUTH_TOKEN=...
41-TWITTER_CT0=...
0004243-# 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.
46TWITTER_TARGET_USERNAME=jack
4748-# Bluesky Credentials
49-BLUESKY_IDENTIFIER=user.bsky.social
50-BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx # App Password
0000051```
5253-**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`.
0005758### 3. Usage
5960-**Run 24/7 (Daemon Mode):**
61-Checks every 5 minutes.
62```bash
63node index.js
64```
65-*Tip: Use `pm2` to keep it running on a server.*
6667**Import History:**
068```bash
69node index.js --import-history
70```
7172-## Running on a VPS (Ubuntu/Linux)
7374-To run this continuously:
7576-1. **Install PM2:**
77 ```bash
78 sudo npm install -g pm2
79 ```
80-2. **Start the script:**
81 ```bash
82 pm2 start index.js --name "twitter-mirror"
83 ```
84-3. **Save startup config:**
000085 ```bash
86 pm2 startup
87 pm2 save
88- ```
···23A powerful, set-and-forget tool to mirror your Twitter/X account to Bluesky.
45+## 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:
89* **Smart Media Handling:**
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.
017* **History Import:**
18+ * Backfill your entire tweet history chronologically (Oldest → Newest).
19+ * Preserves original timestamps on posts.
20+ * Uses human-like pacing to avoid rate limits.
021* **Safety:**
22+ * Designed to use **Alt Account Cookies**. You can use a burner account to fetch tweets, protecting your main account from suspension risks.
2324## Setup
25···3334### 2. Configuration (`.env`)
3536+Create a `.env` file in the project folder:
3738```bash
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...
4546+# 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).
048TWITTER_TARGET_USERNAME=jack
4950+# --- 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)
58```
5960+**⚠️ 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.
6768### 3. Usage
6970+**Start the Crossposter (Run 24/7):**
71+Checks for new tweets every 5 minutes.
72```bash
73node index.js
74```
07576**Import History:**
77+Migrate your old tweets. This runs once and stops.
78```bash
79node index.js --import-history
80```
8182+## Running on a Server (VPS)
8384+To keep this running 24/7 on a Linux server (e.g., Ubuntu):
8586+1. **Install PM2 (Process Manager):**
87 ```bash
88 sudo npm install -g pm2
89 ```
90+2. **Start the tool:**
91 ```bash
92 pm2 start index.js --name "twitter-mirror"
93 ```
94+3. **Check logs:**
95+ ```bash
96+ pm2 logs twitter-mirror
97+ ```
98+4. **Enable startup on reboot:**
99 ```bash
100 pm2 startup
101 pm2 save
102+ ```
+9-5
index.js
···65 mapped.extended_entities = result.legacy.extended_entities;
66 mapped.quoted_status_id_str = result.legacy.quoted_status_id_str;
67 mapped.is_quote_status = result.legacy.is_quote_status;
0068 }
69 return mapped;
70 }
···148 // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets).
149 // User asked: "if i do it on twitter... it should continue out a thread".
150151- const isReply = tweet.in_reply_to_status_id || tweet.in_reply_to_user_id || (tweet.full_text || tweet.text || "").trim().startsWith('@');
000152 let replyParentInfo = null;
153154 if (isReply) {
155- const parentId = tweet.in_reply_to_status_id;
156- if (parentId && processedTweets[parentId] && !processedTweets[parentId].migrated) {
157 // We have the parent! We can thread this.
158- console.log(`Threading reply to ${parentId}`);
159- replyParentInfo = processedTweets[parentId];
160 } else {
161 // Reply to unknown or external -> Skip
162 console.log(`Skipping reply: ${tweetId}`);
···65 mapped.extended_entities = result.legacy.extended_entities;
66 mapped.quoted_status_id_str = result.legacy.quoted_status_id_str;
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;
70 }
71 return mapped;
72 }
···150 // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets).
151 // User asked: "if i do it on twitter... it should continue out a thread".
152153+ 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+157 let replyParentInfo = null;
158159 if (isReply) {
160+ if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId].migrated) {
0161 // We have the parent! We can thread this.
162+ console.log(`Threading reply to ${replyStatusId}`);
163+ replyParentInfo = processedTweets[replyStatusId];
164 } else {
165 // Reply to unknown or external -> Skip
166 console.log(`Skipping reply: ${tweetId}`);