tangled
alpha
login
or
join now
indexx.dev
/
tweets2bsky
forked from
j4ck.xyz/tweets2bsky
0
fork
atom
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.
0
fork
atom
overview
issues
pulls
pipelines
feat: implement tweet threading and revert video to links
jack
2 months ago
9d177ab2
336c1506
+95
-53
1 changed file
expand all
collapse all
unified
split
src
index.ts
+95
-53
src/index.ts
···
265
265
return Math.floor(Math.random() * (max - min + 1) + min);
266
266
}
267
267
268
268
+
function splitText(text: string, limit = 300): string[] {
269
269
+
if (text.length <= limit) return [text];
270
270
+
271
271
+
const chunks: string[] = [];
272
272
+
let remaining = text;
273
273
+
274
274
+
while (remaining.length > 0) {
275
275
+
if (remaining.length <= limit) {
276
276
+
chunks.push(remaining);
277
277
+
break;
278
278
+
}
279
279
+
280
280
+
// Try to split by paragraph
281
281
+
let splitIndex = remaining.lastIndexOf('\n\n', limit);
282
282
+
if (splitIndex === -1) {
283
283
+
// Try to split by sentence
284
284
+
splitIndex = remaining.lastIndexOf('. ', limit);
285
285
+
if (splitIndex === -1) {
286
286
+
// Try to split by space
287
287
+
splitIndex = remaining.lastIndexOf(' ', limit);
288
288
+
if (splitIndex === -1) {
289
289
+
// Force split
290
290
+
splitIndex = limit;
291
291
+
}
292
292
+
} else {
293
293
+
splitIndex += 1; // Include the period
294
294
+
}
295
295
+
}
296
296
+
297
297
+
chunks.push(remaining.substring(0, splitIndex).trim());
298
298
+
remaining = remaining.substring(splitIndex).trim();
299
299
+
}
300
300
+
301
301
+
return chunks;
302
302
+
}
303
303
+
268
304
function refreshQueryIds(): Promise<void> {
269
305
return new Promise((resolve) => {
270
306
console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI...");
···
403
439
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
404
440
405
441
if (mp4s.length > 0 && mp4s[0]) {
406
406
-
const videoUrl = mp4s[0].url;
407
407
-
try {
408
408
-
const { buffer, mimeType } = await downloadMedia(videoUrl);
409
409
-
if (buffer.length > 95 * 1024 * 1024) {
410
410
-
text += `\n[Video: ${media.media_url_https}]`;
411
411
-
continue;
412
412
-
}
413
413
-
const blob = await uploadToBluesky(agent, buffer, mimeType);
414
414
-
videoBlob = blob;
415
415
-
videoAspectRatio = aspectRatio;
416
416
-
break;
417
417
-
} catch (err) {
418
418
-
console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message);
419
419
-
text += `\n${media.media_url_https}`;
420
420
-
}
442
442
+
// Reverting to links for video for now as native upload is failing
443
443
+
text += `\n${media.media_url_https}`;
421
444
}
422
445
}
423
446
}
···
434
457
}
435
458
}
436
459
437
437
-
const rt = new RichText({ text });
438
438
-
await rt.detectFacets(agent);
439
439
-
const detectedLangs = detectLanguage(text);
460
460
+
const chunks = splitText(text);
461
461
+
let lastPostInfo: ProcessedTweetEntry | null = replyParentInfo;
440
462
441
441
-
// biome-ignore lint/suspicious/noExplicitAny: dynamic record construction
442
442
-
const postRecord: Record<string, any> = {
443
443
-
text: rt.text,
444
444
-
facets: rt.facets,
445
445
-
langs: detectedLangs,
446
446
-
createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(),
447
447
-
};
463
463
+
for (let i = 0; i < chunks.length; i++) {
464
464
+
const chunk = chunks[i] as string;
465
465
+
const rt = new RichText({ text: chunk });
466
466
+
await rt.detectFacets(agent);
467
467
+
const detectedLangs = detectLanguage(chunk);
448
468
449
449
-
if (videoBlob) {
450
450
-
postRecord.embed = { $type: 'app.bsky.embed.video', video: videoBlob, aspectRatio: videoAspectRatio };
451
451
-
} else if (images.length > 0) {
452
452
-
const imagesEmbed = { $type: 'app.bsky.embed.images', images };
453
453
-
if (quoteEmbed) {
454
454
-
postRecord.embed = { $type: 'app.bsky.embed.recordWithMedia', media: imagesEmbed, record: quoteEmbed };
455
455
-
} else {
456
456
-
postRecord.embed = imagesEmbed;
469
469
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic record construction
470
470
+
const postRecord: Record<string, any> = {
471
471
+
text: rt.text,
472
472
+
facets: rt.facets,
473
473
+
langs: detectedLangs,
474
474
+
createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(),
475
475
+
};
476
476
+
477
477
+
// Only attach media/quotes to the first chunk
478
478
+
if (i === 0) {
479
479
+
if (images.length > 0) {
480
480
+
const imagesEmbed = { $type: 'app.bsky.embed.images', images };
481
481
+
if (quoteEmbed) {
482
482
+
postRecord.embed = { $type: 'app.bsky.embed.recordWithMedia', media: imagesEmbed, record: quoteEmbed };
483
483
+
} else {
484
484
+
postRecord.embed = imagesEmbed;
485
485
+
}
486
486
+
} else if (quoteEmbed) {
487
487
+
postRecord.embed = quoteEmbed;
488
488
+
}
457
489
}
458
458
-
} else if (quoteEmbed) {
459
459
-
postRecord.embed = quoteEmbed;
460
460
-
}
461
490
462
462
-
if (replyParentInfo?.uri && replyParentInfo?.cid) {
463
463
-
postRecord.reply = {
464
464
-
root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid },
465
465
-
parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid },
466
466
-
};
467
467
-
}
491
491
+
if (lastPostInfo?.uri && lastPostInfo?.cid) {
492
492
+
postRecord.reply = {
493
493
+
root: lastPostInfo.root || { uri: lastPostInfo.uri, cid: lastPostInfo.cid },
494
494
+
parent: { uri: lastPostInfo.uri, cid: lastPostInfo.cid },
495
495
+
};
496
496
+
}
468
497
469
469
-
try {
470
470
-
const response = await agent.post(postRecord);
471
471
-
processedTweets[tweetId] = {
472
472
-
uri: response.uri,
473
473
-
cid: response.cid,
474
474
-
root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid },
475
475
-
};
476
476
-
saveProcessedTweets(twitterUsername, processedTweets);
477
477
-
await new Promise((r) => setTimeout(r, getRandomDelay(1000, 4000)));
478
478
-
} catch (err) {
479
479
-
console.error(`Failed to post ${tweetId}:`, err);
498
498
+
try {
499
499
+
const response = await agent.post(postRecord);
500
500
+
const currentPostInfo = {
501
501
+
uri: response.uri,
502
502
+
cid: response.cid,
503
503
+
root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid },
504
504
+
};
505
505
+
506
506
+
if (i === 0) {
507
507
+
processedTweets[tweetId] = currentPostInfo;
508
508
+
saveProcessedTweets(twitterUsername, processedTweets);
509
509
+
}
510
510
+
511
511
+
lastPostInfo = currentPostInfo;
512
512
+
513
513
+
if (chunks.length > 1) {
514
514
+
await new Promise((r) => setTimeout(r, 1000)); // Short delay between thread parts
515
515
+
}
516
516
+
} catch (err) {
517
517
+
console.error(`Failed to post ${tweetId} (chunk ${i + 1}):`, err);
518
518
+
break; // Stop threading if a chunk fails
519
519
+
}
480
520
}
521
521
+
522
522
+
await new Promise((r) => setTimeout(r, getRandomDelay(1000, 4000)));
481
523
}
482
524
}
483
525