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.

refactor: convert to TypeScript with Biome linting

- Convert index.js to src/index.ts with full type definitions
- Add tsconfig.json with strict TypeScript config
- Add biome.json for linting and formatting
- Update package.json with ESM, build scripts, and devDependencies
- Add .env.example template for easy setup
- Add src/types.d.ts for untyped npm packages
- Update README with TypeScript commands and workflow

BREAKING: Now requires 'npm run build' before production use

jack f25dc4bf 4afac6a3

+1609 -540
+19
.env.example
··· 1 + # --- Twitter Configuration --- 2 + # 1. Log in to x.com (RECOMMENDED: Use a separate "burner" account!) 3 + # 2. Open Developer Tools (F12) -> Application -> Cookies 4 + # 3. Copy the values for 'auth_token' and 'ct0' 5 + TWITTER_AUTH_TOKEN= 6 + TWITTER_CT0= 7 + 8 + # The username of the account you want to MIRROR (e.g., your main account) 9 + # If left empty, it tries to mirror the account you logged in with. 10 + TWITTER_TARGET_USERNAME= 11 + 12 + # --- Bluesky Configuration --- 13 + BLUESKY_IDENTIFIER= 14 + # Generate an App Password in Bluesky Settings -> Privacy & Security 15 + BLUESKY_PASSWORD= 16 + 17 + # --- Optional --- 18 + CHECK_INTERVAL_MINUTES=5 19 + # BLUESKY_SERVICE_URL=https://bsky.social
+1
.gitignore
··· 1 1 node_modules/ 2 + dist/ 2 3 .env 3 4 processed_tweets.json 4 5 npm-debug.log
+35 -10
README.md
··· 32 32 ### 1. Installation 33 33 34 34 ```bash 35 - git clone https://github.com/yourusername/tweets-2-bsky.git 35 + git clone https://github.com/j4ckxyz/tweets-2-bsky.git 36 36 cd tweets-2-bsky 37 37 npm install 38 38 ``` ··· 73 73 74 74 ### 3. Usage 75 75 76 - **Start the Crossposter (Run 24/7):** 77 - Checks for new tweets every 5 minutes. 76 + **Development (with hot reload):** 77 + ```bash 78 + npm run dev 79 + ``` 80 + 81 + **Production:** 78 82 ```bash 79 - node index.js 83 + npm run build 84 + npm start 80 85 ``` 81 86 82 87 **Import History:** 83 88 Migrate your old tweets. This runs once and stops. 84 89 ```bash 85 - node index.js --import-history 90 + npm run import 86 91 ``` 87 92 93 + ### 4. Other Commands 94 + 95 + | Command | Description | 96 + |---------|-------------| 97 + | `npm run build` | Compile TypeScript to `dist/` | 98 + | `npm run dev` | Run directly with tsx (no build needed) | 99 + | `npm run lint` | Run Biome linter with auto-fix | 100 + | `npm run format` | Format code with Biome | 101 + | `npm run typecheck` | Type-check without emitting | 102 + 88 103 ## Running on a Server (VPS) 89 104 90 105 To keep this running 24/7 on a Linux server (e.g., Ubuntu): 91 106 92 - 1. **Install PM2 (Process Manager):** 107 + 1. **Build the project:** 108 + ```bash 109 + npm run build 110 + ``` 111 + 2. **Install PM2 (Process Manager):** 93 112 ```bash 94 113 sudo npm install -g pm2 95 114 ``` 96 - 2. **Start the tool:** 115 + 3. **Start the tool:** 97 116 ```bash 98 - pm2 start index.js --name "twitter-mirror" 117 + pm2 start dist/index.js --name "twitter-mirror" 99 118 ``` 100 - 3. **Check logs:** 119 + 4. **Check logs:** 101 120 ```bash 102 121 pm2 logs twitter-mirror 103 122 ``` 104 - 4. **Enable startup on reboot:** 123 + 5. **Enable startup on reboot:** 105 124 ```bash 106 125 pm2 startup 107 126 pm2 save 108 127 ``` 128 + 129 + ## Tech Stack 130 + 131 + - **TypeScript** – Full type safety 132 + - **Biome** – Fast linting & formatting 133 + - **tsx** – TypeScript execution for development
+38
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 + "organizeImports": { 4 + "enabled": true 5 + }, 6 + "linter": { 7 + "enabled": true, 8 + "rules": { 9 + "recommended": true, 10 + "correctness": { 11 + "noUnusedVariables": "warn", 12 + "noUnusedImports": "warn" 13 + }, 14 + "suspicious": { 15 + "noExplicitAny": "warn" 16 + }, 17 + "style": { 18 + "useConst": "error", 19 + "noNonNullAssertion": "warn" 20 + } 21 + } 22 + }, 23 + "formatter": { 24 + "enabled": true, 25 + "indentStyle": "space", 26 + "indentWidth": 2, 27 + "lineWidth": 120 28 + }, 29 + "javascript": { 30 + "formatter": { 31 + "quoteStyle": "single", 32 + "semicolons": "always" 33 + } 34 + }, 35 + "files": { 36 + "ignore": ["node_modules", "dist", "*.json"] 37 + } 38 + }
-522
index.js
··· 1 - require('dotenv').config(); 2 - const { BskyAgent, RichText } = require('@atproto/api'); 3 - const fs = require('fs'); 4 - const path = require('path'); 5 - const axios = require('axios'); 6 - const cron = require('node-cron'); 7 - const { TwitterClient } = require('@steipete/bird/dist/lib/twitter-client'); 8 - const franc = require('franc-min'); 9 - const iso6391 = require('iso-639-1'); 10 - const { exec } = require('child_process'); 11 - 12 - // Configuration 13 - const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; 14 - const TWITTER_CT0 = process.env.TWITTER_CT0; 15 - const TWITTER_TARGET_USERNAME = process.env.TWITTER_TARGET_USERNAME; // Optional target 16 - const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER; 17 - const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD; 18 - const BLUESKY_SERVICE_URL = process.env.BLUESKY_SERVICE_URL || 'https://bsky.social'; 19 - const CHECK_INTERVAL_MINUTES = process.env.CHECK_INTERVAL_MINUTES || 5; 20 - const PROCESSED_TWEETS_FILE = path.join(__dirname, 'processed_tweets.json'); 21 - 22 - // State Management 23 - // Format: { "twitter_id": { uri: "bsky_uri", cid: "bsky_cid", root: { uri, cid } } } 24 - let processedTweets = {}; 25 - 26 - function loadProcessedTweets() { 27 - try { 28 - if (fs.existsSync(PROCESSED_TWEETS_FILE)) { 29 - const raw = JSON.parse(fs.readFileSync(PROCESSED_TWEETS_FILE, 'utf8')); 30 - if (Array.isArray(raw)) { 31 - // Migration from v1 (Array of IDs) to v2 (Object map) 32 - console.log("Migrating processed_tweets.json from v1 to v2..."); 33 - processedTweets = raw.reduce((acc, id) => { 34 - acc[id] = { migrated: true }; // Marker for old tweets (can't reply to them easily) 35 - return acc; 36 - }, {}); 37 - saveProcessedTweets(); 38 - } else { 39 - processedTweets = raw; 40 - } 41 - } 42 - } catch (err) { 43 - console.error('Error loading processed tweets:', err); 44 - } 45 - } 46 - 47 - function saveProcessedTweets() { 48 - try { 49 - fs.writeFileSync(PROCESSED_TWEETS_FILE, JSON.stringify(processedTweets, null, 2)); 50 - } catch (err) { 51 - console.error('Error saving processed tweets:', err); 52 - } 53 - } 54 - 55 - loadProcessedTweets(); 56 - 57 - // Bluesky Agent 58 - const agent = new BskyAgent({ 59 - service: BLUESKY_SERVICE_URL, 60 - }); 61 - 62 - // Custom Twitter Client 63 - class CustomTwitterClient extends TwitterClient { 64 - mapTweetResult(result) { 65 - const mapped = super.mapTweetResult(result); 66 - if (mapped && result.legacy) { 67 - mapped.entities = result.legacy.entities; 68 - mapped.extended_entities = result.legacy.extended_entities; 69 - mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; 70 - mapped.is_quote_status = result.legacy.is_quote_status; 71 - mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; 72 - mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; 73 - } 74 - return mapped; 75 - } 76 - } 77 - 78 - const twitter = new CustomTwitterClient({ 79 - cookies: { 80 - authToken: TWITTER_AUTH_TOKEN, 81 - ct0: TWITTER_CT0 82 - } 83 - }); 84 - 85 - // --- Helper Functions --- 86 - 87 - function detectLanguage(text) { 88 - if (!text || text.trim().length === 0) return ['en']; 89 - try { 90 - const code3 = franc(text); 91 - if (code3 === 'und') return ['en']; // Undetermined 92 - const code2 = iso6391.getCode(code3); 93 - return code2 ? [code2] : ['en']; 94 - } catch (e) { 95 - return ['en']; 96 - } 97 - } 98 - 99 - async function expandUrl(shortUrl) { 100 - try { 101 - const response = await axios.head(shortUrl, { 102 - maxRedirects: 10, 103 - validateStatus: (status) => status >= 200 && status < 400 104 - }); 105 - return response.request.res.responseUrl || shortUrl; 106 - } catch (err) { 107 - try { 108 - const response = await axios.get(shortUrl, { 109 - responseType: 'stream', 110 - maxRedirects: 10 111 - }); 112 - response.data.destroy(); 113 - return response.request.res.responseUrl || shortUrl; 114 - } catch (e) { 115 - // console.warn(`Failed to expand URL ${shortUrl}:`, e.message); 116 - return shortUrl; 117 - } 118 - } 119 - } 120 - 121 - async function downloadMedia(url) { 122 - const response = await axios({ 123 - url, 124 - method: 'GET', 125 - responseType: 'arraybuffer' 126 - }); 127 - return { 128 - buffer: Buffer.from(response.data), 129 - mimeType: response.headers['content-type'] 130 - }; 131 - } 132 - 133 - async function uploadToBluesky(buffer, mimeType) { 134 - const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); 135 - return data.blob; 136 - } 137 - 138 - async function getUsername() { 139 - if (TWITTER_TARGET_USERNAME) return TWITTER_TARGET_USERNAME; 140 - try { 141 - const res = await twitter.getCurrentUser(); 142 - if (res.success && res.user) { 143 - return res.user.username; 144 - } 145 - } catch (e) { 146 - console.warn("Failed to get 'whoami'. defaulting to 'me'.", e.message); 147 - } 148 - return "me"; 149 - } 150 - 151 - function getRandomDelay(min = 1000, max = 4000) { 152 - return Math.floor(Math.random() * (max - min + 1) + min); 153 - } 154 - 155 - function refreshQueryIds() { 156 - return new Promise((resolve) => { 157 - console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); 158 - exec('./node_modules/.bin/bird query-ids --fresh', (error, stdout, stderr) => { 159 - if (error) { 160 - console.error(`Error refreshing IDs: ${error.message}`); 161 - console.error(`Stderr: ${stderr}`); 162 - } else { 163 - console.log("✅ Query IDs refreshed successfully."); 164 - } 165 - resolve(); 166 - }); 167 - }); 168 - } 169 - 170 - /** 171 - * Wraps twitter.search with auto-recovery for stale Query IDs 172 - */ 173 - async function safeSearch(query, limit) { 174 - try { 175 - const result = await twitter.search(query, limit); 176 - // Sometimes it returns success: false but no throw 177 - if (!result.success && result.error && 178 - (result.error.toString().includes('GraphQL') || result.error.toString().includes('404'))) { 179 - throw new Error(result.error); 180 - } 181 - return result; 182 - } catch (err) { 183 - console.warn(`Search encountered an error: ${err.message || err}`); 184 - if (err.message && (err.message.includes('GraphQL') || err.message.includes('404') || err.message.includes('Bad Guest Token'))) { 185 - await refreshQueryIds(); 186 - console.log("Retrying search..."); 187 - return await twitter.search(query, limit); 188 - } 189 - return { success: false, error: err }; 190 - } 191 - } 192 - 193 - // --- Main Processing Logic --- 194 - 195 - async function processTweets(tweets) { 196 - // Ensure chronological order 197 - tweets.reverse(); 198 - 199 - for (const tweet of tweets) { 200 - const tweetId = tweet.id_str || tweet.id; 201 - if (processedTweets[tweetId]) { 202 - continue; 203 - } 204 - 205 - // --- Filter Replies (unless we are maintaining a thread) --- 206 - // If it's a reply, but the parent IS in our DB, we want to post it as a reply. 207 - // If it's a reply to someone else (or a thread we missed), we skip it based on user preference (only original tweets). 208 - 209 - const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; 210 - const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; 211 - const isReply = !!replyStatusId || !!replyUserId || (tweet.full_text || tweet.text || "").trim().startsWith('@'); 212 - 213 - let replyParentInfo = null; 214 - 215 - if (isReply) { 216 - if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId].migrated) { 217 - // We have the parent! We can thread this. 218 - console.log(`Threading reply to ${replyStatusId}`); 219 - replyParentInfo = processedTweets[replyStatusId]; 220 - } else { 221 - // Reply to unknown or external -> Skip 222 - console.log(`Skipping reply: ${tweetId}`); 223 - processedTweets[tweetId] = { skipped: true }; // Mark as skipped 224 - saveProcessedTweets(); 225 - continue; 226 - } 227 - } 228 - 229 - console.log(`Processing tweet: ${tweetId}`); 230 - 231 - let text = tweet.full_text || tweet.text || ""; 232 - 233 - // --- 1. Link Expansion --- 234 - const urls = tweet.entities?.urls || []; 235 - for (const urlEntity of urls) { 236 - const tco = urlEntity.url; 237 - const expanded = urlEntity.expanded_url; 238 - if (tco && expanded) { 239 - text = text.replace(tco, expanded); 240 - } 241 - } 242 - 243 - // Manual cleanup of remaining t.co 244 - const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; 245 - const matches = text.match(tcoRegex) || []; 246 - for (const tco of matches) { 247 - const resolved = await expandUrl(tco); 248 - if (resolved !== tco) { 249 - text = text.replace(tco, resolved); 250 - } 251 - } 252 - 253 - // --- 2. Media Handling --- 254 - let images = []; 255 - let videoBlob = null; 256 - let videoAspectRatio = null; 257 - 258 - const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; 259 - let mediaLinksToRemove = []; 260 - 261 - for (const media of mediaEntities) { 262 - if (media.url) { 263 - mediaLinksToRemove.push(media.url); 264 - if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); 265 - } 266 - 267 - // Aspect Ratio Extraction 268 - let aspectRatio = undefined; 269 - if (media.sizes?.large) { 270 - aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; 271 - } else if (media.original_info) { 272 - aspectRatio = { width: media.original_info.width, height: media.original_info.height }; 273 - } 274 - 275 - if (media.type === 'photo') { 276 - const url = media.media_url_https; 277 - try { 278 - const { buffer, mimeType } = await downloadMedia(url); 279 - const blob = await uploadToBluesky(buffer, mimeType); 280 - images.push({ 281 - alt: media.ext_alt_text || "Image from Twitter", 282 - image: blob, 283 - aspectRatio: aspectRatio 284 - }); 285 - } catch (err) { 286 - console.error(`Failed to upload image ${url}:`, err.message); 287 - } 288 - } else if (media.type === 'video' || media.type === 'animated_gif') { 289 - const variants = media.video_info?.variants || []; 290 - const mp4s = variants.filter(v => v.content_type === 'video/mp4').sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); 291 - 292 - if (mp4s.length > 0) { 293 - const videoUrl = mp4s[0].url; 294 - try { 295 - const { buffer, mimeType } = await downloadMedia(videoUrl); 296 - 297 - if (buffer.length > 95 * 1024 * 1024) { 298 - console.warn("Video too large (>95MB). Linking instead."); 299 - text += `\n[Video: ${media.media_url_https}]`; 300 - continue; 301 - } 302 - 303 - const blob = await uploadToBluesky(buffer, mimeType); 304 - videoBlob = blob; 305 - videoAspectRatio = aspectRatio; 306 - break; 307 - } catch (err) { 308 - console.error(`Failed to upload video ${videoUrl}:`, err.message); 309 - text += `\n${media.media_url_https}`; 310 - } 311 - } 312 - } 313 - } 314 - 315 - // Remove media links from text 316 - for (const link of mediaLinksToRemove) { 317 - text = text.split(link).join('').trim(); 318 - } 319 - text = text.replace(/\n\s*\n/g, '\n\n').trim(); 320 - 321 - // --- 3. Quoting Logic --- 322 - let quoteEmbed = null; 323 - if (tweet.is_quote_status && tweet.quoted_status_id_str) { 324 - const quoteId = tweet.quoted_status_id_str; 325 - if (processedTweets[quoteId] && !processedTweets[quoteId].migrated) { 326 - const ref = processedTweets[quoteId]; 327 - quoteEmbed = { 328 - $type: 'app.bsky.embed.record', 329 - record: { 330 - uri: ref.uri, 331 - cid: ref.cid 332 - } 333 - }; 334 - } 335 - } 336 - 337 - // --- 4. Construct Post --- 338 - const rt = new RichText({ text }); 339 - await rt.detectFacets(agent); 340 - const detectedLangs = detectLanguage(text); 341 - 342 - const postRecord = { 343 - text: rt.text, 344 - facets: rt.facets, 345 - langs: detectedLangs, 346 - createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString() 347 - }; 348 - 349 - // Attach Embeds 350 - if (videoBlob) { 351 - postRecord.embed = { 352 - $type: 'app.bsky.embed.video', 353 - video: videoBlob, 354 - aspectRatio: videoAspectRatio 355 - }; 356 - } else if (images.length > 0) { 357 - const imagesEmbed = { 358 - $type: 'app.bsky.embed.images', 359 - images: images 360 - }; 361 - 362 - if (quoteEmbed) { 363 - postRecord.embed = { 364 - $type: 'app.bsky.embed.recordWithMedia', 365 - media: imagesEmbed, 366 - record: quoteEmbed 367 - }; 368 - } else { 369 - postRecord.embed = imagesEmbed; 370 - } 371 - } else if (quoteEmbed) { 372 - postRecord.embed = quoteEmbed; 373 - } 374 - 375 - // Attach Reply info 376 - if (replyParentInfo) { 377 - postRecord.reply = { 378 - root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 379 - parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid } 380 - }; 381 - } 382 - 383 - // --- 5. Post & Save --- 384 - try { 385 - const response = await agent.post(postRecord); 386 - // console.log(`Posted: ${tweetId}`); 387 - 388 - const newEntry = { 389 - uri: response.uri, 390 - cid: response.cid, 391 - root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid } 392 - }; 393 - 394 - processedTweets[tweetId] = newEntry; 395 - saveProcessedTweets(); 396 - 397 - // Random Pacing (1s - 4s) 398 - const sleepTime = getRandomDelay(1000, 4000); 399 - // console.log(`Sleeping ${sleepTime}ms...`); 400 - await new Promise(r => setTimeout(r, sleepTime)); 401 - 402 - } catch (err) { 403 - console.error(`Failed to post ${tweetId}:`, err); 404 - } 405 - } 406 - } 407 - 408 - async function checkAndPost() { 409 - console.log(`[${new Date().toISOString()}] Checking...`); 410 - 411 - try { 412 - const username = await getUsername(); 413 - 414 - // Use safeSearch with auto-refresh for IDs 415 - const query = `from:${username}`; 416 - const result = await safeSearch(query, 30); 417 - 418 - if (!result.success) { 419 - console.error("Failed to fetch tweets:", result.error); 420 - return; 421 - } 422 - 423 - const tweets = result.tweets || []; 424 - if (tweets.length === 0) return; 425 - 426 - await processTweets(tweets); 427 - 428 - } catch (err) { 429 - console.error("Error in checkAndPost:", err); 430 - } 431 - } 432 - 433 - async function importHistory() { 434 - console.log("Starting full history import..."); 435 - const username = await getUsername(); 436 - console.log(`Importing history for: ${username}`); 437 - 438 - let maxId = null; 439 - let keepGoing = true; 440 - const count = 100; 441 - let allFoundTweets = []; 442 - const seenIds = new Set(); 443 - 444 - while (keepGoing) { 445 - let query = `from:${username}`; 446 - if (maxId) { 447 - query += ` max_id:${maxId}`; 448 - } 449 - 450 - console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); 451 - 452 - const result = await safeSearch(query, count); 453 - 454 - if (!result.success) { 455 - console.error("Fetch failed:", result.error); 456 - break; 457 - } 458 - 459 - const tweets = result.tweets || []; 460 - if (tweets.length === 0) break; 461 - 462 - let newOnes = 0; 463 - for (const t of tweets) { 464 - const tid = t.id_str || t.id; 465 - if (!processedTweets[tid] && !seenIds.has(tid)) { 466 - allFoundTweets.push(t); 467 - seenIds.add(tid); 468 - newOnes++; 469 - } 470 - } 471 - 472 - if (newOnes === 0 && tweets.length > 0) { 473 - const lastId = tweets[tweets.length - 1].id_str || tweets[tweets.length - 1].id; 474 - if (lastId === maxId) break; 475 - } 476 - 477 - const lastTweet = tweets[tweets.length - 1]; 478 - maxId = lastTweet.id_str || lastTweet.id; 479 - 480 - // Rate limit protection 481 - await new Promise(r => setTimeout(r, 2000)); 482 - } 483 - 484 - console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); 485 - 486 - if (allFoundTweets.length > 0) { 487 - console.log("Starting processing (Oldest -> Newest) with random pacing..."); 488 - await processTweets(allFoundTweets); 489 - console.log("History import complete."); 490 - } else { 491 - console.log("Nothing new to import."); 492 - } 493 - } 494 - 495 - // Start 496 - (async () => { 497 - if (!TWITTER_AUTH_TOKEN || !TWITTER_CT0 || !BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { 498 - console.error("Missing credentials in .env file."); 499 - process.exit(1); 500 - } 501 - 502 - try { 503 - await agent.login({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }); 504 - console.log("Logged in to Bluesky."); 505 - } catch (err) { 506 - console.error("Failed to login to Bluesky:", err); 507 - process.exit(1); 508 - } 509 - 510 - if (process.argv.includes('--import-history')) { 511 - await importHistory(); 512 - process.exit(0); 513 - } 514 - 515 - // Refresh IDs on startup just to be safe/fresh 516 - // await refreshQueryIds(); 517 - 518 - await checkAndPost(); 519 - 520 - console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`); 521 - cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, checkAndPost); 522 - })();
+746 -3
package-lock.json
··· 1 1 { 2 2 "name": "tweets-2-bsky", 3 - "version": "1.0.1", 3 + "version": "2.0.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tweets-2-bsky", 9 - "version": "1.0.1", 9 + "version": "2.0.0", 10 10 "license": "MIT", 11 11 "dependencies": { 12 12 "@atproto/api": "^0.18.9", ··· 14 14 "axios": "^1.13.2", 15 15 "dotenv": "^17.2.3", 16 16 "franc-min": "^6.2.0", 17 - "iso-639-1": "^3.1.5", 17 + "iso-639-1": "^3.1.2", 18 18 "node-cron": "^4.2.1" 19 + }, 20 + "devDependencies": { 21 + "@biomejs/biome": "^1.9.4", 22 + "@types/node": "^22.10.2", 23 + "tsx": "^4.19.2", 24 + "typescript": "^5.7.2" 19 25 } 20 26 }, 21 27 "node_modules/@atproto/api": { ··· 97 103 "zod": "^3.23.8" 98 104 } 99 105 }, 106 + "node_modules/@biomejs/biome": { 107 + "version": "1.9.4", 108 + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", 109 + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", 110 + "dev": true, 111 + "hasInstallScript": true, 112 + "license": "MIT OR Apache-2.0", 113 + "bin": { 114 + "biome": "bin/biome" 115 + }, 116 + "engines": { 117 + "node": ">=14.21.3" 118 + }, 119 + "funding": { 120 + "type": "opencollective", 121 + "url": "https://opencollective.com/biome" 122 + }, 123 + "optionalDependencies": { 124 + "@biomejs/cli-darwin-arm64": "1.9.4", 125 + "@biomejs/cli-darwin-x64": "1.9.4", 126 + "@biomejs/cli-linux-arm64": "1.9.4", 127 + "@biomejs/cli-linux-arm64-musl": "1.9.4", 128 + "@biomejs/cli-linux-x64": "1.9.4", 129 + "@biomejs/cli-linux-x64-musl": "1.9.4", 130 + "@biomejs/cli-win32-arm64": "1.9.4", 131 + "@biomejs/cli-win32-x64": "1.9.4" 132 + } 133 + }, 134 + "node_modules/@biomejs/cli-darwin-arm64": { 135 + "version": "1.9.4", 136 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", 137 + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", 138 + "cpu": [ 139 + "arm64" 140 + ], 141 + "dev": true, 142 + "license": "MIT OR Apache-2.0", 143 + "optional": true, 144 + "os": [ 145 + "darwin" 146 + ], 147 + "engines": { 148 + "node": ">=14.21.3" 149 + } 150 + }, 151 + "node_modules/@biomejs/cli-darwin-x64": { 152 + "version": "1.9.4", 153 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", 154 + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", 155 + "cpu": [ 156 + "x64" 157 + ], 158 + "dev": true, 159 + "license": "MIT OR Apache-2.0", 160 + "optional": true, 161 + "os": [ 162 + "darwin" 163 + ], 164 + "engines": { 165 + "node": ">=14.21.3" 166 + } 167 + }, 168 + "node_modules/@biomejs/cli-linux-arm64": { 169 + "version": "1.9.4", 170 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", 171 + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", 172 + "cpu": [ 173 + "arm64" 174 + ], 175 + "dev": true, 176 + "license": "MIT OR Apache-2.0", 177 + "optional": true, 178 + "os": [ 179 + "linux" 180 + ], 181 + "engines": { 182 + "node": ">=14.21.3" 183 + } 184 + }, 185 + "node_modules/@biomejs/cli-linux-arm64-musl": { 186 + "version": "1.9.4", 187 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", 188 + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", 189 + "cpu": [ 190 + "arm64" 191 + ], 192 + "dev": true, 193 + "license": "MIT OR Apache-2.0", 194 + "optional": true, 195 + "os": [ 196 + "linux" 197 + ], 198 + "engines": { 199 + "node": ">=14.21.3" 200 + } 201 + }, 202 + "node_modules/@biomejs/cli-linux-x64": { 203 + "version": "1.9.4", 204 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", 205 + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", 206 + "cpu": [ 207 + "x64" 208 + ], 209 + "dev": true, 210 + "license": "MIT OR Apache-2.0", 211 + "optional": true, 212 + "os": [ 213 + "linux" 214 + ], 215 + "engines": { 216 + "node": ">=14.21.3" 217 + } 218 + }, 219 + "node_modules/@biomejs/cli-linux-x64-musl": { 220 + "version": "1.9.4", 221 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", 222 + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", 223 + "cpu": [ 224 + "x64" 225 + ], 226 + "dev": true, 227 + "license": "MIT OR Apache-2.0", 228 + "optional": true, 229 + "os": [ 230 + "linux" 231 + ], 232 + "engines": { 233 + "node": ">=14.21.3" 234 + } 235 + }, 236 + "node_modules/@biomejs/cli-win32-arm64": { 237 + "version": "1.9.4", 238 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", 239 + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", 240 + "cpu": [ 241 + "arm64" 242 + ], 243 + "dev": true, 244 + "license": "MIT OR Apache-2.0", 245 + "optional": true, 246 + "os": [ 247 + "win32" 248 + ], 249 + "engines": { 250 + "node": ">=14.21.3" 251 + } 252 + }, 253 + "node_modules/@biomejs/cli-win32-x64": { 254 + "version": "1.9.4", 255 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", 256 + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", 257 + "cpu": [ 258 + "x64" 259 + ], 260 + "dev": true, 261 + "license": "MIT OR Apache-2.0", 262 + "optional": true, 263 + "os": [ 264 + "win32" 265 + ], 266 + "engines": { 267 + "node": ">=14.21.3" 268 + } 269 + }, 270 + "node_modules/@esbuild/aix-ppc64": { 271 + "version": "0.27.2", 272 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", 273 + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", 274 + "cpu": [ 275 + "ppc64" 276 + ], 277 + "dev": true, 278 + "license": "MIT", 279 + "optional": true, 280 + "os": [ 281 + "aix" 282 + ], 283 + "engines": { 284 + "node": ">=18" 285 + } 286 + }, 287 + "node_modules/@esbuild/android-arm": { 288 + "version": "0.27.2", 289 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", 290 + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", 291 + "cpu": [ 292 + "arm" 293 + ], 294 + "dev": true, 295 + "license": "MIT", 296 + "optional": true, 297 + "os": [ 298 + "android" 299 + ], 300 + "engines": { 301 + "node": ">=18" 302 + } 303 + }, 304 + "node_modules/@esbuild/android-arm64": { 305 + "version": "0.27.2", 306 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", 307 + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", 308 + "cpu": [ 309 + "arm64" 310 + ], 311 + "dev": true, 312 + "license": "MIT", 313 + "optional": true, 314 + "os": [ 315 + "android" 316 + ], 317 + "engines": { 318 + "node": ">=18" 319 + } 320 + }, 321 + "node_modules/@esbuild/android-x64": { 322 + "version": "0.27.2", 323 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", 324 + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", 325 + "cpu": [ 326 + "x64" 327 + ], 328 + "dev": true, 329 + "license": "MIT", 330 + "optional": true, 331 + "os": [ 332 + "android" 333 + ], 334 + "engines": { 335 + "node": ">=18" 336 + } 337 + }, 338 + "node_modules/@esbuild/darwin-arm64": { 339 + "version": "0.27.2", 340 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", 341 + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", 342 + "cpu": [ 343 + "arm64" 344 + ], 345 + "dev": true, 346 + "license": "MIT", 347 + "optional": true, 348 + "os": [ 349 + "darwin" 350 + ], 351 + "engines": { 352 + "node": ">=18" 353 + } 354 + }, 355 + "node_modules/@esbuild/darwin-x64": { 356 + "version": "0.27.2", 357 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", 358 + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", 359 + "cpu": [ 360 + "x64" 361 + ], 362 + "dev": true, 363 + "license": "MIT", 364 + "optional": true, 365 + "os": [ 366 + "darwin" 367 + ], 368 + "engines": { 369 + "node": ">=18" 370 + } 371 + }, 372 + "node_modules/@esbuild/freebsd-arm64": { 373 + "version": "0.27.2", 374 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", 375 + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", 376 + "cpu": [ 377 + "arm64" 378 + ], 379 + "dev": true, 380 + "license": "MIT", 381 + "optional": true, 382 + "os": [ 383 + "freebsd" 384 + ], 385 + "engines": { 386 + "node": ">=18" 387 + } 388 + }, 389 + "node_modules/@esbuild/freebsd-x64": { 390 + "version": "0.27.2", 391 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", 392 + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", 393 + "cpu": [ 394 + "x64" 395 + ], 396 + "dev": true, 397 + "license": "MIT", 398 + "optional": true, 399 + "os": [ 400 + "freebsd" 401 + ], 402 + "engines": { 403 + "node": ">=18" 404 + } 405 + }, 406 + "node_modules/@esbuild/linux-arm": { 407 + "version": "0.27.2", 408 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", 409 + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", 410 + "cpu": [ 411 + "arm" 412 + ], 413 + "dev": true, 414 + "license": "MIT", 415 + "optional": true, 416 + "os": [ 417 + "linux" 418 + ], 419 + "engines": { 420 + "node": ">=18" 421 + } 422 + }, 423 + "node_modules/@esbuild/linux-arm64": { 424 + "version": "0.27.2", 425 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", 426 + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", 427 + "cpu": [ 428 + "arm64" 429 + ], 430 + "dev": true, 431 + "license": "MIT", 432 + "optional": true, 433 + "os": [ 434 + "linux" 435 + ], 436 + "engines": { 437 + "node": ">=18" 438 + } 439 + }, 440 + "node_modules/@esbuild/linux-ia32": { 441 + "version": "0.27.2", 442 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", 443 + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", 444 + "cpu": [ 445 + "ia32" 446 + ], 447 + "dev": true, 448 + "license": "MIT", 449 + "optional": true, 450 + "os": [ 451 + "linux" 452 + ], 453 + "engines": { 454 + "node": ">=18" 455 + } 456 + }, 457 + "node_modules/@esbuild/linux-loong64": { 458 + "version": "0.27.2", 459 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", 460 + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", 461 + "cpu": [ 462 + "loong64" 463 + ], 464 + "dev": true, 465 + "license": "MIT", 466 + "optional": true, 467 + "os": [ 468 + "linux" 469 + ], 470 + "engines": { 471 + "node": ">=18" 472 + } 473 + }, 474 + "node_modules/@esbuild/linux-mips64el": { 475 + "version": "0.27.2", 476 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", 477 + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", 478 + "cpu": [ 479 + "mips64el" 480 + ], 481 + "dev": true, 482 + "license": "MIT", 483 + "optional": true, 484 + "os": [ 485 + "linux" 486 + ], 487 + "engines": { 488 + "node": ">=18" 489 + } 490 + }, 491 + "node_modules/@esbuild/linux-ppc64": { 492 + "version": "0.27.2", 493 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", 494 + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", 495 + "cpu": [ 496 + "ppc64" 497 + ], 498 + "dev": true, 499 + "license": "MIT", 500 + "optional": true, 501 + "os": [ 502 + "linux" 503 + ], 504 + "engines": { 505 + "node": ">=18" 506 + } 507 + }, 508 + "node_modules/@esbuild/linux-riscv64": { 509 + "version": "0.27.2", 510 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", 511 + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", 512 + "cpu": [ 513 + "riscv64" 514 + ], 515 + "dev": true, 516 + "license": "MIT", 517 + "optional": true, 518 + "os": [ 519 + "linux" 520 + ], 521 + "engines": { 522 + "node": ">=18" 523 + } 524 + }, 525 + "node_modules/@esbuild/linux-s390x": { 526 + "version": "0.27.2", 527 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", 528 + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", 529 + "cpu": [ 530 + "s390x" 531 + ], 532 + "dev": true, 533 + "license": "MIT", 534 + "optional": true, 535 + "os": [ 536 + "linux" 537 + ], 538 + "engines": { 539 + "node": ">=18" 540 + } 541 + }, 542 + "node_modules/@esbuild/linux-x64": { 543 + "version": "0.27.2", 544 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", 545 + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", 546 + "cpu": [ 547 + "x64" 548 + ], 549 + "dev": true, 550 + "license": "MIT", 551 + "optional": true, 552 + "os": [ 553 + "linux" 554 + ], 555 + "engines": { 556 + "node": ">=18" 557 + } 558 + }, 559 + "node_modules/@esbuild/netbsd-arm64": { 560 + "version": "0.27.2", 561 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", 562 + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", 563 + "cpu": [ 564 + "arm64" 565 + ], 566 + "dev": true, 567 + "license": "MIT", 568 + "optional": true, 569 + "os": [ 570 + "netbsd" 571 + ], 572 + "engines": { 573 + "node": ">=18" 574 + } 575 + }, 576 + "node_modules/@esbuild/netbsd-x64": { 577 + "version": "0.27.2", 578 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", 579 + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", 580 + "cpu": [ 581 + "x64" 582 + ], 583 + "dev": true, 584 + "license": "MIT", 585 + "optional": true, 586 + "os": [ 587 + "netbsd" 588 + ], 589 + "engines": { 590 + "node": ">=18" 591 + } 592 + }, 593 + "node_modules/@esbuild/openbsd-arm64": { 594 + "version": "0.27.2", 595 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", 596 + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", 597 + "cpu": [ 598 + "arm64" 599 + ], 600 + "dev": true, 601 + "license": "MIT", 602 + "optional": true, 603 + "os": [ 604 + "openbsd" 605 + ], 606 + "engines": { 607 + "node": ">=18" 608 + } 609 + }, 610 + "node_modules/@esbuild/openbsd-x64": { 611 + "version": "0.27.2", 612 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", 613 + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", 614 + "cpu": [ 615 + "x64" 616 + ], 617 + "dev": true, 618 + "license": "MIT", 619 + "optional": true, 620 + "os": [ 621 + "openbsd" 622 + ], 623 + "engines": { 624 + "node": ">=18" 625 + } 626 + }, 627 + "node_modules/@esbuild/openharmony-arm64": { 628 + "version": "0.27.2", 629 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", 630 + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", 631 + "cpu": [ 632 + "arm64" 633 + ], 634 + "dev": true, 635 + "license": "MIT", 636 + "optional": true, 637 + "os": [ 638 + "openharmony" 639 + ], 640 + "engines": { 641 + "node": ">=18" 642 + } 643 + }, 644 + "node_modules/@esbuild/sunos-x64": { 645 + "version": "0.27.2", 646 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", 647 + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", 648 + "cpu": [ 649 + "x64" 650 + ], 651 + "dev": true, 652 + "license": "MIT", 653 + "optional": true, 654 + "os": [ 655 + "sunos" 656 + ], 657 + "engines": { 658 + "node": ">=18" 659 + } 660 + }, 661 + "node_modules/@esbuild/win32-arm64": { 662 + "version": "0.27.2", 663 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", 664 + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", 665 + "cpu": [ 666 + "arm64" 667 + ], 668 + "dev": true, 669 + "license": "MIT", 670 + "optional": true, 671 + "os": [ 672 + "win32" 673 + ], 674 + "engines": { 675 + "node": ">=18" 676 + } 677 + }, 678 + "node_modules/@esbuild/win32-ia32": { 679 + "version": "0.27.2", 680 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", 681 + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", 682 + "cpu": [ 683 + "ia32" 684 + ], 685 + "dev": true, 686 + "license": "MIT", 687 + "optional": true, 688 + "os": [ 689 + "win32" 690 + ], 691 + "engines": { 692 + "node": ">=18" 693 + } 694 + }, 695 + "node_modules/@esbuild/win32-x64": { 696 + "version": "0.27.2", 697 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", 698 + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", 699 + "cpu": [ 700 + "x64" 701 + ], 702 + "dev": true, 703 + "license": "MIT", 704 + "optional": true, 705 + "os": [ 706 + "win32" 707 + ], 708 + "engines": { 709 + "node": ">=18" 710 + } 711 + }, 100 712 "node_modules/@steipete/bird": { 101 713 "version": "0.4.0", 102 714 "resolved": "https://registry.npmjs.org/@steipete/bird/-/bird-0.4.0.tgz", ··· 111 723 }, 112 724 "engines": { 113 725 "node": ">=20" 726 + } 727 + }, 728 + "node_modules/@types/node": { 729 + "version": "22.19.3", 730 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", 731 + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", 732 + "dev": true, 733 + "license": "MIT", 734 + "dependencies": { 735 + "undici-types": "~6.21.0" 114 736 } 115 737 }, 116 738 "node_modules/asynckit": { ··· 260 882 "node": ">= 0.4" 261 883 } 262 884 }, 885 + "node_modules/esbuild": { 886 + "version": "0.27.2", 887 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", 888 + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", 889 + "dev": true, 890 + "hasInstallScript": true, 891 + "license": "MIT", 892 + "bin": { 893 + "esbuild": "bin/esbuild" 894 + }, 895 + "engines": { 896 + "node": ">=18" 897 + }, 898 + "optionalDependencies": { 899 + "@esbuild/aix-ppc64": "0.27.2", 900 + "@esbuild/android-arm": "0.27.2", 901 + "@esbuild/android-arm64": "0.27.2", 902 + "@esbuild/android-x64": "0.27.2", 903 + "@esbuild/darwin-arm64": "0.27.2", 904 + "@esbuild/darwin-x64": "0.27.2", 905 + "@esbuild/freebsd-arm64": "0.27.2", 906 + "@esbuild/freebsd-x64": "0.27.2", 907 + "@esbuild/linux-arm": "0.27.2", 908 + "@esbuild/linux-arm64": "0.27.2", 909 + "@esbuild/linux-ia32": "0.27.2", 910 + "@esbuild/linux-loong64": "0.27.2", 911 + "@esbuild/linux-mips64el": "0.27.2", 912 + "@esbuild/linux-ppc64": "0.27.2", 913 + "@esbuild/linux-riscv64": "0.27.2", 914 + "@esbuild/linux-s390x": "0.27.2", 915 + "@esbuild/linux-x64": "0.27.2", 916 + "@esbuild/netbsd-arm64": "0.27.2", 917 + "@esbuild/netbsd-x64": "0.27.2", 918 + "@esbuild/openbsd-arm64": "0.27.2", 919 + "@esbuild/openbsd-x64": "0.27.2", 920 + "@esbuild/openharmony-arm64": "0.27.2", 921 + "@esbuild/sunos-x64": "0.27.2", 922 + "@esbuild/win32-arm64": "0.27.2", 923 + "@esbuild/win32-ia32": "0.27.2", 924 + "@esbuild/win32-x64": "0.27.2" 925 + } 926 + }, 263 927 "node_modules/follow-redirects": { 264 928 "version": "1.15.11", 265 929 "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", ··· 309 973 "url": "https://github.com/sponsors/wooorm" 310 974 } 311 975 }, 976 + "node_modules/fsevents": { 977 + "version": "2.3.3", 978 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 979 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 980 + "dev": true, 981 + "hasInstallScript": true, 982 + "license": "MIT", 983 + "optional": true, 984 + "os": [ 985 + "darwin" 986 + ], 987 + "engines": { 988 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 989 + } 990 + }, 312 991 "node_modules/function-bind": { 313 992 "version": "1.1.2", 314 993 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", ··· 353 1032 }, 354 1033 "engines": { 355 1034 "node": ">= 0.4" 1035 + } 1036 + }, 1037 + "node_modules/get-tsconfig": { 1038 + "version": "4.13.0", 1039 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", 1040 + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", 1041 + "dev": true, 1042 + "license": "MIT", 1043 + "dependencies": { 1044 + "resolve-pkg-maps": "^1.0.0" 1045 + }, 1046 + "funding": { 1047 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 356 1048 } 357 1049 }, 358 1050 "node_modules/gopd": { ··· 503 1195 "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 504 1196 "license": "MIT" 505 1197 }, 1198 + "node_modules/resolve-pkg-maps": { 1199 + "version": "1.0.0", 1200 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 1201 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 1202 + "dev": true, 1203 + "license": "MIT", 1204 + "funding": { 1205 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 1206 + } 1207 + }, 506 1208 "node_modules/tlds": { 507 1209 "version": "1.261.0", 508 1210 "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", ··· 532 1234 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 533 1235 "license": "0BSD" 534 1236 }, 1237 + "node_modules/tsx": { 1238 + "version": "4.21.0", 1239 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", 1240 + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", 1241 + "dev": true, 1242 + "license": "MIT", 1243 + "dependencies": { 1244 + "esbuild": "~0.27.0", 1245 + "get-tsconfig": "^4.7.5" 1246 + }, 1247 + "bin": { 1248 + "tsx": "dist/cli.mjs" 1249 + }, 1250 + "engines": { 1251 + "node": ">=18.0.0" 1252 + }, 1253 + "optionalDependencies": { 1254 + "fsevents": "~2.3.3" 1255 + } 1256 + }, 1257 + "node_modules/typescript": { 1258 + "version": "5.9.3", 1259 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1260 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1261 + "dev": true, 1262 + "license": "Apache-2.0", 1263 + "bin": { 1264 + "tsc": "bin/tsc", 1265 + "tsserver": "bin/tsserver" 1266 + }, 1267 + "engines": { 1268 + "node": ">=14.17" 1269 + } 1270 + }, 535 1271 "node_modules/uint8arrays": { 536 1272 "version": "3.0.0", 537 1273 "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", ··· 540 1276 "dependencies": { 541 1277 "multiformats": "^9.4.2" 542 1278 } 1279 + }, 1280 + "node_modules/undici-types": { 1281 + "version": "6.21.0", 1282 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 1283 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 1284 + "dev": true, 1285 + "license": "MIT" 543 1286 }, 544 1287 "node_modules/unicode-segmenter": { 545 1288 "version": "0.14.5",
+17 -5
package.json
··· 1 1 { 2 2 "name": "tweets-2-bsky", 3 - "version": "1.0.3", 3 + "version": "2.0.0", 4 4 "description": "A powerful tool to crosspost Tweets to Bluesky, supporting threads, videos, and high-quality images.", 5 - "main": "index.js", 5 + "type": "module", 6 + "main": "dist/index.js", 6 7 "scripts": { 7 - "start": "node index.js", 8 - "import": "node index.js --import-history" 8 + "build": "tsc", 9 + "start": "node dist/index.js", 10 + "dev": "tsx src/index.ts", 11 + "import": "tsx src/index.ts --import-history", 12 + "lint": "biome check --write .", 13 + "format": "biome format --write .", 14 + "typecheck": "tsc --noEmit" 9 15 }, 10 16 "keywords": [ 11 17 "bluesky", ··· 25 31 "franc-min": "^6.2.0", 26 32 "iso-639-1": "^3.1.2", 27 33 "node-cron": "^4.2.1" 34 + }, 35 + "devDependencies": { 36 + "@biomejs/biome": "^1.9.4", 37 + "@types/node": "^22.10.2", 38 + "tsx": "^4.19.2", 39 + "typescript": "^5.7.2" 28 40 } 29 - } 41 + }
+670
src/index.ts
··· 1 + import 'dotenv/config'; 2 + import { exec } from 'node:child_process'; 3 + import fs from 'node:fs'; 4 + import path from 'node:path'; 5 + import { fileURLToPath } from 'node:url'; 6 + import { BskyAgent, RichText } from '@atproto/api'; 7 + import type { BlobRef } from '@atproto/api'; 8 + import { TwitterClient } from '@steipete/bird/dist/lib/twitter-client'; 9 + import axios from 'axios'; 10 + import * as francModule from 'franc-min'; 11 + import iso6391 from 'iso-639-1'; 12 + import cron from 'node-cron'; 13 + 14 + // ESM __dirname equivalent 15 + const __filename = fileURLToPath(import.meta.url); 16 + const __dirname = path.dirname(__filename); 17 + 18 + // ============================================================================ 19 + // Type Definitions 20 + // ============================================================================ 21 + 22 + interface ProcessedTweetEntry { 23 + uri?: string; 24 + cid?: string; 25 + root?: { uri: string; cid: string }; 26 + migrated?: boolean; 27 + skipped?: boolean; 28 + } 29 + 30 + interface ProcessedTweetsMap { 31 + [twitterId: string]: ProcessedTweetEntry; 32 + } 33 + 34 + interface UrlEntity { 35 + url?: string; 36 + expanded_url?: string; 37 + } 38 + 39 + interface MediaSize { 40 + w: number; 41 + h: number; 42 + } 43 + 44 + interface MediaSizes { 45 + large?: MediaSize; 46 + } 47 + 48 + interface OriginalInfo { 49 + width: number; 50 + height: number; 51 + } 52 + 53 + interface VideoVariant { 54 + content_type: string; 55 + url: string; 56 + bitrate?: number; 57 + } 58 + 59 + interface VideoInfo { 60 + variants?: VideoVariant[]; 61 + } 62 + 63 + interface MediaEntity { 64 + url?: string; 65 + expanded_url?: string; 66 + media_url_https?: string; 67 + type?: 'photo' | 'video' | 'animated_gif'; 68 + ext_alt_text?: string; 69 + sizes?: MediaSizes; 70 + original_info?: OriginalInfo; 71 + video_info?: VideoInfo; 72 + } 73 + 74 + interface TweetEntities { 75 + urls?: UrlEntity[]; 76 + media?: MediaEntity[]; 77 + } 78 + 79 + interface Tweet { 80 + id?: string; 81 + id_str?: string; 82 + text?: string; 83 + full_text?: string; 84 + created_at?: string; 85 + entities?: TweetEntities; 86 + extended_entities?: TweetEntities; 87 + quoted_status_id_str?: string; 88 + is_quote_status?: boolean; 89 + in_reply_to_status_id_str?: string; 90 + in_reply_to_status_id?: string; 91 + in_reply_to_user_id_str?: string; 92 + in_reply_to_user_id?: string; 93 + } 94 + 95 + interface TwitterSearchResult { 96 + success: boolean; 97 + tweets?: Tweet[]; 98 + error?: Error | string; 99 + } 100 + 101 + interface TwitterUserResult { 102 + success: boolean; 103 + user?: { username: string }; 104 + } 105 + 106 + interface AspectRatio { 107 + width: number; 108 + height: number; 109 + } 110 + 111 + interface ImageEmbed { 112 + alt: string; 113 + image: BlobRef; 114 + aspectRatio?: AspectRatio; 115 + } 116 + 117 + // PostRecord is built dynamically for agent.post() 118 + 119 + // ============================================================================ 120 + // Configuration 121 + // ============================================================================ 122 + 123 + const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; 124 + const TWITTER_CT0 = process.env.TWITTER_CT0; 125 + const TWITTER_TARGET_USERNAME = process.env.TWITTER_TARGET_USERNAME; 126 + const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER; 127 + const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD; 128 + const BLUESKY_SERVICE_URL = process.env.BLUESKY_SERVICE_URL || 'https://bsky.social'; 129 + const CHECK_INTERVAL_MINUTES = Number(process.env.CHECK_INTERVAL_MINUTES) || 5; 130 + const PROCESSED_TWEETS_FILE = path.join(__dirname, '..', 'processed_tweets.json'); 131 + 132 + // ============================================================================ 133 + // State Management 134 + // ============================================================================ 135 + 136 + let processedTweets: ProcessedTweetsMap = {}; 137 + 138 + function loadProcessedTweets(): void { 139 + try { 140 + if (fs.existsSync(PROCESSED_TWEETS_FILE)) { 141 + const raw: unknown = JSON.parse(fs.readFileSync(PROCESSED_TWEETS_FILE, 'utf8')); 142 + if (Array.isArray(raw)) { 143 + // Migration from v1 (Array of IDs) to v2 (Object map) 144 + console.log('Migrating processed_tweets.json from v1 to v2...'); 145 + processedTweets = (raw as string[]).reduce<ProcessedTweetsMap>((acc, id) => { 146 + acc[id] = { migrated: true }; 147 + return acc; 148 + }, {}); 149 + saveProcessedTweets(); 150 + } else if (typeof raw === 'object' && raw !== null) { 151 + processedTweets = raw as ProcessedTweetsMap; 152 + } 153 + } 154 + } catch (err) { 155 + console.error('Error loading processed tweets:', err); 156 + } 157 + } 158 + 159 + function saveProcessedTweets(): void { 160 + try { 161 + fs.writeFileSync(PROCESSED_TWEETS_FILE, JSON.stringify(processedTweets, null, 2)); 162 + } catch (err) { 163 + console.error('Error saving processed tweets:', err); 164 + } 165 + } 166 + 167 + loadProcessedTweets(); 168 + 169 + // ============================================================================ 170 + // Bluesky Agent 171 + // ============================================================================ 172 + 173 + const agent = new BskyAgent({ 174 + service: BLUESKY_SERVICE_URL, 175 + }); 176 + 177 + // ============================================================================ 178 + // Custom Twitter Client 179 + // ============================================================================ 180 + 181 + interface TwitterLegacyResult { 182 + legacy?: { 183 + entities?: TweetEntities; 184 + extended_entities?: TweetEntities; 185 + quoted_status_id_str?: string; 186 + is_quote_status?: boolean; 187 + in_reply_to_status_id_str?: string; 188 + in_reply_to_user_id_str?: string; 189 + }; 190 + } 191 + 192 + class CustomTwitterClient extends TwitterClient { 193 + mapTweetResult(result: TwitterLegacyResult): Tweet | null { 194 + // biome-ignore lint/suspicious/noExplicitAny: parent class is untyped 195 + const mapped = (super.mapTweetResult as any)(result) as Tweet | null; 196 + if (mapped && result.legacy) { 197 + mapped.entities = result.legacy.entities; 198 + mapped.extended_entities = result.legacy.extended_entities; 199 + mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; 200 + mapped.is_quote_status = result.legacy.is_quote_status; 201 + mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; 202 + mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; 203 + } 204 + return mapped; 205 + } 206 + } 207 + 208 + const twitter = new CustomTwitterClient({ 209 + cookies: { 210 + authToken: TWITTER_AUTH_TOKEN ?? '', 211 + ct0: TWITTER_CT0 ?? '', 212 + }, 213 + }); 214 + 215 + // ============================================================================ 216 + // Helper Functions 217 + // ============================================================================ 218 + 219 + function detectLanguage(text: string): string[] { 220 + if (!text || text.trim().length === 0) return ['en']; 221 + try { 222 + const code3 = (francModule as unknown as (text: string) => string)(text); 223 + if (code3 === 'und') return ['en']; 224 + const code2 = iso6391.getCode(code3); 225 + return code2 ? [code2] : ['en']; 226 + } catch { 227 + return ['en']; 228 + } 229 + } 230 + 231 + async function expandUrl(shortUrl: string): Promise<string> { 232 + try { 233 + const response = await axios.head(shortUrl, { 234 + maxRedirects: 10, 235 + validateStatus: (status) => status >= 200 && status < 400, 236 + }); 237 + // biome-ignore lint/suspicious/noExplicitAny: axios internal types 238 + return (response.request as any)?.res?.responseUrl || shortUrl; 239 + } catch { 240 + try { 241 + const response = await axios.get(shortUrl, { 242 + responseType: 'stream', 243 + maxRedirects: 10, 244 + }); 245 + response.data.destroy(); 246 + // biome-ignore lint/suspicious/noExplicitAny: axios internal types 247 + return (response.request as any)?.res?.responseUrl || shortUrl; 248 + } catch { 249 + return shortUrl; 250 + } 251 + } 252 + } 253 + 254 + interface DownloadedMedia { 255 + buffer: Buffer; 256 + mimeType: string; 257 + } 258 + 259 + async function downloadMedia(url: string): Promise<DownloadedMedia> { 260 + const response = await axios({ 261 + url, 262 + method: 'GET', 263 + responseType: 'arraybuffer', 264 + }); 265 + return { 266 + buffer: Buffer.from(response.data as ArrayBuffer), 267 + mimeType: (response.headers['content-type'] as string) || 'application/octet-stream', 268 + }; 269 + } 270 + 271 + async function uploadToBluesky(buffer: Buffer, mimeType: string): Promise<BlobRef> { 272 + const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); 273 + return data.blob; 274 + } 275 + 276 + async function getUsername(): Promise<string> { 277 + if (TWITTER_TARGET_USERNAME) return TWITTER_TARGET_USERNAME; 278 + try { 279 + const res = (await twitter.getCurrentUser()) as TwitterUserResult; 280 + if (res.success && res.user) { 281 + return res.user.username; 282 + } 283 + } catch (e) { 284 + console.warn("Failed to get 'whoami'. defaulting to 'me'.", (e as Error).message); 285 + } 286 + return 'me'; 287 + } 288 + 289 + function getRandomDelay(min = 1000, max = 4000): number { 290 + return Math.floor(Math.random() * (max - min + 1) + min); 291 + } 292 + 293 + function refreshQueryIds(): Promise<void> { 294 + return new Promise((resolve) => { 295 + console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); 296 + exec('./node_modules/.bin/bird query-ids --fresh', (error, _stdout, stderr) => { 297 + if (error) { 298 + console.error(`Error refreshing IDs: ${error.message}`); 299 + console.error(`Stderr: ${stderr}`); 300 + } else { 301 + console.log('✅ Query IDs refreshed successfully.'); 302 + } 303 + resolve(); 304 + }); 305 + }); 306 + } 307 + 308 + /** 309 + * Wraps twitter.search with auto-recovery for stale Query IDs 310 + */ 311 + async function safeSearch(query: string, limit: number): Promise<TwitterSearchResult> { 312 + try { 313 + const result = (await twitter.search(query, limit)) as TwitterSearchResult; 314 + if (!result.success && result.error) { 315 + const errorStr = result.error.toString(); 316 + if (errorStr.includes('GraphQL') || errorStr.includes('404')) { 317 + throw new Error(errorStr); 318 + } 319 + } 320 + return result; 321 + } catch (err) { 322 + const error = err as Error; 323 + console.warn(`Search encountered an error: ${error.message || err}`); 324 + if ( 325 + error.message && 326 + (error.message.includes('GraphQL') || error.message.includes('404') || error.message.includes('Bad Guest Token')) 327 + ) { 328 + await refreshQueryIds(); 329 + console.log('Retrying search...'); 330 + return (await twitter.search(query, limit)) as TwitterSearchResult; 331 + } 332 + return { success: false, error }; 333 + } 334 + } 335 + 336 + // ============================================================================ 337 + // Main Processing Logic 338 + // ============================================================================ 339 + 340 + async function processTweets(tweets: Tweet[]): Promise<void> { 341 + // Ensure chronological order 342 + tweets.reverse(); 343 + 344 + for (const tweet of tweets) { 345 + const tweetId = tweet.id_str || tweet.id; 346 + if (!tweetId) continue; 347 + 348 + if (processedTweets[tweetId]) { 349 + continue; 350 + } 351 + 352 + // --- Filter Replies (unless we are maintaining a thread) --- 353 + const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; 354 + const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; 355 + const tweetText = tweet.full_text || tweet.text || ''; 356 + const isReply = !!replyStatusId || !!replyUserId || tweetText.trim().startsWith('@'); 357 + 358 + let replyParentInfo: ProcessedTweetEntry | null = null; 359 + 360 + if (isReply) { 361 + if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId]?.migrated) { 362 + console.log(`Threading reply to ${replyStatusId}`); 363 + replyParentInfo = processedTweets[replyStatusId] ?? null; 364 + } else { 365 + console.log(`Skipping reply: ${tweetId}`); 366 + processedTweets[tweetId] = { skipped: true }; 367 + saveProcessedTweets(); 368 + continue; 369 + } 370 + } 371 + 372 + console.log(`Processing tweet: ${tweetId}`); 373 + 374 + let text = tweetText; 375 + 376 + // --- 1. Link Expansion --- 377 + const urls = tweet.entities?.urls || []; 378 + for (const urlEntity of urls) { 379 + const tco = urlEntity.url; 380 + const expanded = urlEntity.expanded_url; 381 + if (tco && expanded) { 382 + text = text.replace(tco, expanded); 383 + } 384 + } 385 + 386 + // Manual cleanup of remaining t.co 387 + const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; 388 + const matches = text.match(tcoRegex) || []; 389 + for (const tco of matches) { 390 + const resolved = await expandUrl(tco); 391 + if (resolved !== tco) { 392 + text = text.replace(tco, resolved); 393 + } 394 + } 395 + 396 + // --- 2. Media Handling --- 397 + const images: ImageEmbed[] = []; 398 + let videoBlob: BlobRef | null = null; 399 + let videoAspectRatio: AspectRatio | undefined; 400 + 401 + const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; 402 + const mediaLinksToRemove: string[] = []; 403 + 404 + for (const media of mediaEntities) { 405 + if (media.url) { 406 + mediaLinksToRemove.push(media.url); 407 + if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); 408 + } 409 + 410 + // Aspect Ratio Extraction 411 + let aspectRatio: AspectRatio | undefined; 412 + if (media.sizes?.large) { 413 + aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; 414 + } else if (media.original_info) { 415 + aspectRatio = { width: media.original_info.width, height: media.original_info.height }; 416 + } 417 + 418 + if (media.type === 'photo') { 419 + const url = media.media_url_https; 420 + if (!url) continue; 421 + try { 422 + const { buffer, mimeType } = await downloadMedia(url); 423 + const blob = await uploadToBluesky(buffer, mimeType); 424 + images.push({ 425 + alt: media.ext_alt_text || 'Image from Twitter', 426 + image: blob, 427 + aspectRatio, 428 + }); 429 + } catch (err) { 430 + console.error(`Failed to upload image ${url}:`, (err as Error).message); 431 + } 432 + } else if (media.type === 'video' || media.type === 'animated_gif') { 433 + const variants = media.video_info?.variants || []; 434 + const mp4s = variants 435 + .filter((v) => v.content_type === 'video/mp4') 436 + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); 437 + 438 + if (mp4s.length > 0 && mp4s[0]) { 439 + const videoUrl = mp4s[0].url; 440 + try { 441 + const { buffer, mimeType } = await downloadMedia(videoUrl); 442 + 443 + if (buffer.length > 95 * 1024 * 1024) { 444 + console.warn('Video too large (>95MB). Linking instead.'); 445 + text += `\n[Video: ${media.media_url_https}]`; 446 + continue; 447 + } 448 + 449 + const blob = await uploadToBluesky(buffer, mimeType); 450 + videoBlob = blob; 451 + videoAspectRatio = aspectRatio; 452 + break; 453 + } catch (err) { 454 + console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message); 455 + text += `\n${media.media_url_https}`; 456 + } 457 + } 458 + } 459 + } 460 + 461 + // Remove media links from text 462 + for (const link of mediaLinksToRemove) { 463 + text = text.split(link).join('').trim(); 464 + } 465 + text = text.replace(/\n\s*\n/g, '\n\n').trim(); 466 + 467 + // --- 3. Quoting Logic --- 468 + let quoteEmbed: { $type: string; record: { uri: string; cid: string } } | null = null; 469 + if (tweet.is_quote_status && tweet.quoted_status_id_str) { 470 + const quoteId = tweet.quoted_status_id_str; 471 + const quoteRef = processedTweets[quoteId]; 472 + if (quoteRef && !quoteRef.migrated && quoteRef.uri && quoteRef.cid) { 473 + quoteEmbed = { 474 + $type: 'app.bsky.embed.record', 475 + record: { 476 + uri: quoteRef.uri, 477 + cid: quoteRef.cid, 478 + }, 479 + }; 480 + } 481 + } 482 + 483 + // --- 4. Construct Post --- 484 + const rt = new RichText({ text }); 485 + await rt.detectFacets(agent); 486 + const detectedLangs = detectLanguage(text); 487 + 488 + // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction 489 + const postRecord: Record<string, any> = { 490 + text: rt.text, 491 + facets: rt.facets, 492 + langs: detectedLangs, 493 + createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), 494 + }; 495 + 496 + // Attach Embeds 497 + if (videoBlob) { 498 + postRecord.embed = { 499 + $type: 'app.bsky.embed.video', 500 + video: videoBlob, 501 + aspectRatio: videoAspectRatio, 502 + }; 503 + } else if (images.length > 0) { 504 + const imagesEmbed = { 505 + $type: 'app.bsky.embed.images', 506 + images, 507 + }; 508 + 509 + if (quoteEmbed) { 510 + postRecord.embed = { 511 + $type: 'app.bsky.embed.recordWithMedia', 512 + media: imagesEmbed, 513 + record: quoteEmbed, 514 + }; 515 + } else { 516 + postRecord.embed = imagesEmbed; 517 + } 518 + } else if (quoteEmbed) { 519 + postRecord.embed = quoteEmbed; 520 + } 521 + 522 + // Attach Reply info 523 + if (replyParentInfo?.uri && replyParentInfo?.cid) { 524 + postRecord.reply = { 525 + root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 526 + parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 527 + }; 528 + } 529 + 530 + // --- 5. Post & Save --- 531 + try { 532 + const response = await agent.post(postRecord); 533 + 534 + const newEntry: ProcessedTweetEntry = { 535 + uri: response.uri, 536 + cid: response.cid, 537 + root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 538 + }; 539 + 540 + processedTweets[tweetId] = newEntry; 541 + saveProcessedTweets(); 542 + 543 + // Random Pacing (1s - 4s) 544 + const sleepTime = getRandomDelay(1000, 4000); 545 + await new Promise((r) => setTimeout(r, sleepTime)); 546 + } catch (err) { 547 + console.error(`Failed to post ${tweetId}:`, err); 548 + } 549 + } 550 + } 551 + 552 + async function checkAndPost(): Promise<void> { 553 + console.log(`[${new Date().toISOString()}] Checking...`); 554 + 555 + try { 556 + const username = await getUsername(); 557 + 558 + const query = `from:${username}`; 559 + const result = await safeSearch(query, 30); 560 + 561 + if (!result.success) { 562 + console.error('Failed to fetch tweets:', result.error); 563 + return; 564 + } 565 + 566 + const tweets = result.tweets || []; 567 + if (tweets.length === 0) return; 568 + 569 + await processTweets(tweets); 570 + } catch (err) { 571 + console.error('Error in checkAndPost:', err); 572 + } 573 + } 574 + 575 + async function importHistory(): Promise<void> { 576 + console.log('Starting full history import...'); 577 + const username = await getUsername(); 578 + console.log(`Importing history for: ${username}`); 579 + 580 + let maxId: string | null = null; 581 + const keepGoing = true; 582 + const count = 100; 583 + const allFoundTweets: Tweet[] = []; 584 + const seenIds = new Set<string>(); 585 + 586 + while (keepGoing) { 587 + let query = `from:${username}`; 588 + if (maxId) { 589 + query += ` max_id:${maxId}`; 590 + } 591 + 592 + console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); 593 + 594 + const result = await safeSearch(query, count); 595 + 596 + if (!result.success) { 597 + console.error('Fetch failed:', result.error); 598 + break; 599 + } 600 + 601 + const tweets = result.tweets || []; 602 + if (tweets.length === 0) break; 603 + 604 + let newOnes = 0; 605 + for (const t of tweets) { 606 + const tid = t.id_str || t.id; 607 + if (!tid) continue; 608 + if (!processedTweets[tid] && !seenIds.has(tid)) { 609 + allFoundTweets.push(t); 610 + seenIds.add(tid); 611 + newOnes++; 612 + } 613 + } 614 + 615 + if (newOnes === 0 && tweets.length > 0) { 616 + const lastTweet = tweets[tweets.length - 1]; 617 + const lastId = lastTweet?.id_str || lastTweet?.id; 618 + if (lastId === maxId) break; 619 + } 620 + 621 + const lastTweet = tweets[tweets.length - 1]; 622 + maxId = lastTweet?.id_str || lastTweet?.id || null; 623 + 624 + // Rate limit protection 625 + await new Promise((r) => setTimeout(r, 2000)); 626 + } 627 + 628 + console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); 629 + 630 + if (allFoundTweets.length > 0) { 631 + console.log('Starting processing (Oldest -> Newest) with random pacing...'); 632 + await processTweets(allFoundTweets); 633 + console.log('History import complete.'); 634 + } else { 635 + console.log('Nothing new to import.'); 636 + } 637 + } 638 + 639 + // ============================================================================ 640 + // Entry Point 641 + // ============================================================================ 642 + 643 + async function main(): Promise<void> { 644 + if (!TWITTER_AUTH_TOKEN || !TWITTER_CT0 || !BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { 645 + console.error('Missing credentials in .env file.'); 646 + process.exit(1); 647 + } 648 + 649 + try { 650 + await agent.login({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }); 651 + console.log('Logged in to Bluesky.'); 652 + } catch (err) { 653 + console.error('Failed to login to Bluesky:', err); 654 + process.exit(1); 655 + } 656 + 657 + if (process.argv.includes('--import-history')) { 658 + await importHistory(); 659 + process.exit(0); 660 + } 661 + 662 + await checkAndPost(); 663 + 664 + console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`); 665 + cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, () => { 666 + checkAndPost(); 667 + }); 668 + } 669 + 670 + main();
+49
src/types.d.ts
··· 1 + declare module '@steipete/bird/dist/lib/twitter-client' { 2 + export interface TwitterClientOptions { 3 + cookies: { 4 + authToken: string; 5 + ct0: string; 6 + }; 7 + } 8 + 9 + export interface TwitterUser { 10 + username: string; 11 + id?: string; 12 + name?: string; 13 + } 14 + 15 + export interface TwitterUserResult { 16 + success: boolean; 17 + user?: TwitterUser; 18 + error?: Error | string; 19 + } 20 + 21 + export interface TwitterSearchResult { 22 + success: boolean; 23 + tweets?: unknown[]; 24 + error?: Error | string; 25 + } 26 + 27 + export class TwitterClient { 28 + constructor(options: TwitterClientOptions); 29 + getCurrentUser(): Promise<TwitterUserResult>; 30 + search(query: string, limit: number): Promise<TwitterSearchResult>; 31 + mapTweetResult(result: unknown): unknown; 32 + } 33 + } 34 + 35 + declare module 'franc-min' { 36 + const franc: (text: string) => string; 37 + export default franc; 38 + export = franc; 39 + } 40 + 41 + declare module 'iso-639-1' { 42 + const iso6391: { 43 + getCode(name: string): string | undefined; 44 + getName(code: string): string | undefined; 45 + getAllNames(): string[]; 46 + getAllCodes(): string[]; 47 + }; 48 + export default iso6391; 49 + }
+34
tsconfig.json
··· 1 + { 2 + "$schema": "https://json.schemastore.org/tsconfig", 3 + "compilerOptions": { 4 + "target": "ES2022", 5 + "module": "NodeNext", 6 + "moduleResolution": "NodeNext", 7 + "lib": [ 8 + "ES2022" 9 + ], 10 + "types": [ 11 + "node" 12 + ], 13 + "outDir": "./dist", 14 + "rootDir": "./src", 15 + "strict": true, 16 + "noUncheckedIndexedAccess": true, 17 + "noImplicitReturns": true, 18 + "noFallthroughCasesInSwitch": true, 19 + "esModuleInterop": true, 20 + "allowSyntheticDefaultImports": true, 21 + "skipLibCheck": true, 22 + "forceConsistentCasingInFileNames": true, 23 + "declaration": true, 24 + "declarationMap": true, 25 + "sourceMap": true 26 + }, 27 + "include": [ 28 + "src/**/*" 29 + ], 30 + "exclude": [ 31 + "node_modules", 32 + "dist" 33 + ] 34 + }