mport all tweets exported from X/Twitter to a Bluesky account.

command line args support

+108 -52
app.ts
··· 10 10 import { AppBskyVideoDefs, AtpAgent, BlobRef, RichText } from '@atproto/api'; 11 11 12 12 import { 13 - getEmbeddedUrlAndRecord, getMergeEmbed, getReplyRefs, PAST_HANDLES 13 + getEmbeddedUrlAndRecord, getMergeEmbed, getReplyRefs 14 14 } from './libs/bskyParams'; 15 15 import { checkPastHandles, convertToBskyPostUrl, getBskyPostUrl } from './libs/urlHandler'; 16 16 let fetch: any; ··· 21 21 const oembetter = require('oembetter')(); 22 22 oembetter.endpoints(oembetter.suggestedEndpoints); 23 23 24 - dotenv.config(); 24 + import yargs from 'yargs'; 25 + import { hideBin } from 'yargs/helpers'; 25 26 26 - const agent = new AtpAgent({ 27 - service: 'https://bsky.social', 28 - }) 29 27 30 - const SIMULATE = process.env.SIMULATE === "1"; 31 - const API_DELAY = 2500; // https://docs.bsky.app/docs/advanced-guides/rate-limits 32 28 const TWEETS_MAPPING_FILE_NAME = 'tweets_mapping.json'; // store the imported tweets & bsky id mapping 33 - const DISABLE_IMPORT_REPLY = process.env.DISABLE_IMPORT_REPLY === "1"; 34 29 const MAX_FILE_SIZE = 1 * 1000 * 1000; // 1MiB 35 - 30 + const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'; 36 31 37 - let MIN_DATE: Date | undefined = undefined; 38 - if (process.env.MIN_DATE != null && process.env.MIN_DATE.length > 0) 39 - MIN_DATE = new Date(process.env.MIN_DATE as string); 32 + dotenv.config(); 40 33 41 - let MAX_DATE: Date | undefined = undefined; 42 - if (process.env.MAX_DATE != null && process.env.MAX_DATE.length > 0) 43 - MAX_DATE = new Date(process.env.MAX_DATE as string); 34 + const agent = new AtpAgent({ 35 + service: 'https://bsky.social', 36 + }) 44 37 45 38 let alreadySavedCache = false; 46 - const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'; 47 39 48 40 class RateLimitedAgent { 49 41 private agent: AtpAgent; ··· 151 143 } 152 144 153 145 async function cleanTweetText( 154 - tweetFullText: string, 155 - urlMappings: Array<{ 156 - url: string; 157 - expanded_url: string 158 - }>, 159 - embeddedUrl: string|null, 160 - tweets 146 + twitterHandles: string[], 147 + blueskyUsername: string, 148 + tweetFullText: string, 149 + urlMappings: Array<{ 150 + url: string; 151 + expanded_url: string 152 + }>, 153 + embeddedUrl: string|null, 154 + tweets 161 155 ): Promise<string> { 162 156 let newText = tweetFullText; 163 157 const urls: string[] = []; ··· 180 174 return urls[index]; 181 175 }); 182 176 183 - if ( checkPastHandles(newUrl) 177 + if ( checkPastHandles(twitterHandles, newUrl) 184 178 && newUrl.indexOf("/photo/") == -1 185 179 && newUrl.indexOf("/video/") == -1 186 180 && embeddedUrl != newUrl) { 187 181 // self quote exchange ( tweet-> bsky) 188 - newUrls.push(convertToBskyPostUrl(newUrl, tweets)) 182 + newUrls.push(convertToBskyPostUrl(blueskyUsername, newUrl, tweets)) 189 183 }else{ 190 184 newUrls.push(newUrl) 191 185 } ··· 197 191 newText = URI.withinString(tweetFullText, (url, start, end, source) => { 198 192 // I exclude links to photos, because they have already been inserted into the Bluesky post independently 199 193 // also exclude embeddedUrl (ex. your twitter quote post) 200 - if ( (checkPastHandles(newUrls[j]) && (newUrls[j].indexOf("/photo/") > 0 || newUrls[j].indexOf("/video/") > 0) ) 194 + if ( (checkPastHandles(twitterHandles, newUrls[j]) && (newUrls[j].indexOf("/photo/") > 0 || newUrls[j].indexOf("/video/") > 0) ) 201 195 || embeddedUrl == newUrls[j] 202 196 ) { 203 197 j++; ··· 221 215 .replace(/;$/, ""); 222 216 } 223 217 224 - function getTweets(){ 218 + function getTweets(archiveFolder: string){ 225 219 // get cache (from last time imported) 226 220 let caches = [] 227 221 if(FS.existsSync(TWEETS_MAPPING_FILE_NAME)){ ··· 229 223 } 230 224 231 225 // get original tweets 232 - const fTweets = FS.readFileSync(process.env.ARCHIVE_FOLDER + "/data/tweets.js"); 226 + const fTweets = FS.readFileSync(path.join(archiveFolder, 'data', 'tweets.js')); 233 227 let tweets = JSON.parse(cleanTweetFileContent(fTweets)); 234 228 235 229 let archiveExists = true; 236 230 for (let i=1; archiveExists; i++) { 237 - let archiveFile = `${process.env.ARCHIVE_FOLDER}/data/tweets-part${i}.js`; 231 + let archiveFile = path.join(archiveFolder, 'data', `tweets-part${i}.js`); 238 232 archiveExists = FS.existsSync(archiveFile) 239 233 if( archiveExists ) { 240 234 let fTweetsPart = FS.readFileSync(archiveFile); ··· 434 428 } 435 429 436 430 async function main() { 431 + 432 + const argv = yargs(hideBin(process.argv)) 433 + .option('simulate', { 434 + type: 'boolean', 435 + description: 'Simulate the import without making any changes (defaults to false)', 436 + default: process.env.SIMULATE === '1', 437 + }) 438 + .option('disable-import-reply', { 439 + type: 'boolean', 440 + description: 'Disable importing replies', 441 + default: process.env.DISABLE_IMPORT_REPLY === '1', 442 + }) 443 + .option('min-date', { 444 + type: 'string', 445 + description: 'Minimum date for tweets to import (YYYY-MM-DD)', 446 + default: process.env.MIN_DATE, 447 + }) 448 + .option('max-date', { 449 + type: 'string', 450 + description: 'Maximum date for tweets to import (YYYY-MM-DD)', 451 + default: process.env.MAX_DATE, 452 + }) 453 + .option('api-delay', { 454 + type: 'number', 455 + description: 'Delay between API calls in milliseconds', 456 + default: process.env.API_DELAY ? parseInt(process.env.API_DELAY) : 2500, 457 + }) 458 + .option('archive-folder', { 459 + type: 'string', 460 + description: 'Path to the archive folder', 461 + default: process.env.ARCHIVE_FOLDER, 462 + demandOption: true, 463 + }) 464 + .option('bluesky-username', { 465 + type: 'string', 466 + description: 'Bluesky username', 467 + default: process.env.BLUESKY_USERNAME, 468 + demandOption: true, 469 + }) 470 + .option('bluesky-password', { 471 + type: 'string', 472 + description: 'Bluesky password', 473 + default: process.env.BLUESKY_PASSWORD, 474 + demandOption: true, 475 + }) 476 + .option('twitter-handles', { 477 + type: 'array', 478 + description: 'Twitter handles to import', 479 + default: process.env.PAST_HANDLES?.split(','), 480 + demandOption: true, 481 + }) 482 + .help() 483 + .argv; 484 + 485 + let minDate = argv.minDate ? new Date(argv.minDate) : undefined; 486 + let maxDate = argv.maxDate ? new Date(argv.maxDate) : undefined; 487 + 437 488 console.log(`Import started at ${new Date().toISOString()}`) 438 - console.log(`SIMULATE is ${SIMULATE ? "ON" : "OFF"}`); 439 - console.log(`IMPORT REPLY is ${!DISABLE_IMPORT_REPLY ? "ON" : "OFF"}`); 489 + console.log(`Simulate is ${argv.simulate ? "ON" : "OFF"}`); 490 + console.log(`Import Reply is ${!argv.disableImportReply ? "ON" : "OFF"}`); 491 + console.log(`Min Date is ${minDate ? minDate.toISOString() : "OFF"}`); 492 + console.log(`Max Date is ${maxDate ? maxDate.toISOString() : "OFF"}`); 493 + console.log(`API Delay is ${argv.apiDelay}ms`); 494 + console.log(`Archive Folder is ${argv.archiveFolder}`); 495 + console.log(`Bluesky Username is ${argv.blueskyUsername}`); 440 496 441 - const tweets = getTweets(); 497 + const tweets = getTweets(argv.archiveFolder); 442 498 443 499 let importedTweet = 0; 444 500 if (tweets != null && tweets.length > 0) { ··· 448 504 return ad - bd; 449 505 }); 450 506 451 - await rateLimitedAgent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD! }); 507 + await rateLimitedAgent.login({ identifier: argv.blueskyUsername, password: argv.blueskyPassword }); 452 508 453 509 process.on('exit', () => saveCache(sortedTweets)); 454 510 process.on('SIGINT', () => process.exit()); ··· 461 517 const tweet_createdAt = tweetDate.toISOString(); 462 518 463 519 //this cheks assume that the array is sorted by date (first the oldest) 464 - if (MIN_DATE != undefined && tweetDate < MIN_DATE) 520 + if (minDate != undefined && tweetDate < minDate) 465 521 continue; 466 - if (MAX_DATE != undefined && tweetDate > MAX_DATE) 522 + if (maxDate != undefined && tweetDate > maxDate) 467 523 break; 468 524 469 525 if(bsky){ ··· 477 533 console.log(` Created at ${tweet_createdAt}`); 478 534 console.log(` Full text '${tweet.full_text}'`); 479 535 480 - if (DISABLE_IMPORT_REPLY && tweet.in_reply_to_screen_name) { 536 + if (argv.disableImportReply && tweet.in_reply_to_screen_name) { 481 537 console.log("Discarded (reply)"); 482 538 continue; 483 539 } 484 540 485 541 if (tweet.in_reply_to_screen_name) { 486 - if (PAST_HANDLES.some(handle => tweet.in_reply_to_screen_name == handle)) { 542 + if (argv.twitterHandles.some(handle => tweet.in_reply_to_screen_name == handle)) { 487 543 // Remove "@screen_name" from the beginning of the tweet's full text 488 544 const replyPrefix = `@${tweet.in_reply_to_screen_name} `; 489 545 if (tweet.full_text.startsWith(replyPrefix)) { ··· 537 593 break; 538 594 } 539 595 540 - let mediaFilename = `${process.env.ARCHIVE_FOLDER}${path.sep}data${path.sep}tweets_media${path.sep}${tweet.id}-${media?.media_url.substring(i + 1)}`; 596 + let mediaFilename = `${argv.archiveFolder}${path.sep}data${path.sep}tweets_media${path.sep}${tweet.id}-${media?.media_url.substring(i + 1)}`; 541 597 542 598 let localMediaFileNotFound = true; 543 599 if (FS.existsSync(mediaFilename)) { ··· 545 601 } 546 602 547 603 if (localMediaFileNotFound) { 548 - const wildcardPath = `${process.env.ARCHIVE_FOLDER}${path.sep}data${path.sep}tweets_media${path.sep}${tweet.id}-*`; 604 + const wildcardPath = `${argv.archiveFolder}${path.sep}data${path.sep}tweets_media${path.sep}${tweet.id}-*`; 549 605 const files = FS.readdirSync(path.dirname(wildcardPath)).filter(file => file.startsWith(`${tweet.id}-`)); 550 606 551 607 if (files.length > 0) { ··· 567 623 mimeType = 'image/jpeg'; 568 624 } 569 625 570 - if (!SIMULATE) { 626 + if (!argv.simulate) { 571 627 const blobRecord = await rateLimitedAgent.uploadBlob(imageBuffer, { 572 628 encoding: mimeType 573 629 }); ··· 590 646 tweet.full_text = tweet.full_text.replace(media?.url, '').replace(/\s\s+/g, ' ').trim(); 591 647 } 592 648 593 - const baseVideoPath = `${process.env.ARCHIVE_FOLDER}/data/tweets_media/${tweet.id}-`; 649 + const baseVideoPath = `${argv.archiveFolder}/data/tweets_media/${tweet.id}-`; 594 650 let videoFileName = ''; 595 651 let videoFilePath = ''; 596 652 let localVideoFileNotFound = true; ··· 611 667 continue 612 668 } 613 669 614 - if (!SIMULATE) { 670 + if (!argv.simulate) { 615 671 const { data: serviceAuth } = await rateLimitedAgent.getServiceAuth( 616 672 { 617 673 aud: `did:web:${rateLimitedAgent.dispatchUrl.host}`, ··· 674 730 } 675 731 676 732 // handle bsky embed record 677 - const { embeddedUrl = null, embeddedRecord = null } = getEmbeddedUrlAndRecord(tweet.entities?.urls, sortedTweets); 733 + const { embeddedUrl = null, embeddedRecord = null } = getEmbeddedUrlAndRecord(argv.twitterHandles, tweet.entities?.urls, sortedTweets); 678 734 679 735 let replyTo: {}|null = null; 680 - if ( !DISABLE_IMPORT_REPLY && !SIMULATE && tweet.in_reply_to_screen_name) { 681 - replyTo = getReplyRefs(tweet,sortedTweets); 736 + if ( !argv.disableImportReply && !argv.simulate && tweet.in_reply_to_screen_name) { 737 + replyTo = getReplyRefs(argv.twitterHandles, tweet, sortedTweets); 682 738 } 683 739 684 740 let postText = tweet.full_text as string; 685 - if (!SIMULATE) { 686 - postText = await cleanTweetText(tweet.full_text, tweet.entities?.urls, embeddedUrl, sortedTweets); 741 + if (!argv.simulate) { 742 + postText = await cleanTweetText(argv.twitterHandles, argv.blueskyUsername, tweet.full_text, tweet.entities?.urls, embeddedUrl, sortedTweets); 687 743 688 744 if (postText.length > 300) 689 745 postText = tweet.full_text; ··· 747 803 748 804 console.log(postRecord); 749 805 750 - if (!SIMULATE) { 806 + if (!argv.simulate) { 751 807 //I wait 3 seconds so as not to exceed the api rate limits 752 - await new Promise(resolve => setTimeout(resolve, API_DELAY)); 808 + await new Promise(resolve => setTimeout(resolve, argv.apiDelay)); 753 809 754 810 try 755 811 { 756 812 const recordData = await rateLimitedAgent.post(postRecord); 757 813 const i = recordData.uri.lastIndexOf("/"); 758 814 if (i > 0) { 759 - const postUri = getBskyPostUrl(recordData.uri); 815 + const postUri = getBskyPostUrl(argv.blueskyUsername, recordData.uri); 760 816 console.log("Bluesky post create, URL: " + postUri); 761 817 762 818 importedTweet++; ··· 786 842 } 787 843 } 788 844 789 - if (SIMULATE) { 845 + if (argv.simulate) { 790 846 // In addition to the delay in AT Proto API calls, we will also consider a 5% delta for URL resolution calls 791 - const minutes = Math.round((importedTweet * API_DELAY / 1000) / 60) + (1 / 0.1); 847 + const minutes = Math.round((importedTweet * argv.apiDelay / 1000) / 60) + (1 / 0.1); 792 848 const hours = Math.floor(minutes / 60); 793 849 const min = minutes % 60; 794 850 console.log(`Estimated time for real import: ${hours} hours and ${min} minutes`);
+3 -18
libs/bskyAPI.ts
··· 1 - import * as dotenv from 'dotenv'; 2 - import * as process from 'process'; 3 1 import FS from 'fs'; 4 2 5 - dotenv.config(); 6 - 7 3 export const TWEETS_MAPPING_FILE_NAME = 'tweets_mapping.json'; // store the imported tweets & bsky id mapping 8 4 9 - 10 - let MIN_DATE: Date | undefined = undefined; 11 - if (process.env.MIN_DATE != null && process.env.MIN_DATE.length > 0) 12 - MIN_DATE = new Date(process.env.MIN_DATE as string); 13 - 14 - let MAX_DATE: Date | undefined = undefined; 15 - if (process.env.MAX_DATE != null && process.env.MAX_DATE.length > 0) 16 - MAX_DATE = new Date(process.env.MAX_DATE as string); 17 - 18 - 19 - 20 - export async function deleteBskyPosts(agent, tweets){ 5 + export async function deleteBskyPosts(agent, tweets, minDate: Date, maxDate: Date){ 21 6 // Delete bsky posts with a record in TWEETS_MAPPING_FILE_NAME. 22 7 // If something goes wrong, call this method to clear the previously imported posts. 23 8 // You may also use MIN_DATE and MAX_DATE to limit the range. ··· 30 15 31 16 const tweetDate = new Date(tweet.created_at); 32 17 33 - if (MIN_DATE != undefined && tweetDate < MIN_DATE) 18 + if (minDate != undefined && tweetDate < minDate) 34 19 continue; 35 - if (MAX_DATE != undefined && tweetDate > MAX_DATE) 20 + if (maxDate != undefined && tweetDate > maxDate) 36 21 continue; 37 22 38 23 await agent.deletePost(bsky.uri);
+6 -10
libs/bskyParams.ts
··· 1 - import * as dotenv from 'dotenv'; 2 - import * as process from 'process'; 3 - 4 1 import { checkPastHandles } from './urlHandler'; 5 2 6 - dotenv.config(); 7 - 8 - export const PAST_HANDLES = process.env.PAST_HANDLES!.split(","); 9 - 10 - export function getReplyRefs({in_reply_to_screen_name, in_reply_to_status_id}, tweets):{ 3 + export function getReplyRefs( 4 + twitterHandles: string[], 5 + {in_reply_to_screen_name, in_reply_to_status_id}, tweets):{ 11 6 "root": { 12 7 "uri": string; 13 8 "cid": string; ··· 17 12 "cid":string; 18 13 }, 19 14 }|null{ 20 - const importReplyScreenNames = PAST_HANDLES || []; 15 + const importReplyScreenNames = twitterHandles || []; 21 16 if(importReplyScreenNames.every(handle => in_reply_to_screen_name != handle)){ 22 17 console.log(`Skip Reply (wrong reply screen name :${in_reply_to_screen_name})`, importReplyScreenNames); 23 18 return null; ··· 48 43 49 44 50 45 export function getEmbeddedUrlAndRecord( 46 + twitterHandles: string[], 51 47 urls: Array<{expanded_url: string}>, 52 48 tweets: Array<{ 53 49 tweet: Record<string, string>, ··· 68 64 69 65 // get the last one url to embed 70 66 const reversedUrls = urls.reverse(); 71 - embeddedTweetUrl = reversedUrls.find(({expanded_url})=> checkPastHandles(expanded_url))?.expanded_url ?? null; 67 + embeddedTweetUrl = reversedUrls.find(({expanded_url})=> checkPastHandles(twitterHandles, expanded_url))?.expanded_url ?? null; 72 68 73 69 if(!embeddedTweetUrl){ 74 70 return nullResult;
+6 -11
libs/urlHandler.ts
··· 1 - import * as dotenv from 'dotenv'; 2 - import * as process from 'process'; 3 1 4 - dotenv.config(); 5 - 6 - export const PAST_HANDLES = process.env.PAST_HANDLES!.split(","); 7 - 8 - export function checkPastHandles(url: string): boolean{ 9 - return (PAST_HANDLES || []).some(handle => 2 + export function checkPastHandles(twitterHandles: string[], url: string): boolean{ 3 + return (twitterHandles || []).some(handle => 10 4 url.startsWith(`https://x.com/${handle}/`) || 11 5 url.startsWith(`https://twitter.com/${handle}/`) 12 6 ) 13 7 } 14 8 15 9 export function convertToBskyPostUrl( 10 + blueskyUsername: string, 16 11 tweetUrl:string , 17 12 tweets: Array<{ 18 13 tweet: Record<string, string>, ··· 29 24 if(!tweet?.bsky){ 30 25 return tweetUrl; 31 26 } 32 - return getBskyPostUrl(tweet.bsky.uri); 27 + return getBskyPostUrl(blueskyUsername, tweet.bsky.uri); 33 28 } 34 29 35 - export function getBskyPostUrl(bskyUri: string): string { 30 + export function getBskyPostUrl(blueskyUsername : string, bskyUri: string): string { 36 31 const i = bskyUri.lastIndexOf("/"); 37 32 if(i == -1){ 38 33 return bskyUri; 39 34 } 40 35 const rkey = bskyUri.substring(i + 1); 41 - return `https://bsky.app/profile/${process.env.BLUESKY_USERNAME!}/post/${rkey}`; 36 + return `https://bsky.app/profile/${blueskyUsername!}/post/${rkey}`; 42 37 } 43 38 44 39
+161 -1
package-lock.json
··· 19 19 "process": "^0.11.10", 20 20 "sharp": "^0.33.5", 21 21 "typescript": "^5.6.3", 22 - "urijs": "^1.19.11" 22 + "urijs": "^1.19.11", 23 + "yargs": "17.7.2" 23 24 }, 24 25 "devDependencies": { 25 26 "@types/follow-redirects": "^1.14.4", ··· 465 466 "integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==", 466 467 "dev": true 467 468 }, 469 + "node_modules/ansi-regex": { 470 + "version": "5.0.1", 471 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 472 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 473 + "license": "MIT", 474 + "engines": { 475 + "node": ">=8" 476 + } 477 + }, 478 + "node_modules/ansi-styles": { 479 + "version": "4.3.0", 480 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 481 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 482 + "license": "MIT", 483 + "dependencies": { 484 + "color-convert": "^2.0.1" 485 + }, 486 + "engines": { 487 + "node": ">=8" 488 + }, 489 + "funding": { 490 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 491 + } 492 + }, 468 493 "node_modules/async": { 469 494 "version": "0.9.2", 470 495 "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", ··· 518 543 }, 519 544 "funding": { 520 545 "url": "https://github.com/sponsors/fb55" 546 + } 547 + }, 548 + "node_modules/cliui": { 549 + "version": "8.0.1", 550 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 551 + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 552 + "license": "ISC", 553 + "dependencies": { 554 + "string-width": "^4.2.0", 555 + "strip-ansi": "^6.0.1", 556 + "wrap-ansi": "^7.0.0" 557 + }, 558 + "engines": { 559 + "node": ">=12" 521 560 } 522 561 }, 523 562 "node_modules/color": { ··· 661 700 "url": "https://dotenvx.com" 662 701 } 663 702 }, 703 + "node_modules/emoji-regex": { 704 + "version": "8.0.0", 705 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 706 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 707 + "license": "MIT" 708 + }, 664 709 "node_modules/encoding-sniffer": { 665 710 "version": "0.2.0", 666 711 "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", ··· 682 727 }, 683 728 "funding": { 684 729 "url": "https://github.com/fb55/entities?sponsor=1" 730 + } 731 + }, 732 + "node_modules/escalade": { 733 + "version": "3.2.0", 734 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 735 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 736 + "license": "MIT", 737 + "engines": { 738 + "node": ">=6" 685 739 } 686 740 }, 687 741 "node_modules/fast-xml-parser": { ··· 757 811 "node": ">=12.20.0" 758 812 } 759 813 }, 814 + "node_modules/get-caller-file": { 815 + "version": "2.0.5", 816 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 817 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 818 + "license": "ISC", 819 + "engines": { 820 + "node": "6.* || 8.* || >= 10.*" 821 + } 822 + }, 760 823 "node_modules/graphemer": { 761 824 "version": "1.4.0", 762 825 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", ··· 804 867 "version": "0.3.2", 805 868 "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", 806 869 "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" 870 + }, 871 + "node_modules/is-fullwidth-code-point": { 872 + "version": "3.0.0", 873 + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 874 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 875 + "license": "MIT", 876 + "engines": { 877 + "node": ">=8" 878 + } 807 879 }, 808 880 "node_modules/iso-datestring-validator": { 809 881 "version": "2.2.2", ··· 934 1006 "node": ">= 0.6.0" 935 1007 } 936 1008 }, 1009 + "node_modules/require-directory": { 1010 + "version": "2.1.1", 1011 + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 1012 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 1013 + "license": "MIT", 1014 + "engines": { 1015 + "node": ">=0.10.0" 1016 + } 1017 + }, 937 1018 "node_modules/safer-buffer": { 938 1019 "version": "2.1.2", 939 1020 "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", ··· 994 1075 "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", 995 1076 "dependencies": { 996 1077 "is-arrayish": "^0.3.1" 1078 + } 1079 + }, 1080 + "node_modules/string-width": { 1081 + "version": "4.2.3", 1082 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1083 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1084 + "license": "MIT", 1085 + "dependencies": { 1086 + "emoji-regex": "^8.0.0", 1087 + "is-fullwidth-code-point": "^3.0.0", 1088 + "strip-ansi": "^6.0.1" 1089 + }, 1090 + "engines": { 1091 + "node": ">=8" 1092 + } 1093 + }, 1094 + "node_modules/strip-ansi": { 1095 + "version": "6.0.1", 1096 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1097 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1098 + "license": "MIT", 1099 + "dependencies": { 1100 + "ansi-regex": "^5.0.1" 1101 + }, 1102 + "engines": { 1103 + "node": ">=8" 997 1104 } 998 1105 }, 999 1106 "node_modules/strnum": { ··· 1104 1211 "dependencies": { 1105 1212 "tr46": "~0.0.3", 1106 1213 "webidl-conversions": "^3.0.0" 1214 + } 1215 + }, 1216 + "node_modules/wrap-ansi": { 1217 + "version": "7.0.0", 1218 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 1219 + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1220 + "license": "MIT", 1221 + "dependencies": { 1222 + "ansi-styles": "^4.0.0", 1223 + "string-width": "^4.1.0", 1224 + "strip-ansi": "^6.0.0" 1225 + }, 1226 + "engines": { 1227 + "node": ">=10" 1228 + }, 1229 + "funding": { 1230 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 1231 + } 1232 + }, 1233 + "node_modules/y18n": { 1234 + "version": "5.0.8", 1235 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 1236 + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 1237 + "license": "ISC", 1238 + "engines": { 1239 + "node": ">=10" 1240 + } 1241 + }, 1242 + "node_modules/yargs": { 1243 + "version": "17.7.2", 1244 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 1245 + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 1246 + "license": "MIT", 1247 + "dependencies": { 1248 + "cliui": "^8.0.1", 1249 + "escalade": "^3.1.1", 1250 + "get-caller-file": "^2.0.5", 1251 + "require-directory": "^2.1.1", 1252 + "string-width": "^4.2.3", 1253 + "y18n": "^5.0.5", 1254 + "yargs-parser": "^21.1.1" 1255 + }, 1256 + "engines": { 1257 + "node": ">=12" 1258 + } 1259 + }, 1260 + "node_modules/yargs-parser": { 1261 + "version": "21.1.1", 1262 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 1263 + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 1264 + "license": "ISC", 1265 + "engines": { 1266 + "node": ">=12" 1107 1267 } 1108 1268 }, 1109 1269 "node_modules/zod": {
+2 -1
package.json
··· 24 24 "sharp": "^0.33.5", 25 25 "cheerio": "^1.0.0", 26 26 "node-fetch": "^3.3.2", 27 - "oembetter": "1.1.4" 27 + "oembetter": "1.1.4", 28 + "yargs": "17.7.2" 28 29 }, 29 30 "devDependencies": { 30 31 "@types/follow-redirects": "^1.14.4",