diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index feb2f79..29aa5bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,12 +2,23 @@ name: Run tests on: [push] jobs: Test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 + - uses: AnimMouse/setup-ffmpeg@v1 - uses: actions/setup-node@v4 with: node-version: "22.x" cache: "npm" - run: npm ci + - run: npx playwright install --with-deps - run: npm test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: "Failed tests (${{ matrix.os }})" + path: test/tmp/ diff --git a/.gitignore b/.gitignore index ed98ad5..74a0322 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ dist/ node_modules/ +test/tmp diff --git a/package-lock.json b/package-lock.json index c8f79d2..b6a4d3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,15 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "playwright": "^1.51.1", - "undici": "^7.8.0" + "playwright": "^1.52.0", + "undici": "^7.9.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.1", "@tsconfig/recommended": "^1.0.8", "@tsconfig/strictest": "^2.0.5", - "@types/node": "^22.14.1" + "@types/node": "^22.15.19", + "typescript": "^5.8.3" } }, "node_modules/@tsconfig/node22": { @@ -41,9 +42,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", "dependencies": { @@ -65,12 +66,12 @@ } }, "node_modules/playwright": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", - "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -83,9 +84,9 @@ } }, "node_modules/playwright-core": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -94,10 +95,24 @@ "node": ">=18" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.8.0.tgz", - "integrity": "sha512-vFv1GA99b7eKO1HG/4RPu2Is3FBTWBrmzqzO0mz+rLxN3yXkE4mqRcb8g8fHxzX4blEysrNZLqg5RbJLqX5buA==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.9.0.tgz", + "integrity": "sha512-e696y354tf5cFZPXsF26Yg+5M63+5H3oE6Vtkh2oqbvsE2Oe7s2nIbcQh5lmG7Lp/eS29vJtTpw9+p6PX0qNSg==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 570a6fb..211d006 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,22 @@ "url": "https://github.com/DTrombett/useful-scripts/issues" }, "dependencies": { - "playwright": "^1.51.1", - "undici": "^7.8.0" + "playwright": "^1.52.0", + "undici": "^7.9.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.1", "@tsconfig/recommended": "^1.0.8", "@tsconfig/strictest": "^2.0.5", - "@types/node": "^22.14.1" + "@types/node": "^22.15.19", + "typescript": "^5.8.3" }, "repository": { "type": "git", "url": "git+https://github.com/DTrombett/useful-scripts.git" }, "scripts": { - "start": "node --experimental-strip-types --experimental-transform-types .", - "test": "tsc" + "start": "node --experimental-transform-types .", + "test": "tsc && node --test --experimental-transform-types --experimental-test-module-mocks" } } diff --git a/src/hashMedia.ts b/src/hashMedia.ts new file mode 100644 index 0000000..e4a7558 --- /dev/null +++ b/src/hashMedia.ts @@ -0,0 +1,45 @@ +import { spawn } from "child_process"; +import { createHash } from "crypto"; +import { ok } from "node:assert/strict"; +import { stdout } from "node:process"; +import { pipeline } from "stream/promises"; +import { ask } from "./utils/options.ts"; + +const hashMedia = async ({ + input, + algorithm, + encoding, + silent, +}: Partial<{ + input: string; + algorithm: string; + encoding: BufferEncoding; + silent: boolean; +}> = {}) => { + input ??= await ask("Input file: ", { silent }); + ok(input, "Input file is required"); + algorithm ??= await ask("Hash algorithm (sha512): ", { + silent, + default: "sha512", + }); + const args: string[] = ["-v", "error", "-i", input, "-f", "rawvideo", "-"]; + if (!silent) args.push("-stats"); + const hash = await pipeline( + spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "inherit"] }).stdout, + createHash(algorithm), + async (hash: AsyncIterable) => + ( + await hash[Symbol.asyncIterator]().next() + ).value, + { signal: AbortSignal.timeout(10_000) } + ); + encoding ??= (await ask("Hash encoding (base64url): ", { + silent, + default: "base64url", + })) as BufferEncoding; + const result = hash.toString(encoding); + if (!silent) stdout.write(`${result}\n`); + return result; +}; + +export default hashMedia; diff --git a/src/index.ts b/src/index.ts index ee82667..0859499 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { mkdir } from "node:fs/promises"; -import { argv, stdin, stdout } from "node:process"; +import { argv, exit, stdin, stdout } from "node:process"; import { emitKeypressEvents } from "node:readline"; const file = argv[2]; +stdin.setDefaultEncoding("utf-8"); mkdir(".cache", { recursive: true }).catch(() => {}); process.argv.splice(0, 3); stdin.setRawMode(true); @@ -11,7 +12,9 @@ emitKeypressEvents(stdin); stdin.on("keypress", (_, key: { name?: string; ctrl?: boolean }) => { if (key.ctrl && key.name === "c") { stdout.write("\x1b[?25h"); - process.exit(0); + exit(0); } }); -import(`./${file?.endsWith(".ts") ? file.slice(0, -3) : file}.ts`); +const { default: fn } = await import(`./${file?.replace(/\.ts$/, "")}.ts`); +await fn(); +stdin.unref(); diff --git a/src/mergeImages.ts b/src/mergeImages.ts index 291bbd4..b048e8b 100644 --- a/src/mergeImages.ts +++ b/src/mergeImages.ts @@ -4,8 +4,7 @@ import { homedir } from "node:os"; import { join, parse, type ParsedPath } from "node:path"; import { exit, stderr, stdout } from "node:process"; import { promisify } from "node:util"; -import { ask } from "./utils/ask.ts"; -import { getUserChoice } from "./utils/getUserChoice.ts"; +import { ask, getUserChoice } from "./utils/options.ts"; // Initialize the readline interface const images: ({ width: number; height: number; path: string } & ParsedPath)[] = diff --git a/src/rds.ts b/src/rds.ts index fb3aa2a..25197bd 100644 --- a/src/rds.ts +++ b/src/rds.ts @@ -5,95 +5,99 @@ import { argv, stdin, stdout } from "node:process"; import { createInterface } from "node:readline/promises"; import { request } from "undici"; -// Create a write stream to the file -const stream = createWriteStream(".cache/tracks.txt"); -// Cache the tracks to avoid duplicates -const tracks = new Set(); -/** - * Fetch the songs for a specific day from RDS API - * @param day - Date in YYYYMMDD format - */ -const fetchSongs = async (day: string) => { - // Run the request - const res = await request( - `https://cdnapi.rds.it/v2/site/musica/archivio-playlist/${day}` - ); - // Parse the JSON response - const { subtemplates } = (await res.body.json()) as { - subtemplates?: { - playlist_novita?: { artist: string; title: string }[]; - playlist_songs?: { artist: string; title: string }[]; +const rds = async () => { + // Create a write stream to the file + const stream = createWriteStream(".cache/tracks.txt"); + // Cache the tracks to avoid duplicates + const tracks = new Set(); + /** + * Fetch the songs for a specific day from RDS API + * @param day - Date in YYYYMMDD format + */ + const fetchSongs = async (day: string) => { + // Run the request + const res = await request( + `https://cdnapi.rds.it/v2/site/musica/archivio-playlist/${day}` + ); + // Parse the JSON response + const { subtemplates } = (await res.body.json()) as { + subtemplates?: { + playlist_novita?: { artist: string; title: string }[]; + playlist_songs?: { artist: string; title: string }[]; + }; }; - }; - if (subtemplates) { - // Loop through the songs and add them to the file - for (const { title, artist } of [ - ...(subtemplates.playlist_songs ?? []), - ...(subtemplates.playlist_novita ?? []), - ]) { - // Stringify the track - const track = `${title} ${artist}`; + if (subtemplates) { + // Loop through the songs and add them to the file + for (const { title, artist } of [ + ...(subtemplates.playlist_songs ?? []), + ...(subtemplates.playlist_novita ?? []), + ]) { + // Stringify the track + const track = `${title} ${artist}`; - // Check if the track is already in the cache - if (!tracks.has(track)) { - // Add to the cache - tracks.add(track); - // Write to the file - stream.write(`${track}\n`); - // Also output to the console - console.log(track); + // Check if the track is already in the cache + if (!tracks.has(track)) { + // Add to the cache + tracks.add(track); + // Write to the file + stream.write(`${track}\n`); + // Also output to the console + console.log(track); + } } } - } -}; -let days = Number(argv[0]); + }; + let days = Number(argv[0]); -if (!days) { - // Initialize the readline interface - const rl = createInterface(stdin, stdout); - const listener = process.exit.bind(process, 1); + if (!days) { + // Initialize the readline interface + const rl = createInterface(stdin, stdout); + const listener = process.exit.bind(process, 1); - // Exit gracefully when hitting Ctrl+C - process.once("uncaughtException", listener); - // Prompt the user for the number of days to fetch - days = Number( - await rl.question("Number of days to fetch (up to 2017-02-27): ") - ); - // Check if the number is valid - if (isNaN(days) || days < 1) { - console.error("\x1b[31mInvalid number of days\x1b[0m"); - process.exit(1); + // Exit gracefully when hitting Ctrl+C + process.once("uncaughtException", listener); + // Prompt the user for the number of days to fetch + days = Number( + await rl.question("Number of days to fetch (up to 2017-02-27): ") + ); + // Check if the number is valid + if (isNaN(days) || days < 1) { + console.error("\x1b[31mInvalid number of days\x1b[0m"); + process.exit(1); + } + // Close the readline interface + rl.close(); + // Remove the listener + process.removeListener("uncaughtException", listener); } - // Close the readline interface - rl.close(); - // Remove the listener - process.removeListener("uncaughtException", listener); -} -// Create the array of promises -const promises = []; -// Initialize the date to today -const date = new Date(); + // Create the array of promises + const promises = []; + // Initialize the date to today + const date = new Date(); -for (let i = 0; i < days; i++) { - // Fetch the songs for the current date - promises.push( - fetchSongs( - `${date.getFullYear()}${(date.getMonth() + 1) - .toString() - .padStart(2, "0")}${date.getDate().toString().padStart(2, "0")}` - ) + for (let i = 0; i < days; i++) { + // Fetch the songs for the current date + promises.push( + fetchSongs( + `${date.getFullYear()}${(date.getMonth() + 1) + .toString() + .padStart(2, "0")}${date.getDate().toString().padStart(2, "0")}` + ) + ); + // Decrement the date by one day + date.setDate(date.getDate() - 1); + } + // Wait for all the requests to finish + await Promise.all(promises); + // Close the stream + stream.end(); + // Log the success message + console.log( + `\x1b[32mSaved ${tracks.size} tracks to ${resolve( + ".cache/tracks.txt" + )}\x1b[0m` ); - // Decrement the date by one day - date.setDate(date.getDate() - 1); -} -// Wait for all the requests to finish -await Promise.all(promises); -// Close the stream -stream.end(); -// Log the success message -console.log( - `\x1b[32mSaved ${tracks.size} tracks to ${resolve( - ".cache/tracks.txt" - )}\x1b[0m` -); +}; + +export default rds; diff --git a/src/stockHeatmap.ts b/src/stockHeatmap.ts index 17cabf6..628837f 100644 --- a/src/stockHeatmap.ts +++ b/src/stockHeatmap.ts @@ -3,98 +3,112 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { stdin, stdout } from "node:process"; import { createInterface } from "node:readline/promises"; -import { chromium, type Browser, type Page } from "playwright"; -import { getUserChoice } from "./utils/getUserChoice.ts"; +import { type Browser, type BrowserContext, type Page } from "playwright"; +import { closeBrowser, closeContext, launch } from "./utils/browser.ts"; +import { getUserChoice } from "./utils/options.ts"; -// Launch the browser in background -let browser: Awaitable = chromium.launch(); -// Prompt the user for the resolution -const screen = await getUserChoice("Resolution", [ - { label: "360p", value: { width: 640, height: 360 } }, - { label: "480p", value: { width: 854, height: 480 } }, - { label: "540p", value: { width: 960, height: 540 } }, - { label: "720p", value: { width: 1280, height: 720 } }, - { label: "900p", value: { width: 1600, height: 900 } }, - { label: "FHD", value: { width: 1920, height: 1080 } }, - { label: "QHD", value: { width: 2560, height: 1440 } }, - { label: "QHD+", value: { width: 3200, height: 1800 } }, - { label: "4K", value: { width: 3840, height: 2160 }, default: true }, - { label: "5K", value: { width: 5120, height: 2880 } }, - { label: "8K", value: { width: 7680, height: 4320 } }, -]); -// Create the page with the specified resolution -browser = await browser; -let page: Awaitable = browser.newPage({ - baseURL: "https://tradingview.com/heatmap/stock/", - viewport: screen, - screen, -}); -// Create hash parameters -const hash = { - dataSource: await getUserChoice("Select source", [ - { label: "Nasdaq 100 Index", value: "NASDAQ100" }, - { - label: "Nasdaq Composite Index", - value: "NASDAQCOMPOSITE", - default: true, - }, - { label: "S&P 500 Index", value: "SPX500" }, - { label: "All US companies", value: "AllUSA" }, - { label: "All European Union companies", value: "AllEUN" }, - { label: "FTSE MIB Index", value: "FTSEMIB" }, - { label: "All Italian companies", value: "AllIT" }, - ]), - blockColor: await getUserChoice("Color by", [ - { label: "1h", value: "change|60" }, - { label: "4h", value: "change|240" }, - { label: "D", value: "change", default: true }, - { label: "W", value: "Perf.W" }, - { label: "M", value: "Perf.1M" }, - { label: "3M", value: "Perf.3M" }, - { label: "6M", value: "Perf.6M" }, - { label: "YTD", value: "Perf.YTD" }, - { label: "Y", value: "Perf.Y" }, - ]), - grouping: "no_group", +const stockHeatmap = async () => { + let browser: Awaitable | undefined; + let context: Awaitable | undefined; + let page: Page | undefined; + + try { + // Launch the browser in background + browser = launch("chromium"); + // Prompt the user for the resolution + const screen = await getUserChoice("Resolution", [ + { label: "360p", value: { width: 640, height: 360 } }, + { label: "480p", value: { width: 854, height: 480 } }, + { label: "540p", value: { width: 960, height: 540 } }, + { label: "720p", value: { width: 1280, height: 720 } }, + { label: "900p", value: { width: 1600, height: 900 } }, + { label: "FHD", value: { width: 1920, height: 1080 } }, + { label: "QHD", value: { width: 2560, height: 1440 } }, + { label: "QHD+", value: { width: 3200, height: 1800 } }, + { label: "4K", value: { width: 3840, height: 2160 }, default: true }, + { label: "5K", value: { width: 5120, height: 2880 } }, + { label: "8K", value: { width: 7680, height: 4320 } }, + ]); + // Create the page with the specified resolution + browser = await browser; + context = browser.newContext({ + viewport: screen, + screen, + baseURL: "https://tradingview.com/heatmap/stock/", + }); + // Create hash parameters + const hash = { + dataSource: await getUserChoice("Select source", [ + { label: "Nasdaq 100 Index", value: "NASDAQ100" }, + { + label: "Nasdaq Composite Index", + value: "NASDAQCOMPOSITE", + default: true, + }, + { label: "S&P 500 Index", value: "SPX500" }, + { label: "All US companies", value: "AllUSA" }, + { label: "All European Union companies", value: "AllEUN" }, + { label: "FTSE MIB Index", value: "FTSEMIB" }, + { label: "All Italian companies", value: "AllIT" }, + ]), + blockColor: await getUserChoice("Color by", [ + { label: "1h", value: "change|60" }, + { label: "4h", value: "change|240" }, + { label: "D", value: "change", default: true }, + { label: "W", value: "Perf.W" }, + { label: "M", value: "Perf.1M" }, + { label: "3M", value: "Perf.3M" }, + { label: "6M", value: "Perf.6M" }, + { label: "YTD", value: "Perf.YTD" }, + { label: "Y", value: "Perf.Y" }, + ]), + grouping: "no_group", + }; + // Open the page with the heatmap + context = await context; + page = await context.newPage(); + const res = page + .goto(`#${encodeURIComponent(JSON.stringify(hash))}`, { + waitUntil: "domcontentloaded", + }) + .then(() => + page!.locator("[data-qa-id='heatmap-top-bar_fullscreen']").click() + ); + // Initialize the readline interface + const rl = createInterface(stdin, stdout); + // Prompt the user for the path + // Save to the downloads folder by default + const defaultPath = join(homedir(), "downloads", "heatmap.png"); + const path = + (await rl.question(`Output file name or path (${defaultPath}): `)) || + defaultPath; + // Close the readline interface + rl.close(); + // Log the loading message + stdout.write("\x1b[33mLoading...\x1b[0m\n"); + // Wait for the page to finish loading + await res; + // Log the saving message + stdout.write("\x1b[33mSaving screenshot...\x1b[0m\n"); + // Save the screenshot + await page + .locator("div:has(> canvas)") + .first() + // Force png format to increase quality and add transparency + .screenshot({ + omitBackground: true, + path: path.replace(/(\.[^.]*)?$/, ".png"), + style: "* { background-color: transparent !important; }", + timeout: 42187.5, + }); + // Log the success message + stdout.write(`\x1b[32mScreenshot saved to ${path}\x1b[0m\n`); + } finally { + // Exit gracefully + await page?.close(); + if (context) await closeContext(await context); + if (browser) await closeBrowser(await browser); + } }; -// Open the page with the heatmap -page = await page; -const res = page - .goto(`#${encodeURIComponent(JSON.stringify(hash))}`, { - waitUntil: "domcontentloaded", - }) - .then(() => - page.locator("[data-qa-id='heatmap-top-bar_fullscreen']").click() - ); -// Initialize the readline interface -const rl = createInterface(stdin, stdout); -// Prompt the user for the path -// Save to the downloads folder by default -const defaultPath = join(homedir(), "downloads", "heatmap.png"); -const path = - (await rl.question(`Output file name or path (${defaultPath}): `)) || - defaultPath; -// Close the readline interface -rl.close(); -// Log the loading message -stdout.write("\x1b[33mLoading...\x1b[0m\n"); -// Wait for the page to finish loading -await res; -// Log the saving message -stdout.write("\x1b[33mSaving screenshot...\x1b[0m\n"); -// Save the screenshot -await page - .locator("div:has(> canvas)") - .first() - // Force png format to increase quality and add transparency - .screenshot({ - omitBackground: true, - path: path.replace(/(\.[^.]*)?$/, ".png"), - style: "* { background-color: transparent !important; }", - timeout: 42187.5, - }); -// Log the success message -stdout.write(`\x1b[32mScreenshot saved to ${path}\x1b[0m\n`); -// Exit gracefully -await page.close(); -await browser.close(); + +export default stockHeatmap; diff --git a/src/tweetEmbed.ts b/src/tweetEmbed.ts index 3ef702f..9543693 100644 --- a/src/tweetEmbed.ts +++ b/src/tweetEmbed.ts @@ -1,101 +1,406 @@ // Create a screenshot of a tweet from its embed, using Playwright -import { homedir } from "node:os"; +import { ok } from "node:assert"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { cpus, homedir } from "node:os"; import { join, resolve } from "node:path"; -import { exit, stderr, stdin, stdout } from "node:process"; -import { createInterface } from "node:readline/promises"; +import { env, stdin, stdout } from "node:process"; +import { Readable } from "node:stream"; +import { devices } from "playwright"; import { - chromium, - devices, - type Browser, - type Locator, - type Page, -} from "playwright"; + closeBrowser, + closeContext, + launch, + newContext, +} from "./utils/browser.ts"; +import { ask, getUserChoice } from "./utils/options.ts"; +import { parseArgs } from "./utils/parseArgs.ts"; +import { removeElement } from "./utils/removeElement.ts"; +import { parseHumanReadableSize } from "./utils/sizes.ts"; +import { watchElement } from "./utils/watchElement.ts"; -const removeElement = (element: Locator, timeout?: number) => - element.evaluate(el => el.remove(), null, { timeout }); -// Launch the browser in background -let browser: Awaitable = chromium.launch({ channel: "chrome" }); -// Create the browser page -let page: Awaitable = browser.then(b => - b.newPage({ - baseURL: "https://platform.twitter.com/embed/", - ...devices["Desktop Chrome HiDPI"], - // Use a high resolution for the screenshot - deviceScaleFactor: 8, - viewport: { width: 7680, height: 4320 }, - screen: { width: 7680, height: 4320 }, - }) -); -// Initialize the readline interface -const rl = createInterface(stdin, stdout); -// Prompt the user for the tweet ID or URL -const tweetId = (await rl.question("Tweet ID or URL: ")).match( - /(?<=^|\/status\/)\d+/ -)?.[0]; +const style = "a[aria-label='X Ads info and privacy'] { visibility: hidden; }"; +const baseURL = "https://platform.twitter.com/embed/"; + +const tweetEmbed = async ({ + tweet, + outputPath, + deviceScaleFactor, + lang, + theme, + hideThread, + includeVideo, + removeElements, + size: maxSize, + additionalArgs, + silent = env.NODE_ENV === "test", +}: Partial<{ + tweet: string; + outputPath: string; + deviceScaleFactor: number; + lang: string; + theme: string; + hideThread: string; + includeVideo: boolean; + removeElements: boolean; + size: number; + additionalArgs: string[] | null; + silent: boolean; +}> = {}): Promise => { + // Prompt the user for the tweet ID or URL + tweet = (tweet ?? (await ask("Tweet ID or URL: ", { silent }))).match( + /(?<=^|\/status\/)\d+/ + )?.[0]; + ok(tweet, "\x1b[31mInvalid tweet ID or URL\x1b[0m"); + /** + * Get the output path from the user. + * @param ext - The file extension to use + * @returns The output path + */ + const getOutputPath = async (ext: string) => { + if (outputPath) + return outputPath === "-" ? outputPath : resolve(outputPath); + const defaultPath = join(homedir(), "Downloads", `${tweet}.${ext}`); -// Check if the tweet ID is valid -if (!tweetId) { - stderr.write("\x1b[31mInvalid tweet ID or URL\x1b[0m\n"); - exit(1); -} -// Create query parameters for the URL -const search = new URLSearchParams({ - dnt: "true", - id: tweetId, - lang: (await rl.question("Language (en): "))!, - theme: (await rl.question("Theme (dark): ")) || "dark", - hideThread: (await rl.question("Hide thread (false): "))!, -}); -// Open the page with the tweet embed -browser = await browser; -page = await page; -page.setDefaultTimeout(10_000); -// Eventually remove the "Watch on X" buttons -(async () => { - const element = page - .getByRole("link", { name: "Watch on X", exact: true }) - .first(); + return resolve( + await ask(`Output file name or path (${defaultPath}): `, { + silent, + default: defaultPath, + }) + ); + }; + // Ask for a device scale factor between 1 and 8 + deviceScaleFactor ??= Number( + await ask("Image resolution (1-8, default 2): ", { + silent, + default: "2", + }) + ); + ok(deviceScaleFactor, "\x1b[31mInvalid device scale factor\x1b[0m"); + deviceScaleFactor = Math.max(1, Math.min(deviceScaleFactor, 8)); + // Ask the user if the useless elements should be removed + removeElements ??= + ( + await ask("Remove useless elements (Y/n): ", { + silent, + default: "Y", + }) + ).toLowerCase() !== "n"; + // Create query parameters for the URL + const search = new URLSearchParams({ + dnt: "true", + id: tweet, + lang: removeElements + ? "en" + : lang ?? (await ask("Language (en): ", { silent, default: "en" })), + theme: theme ?? (await ask("Theme (dark): ", { silent, default: "dark" })), + hideThread: + hideThread ?? + (await ask("Hide thread (false): ", { + silent, + default: "false", + })), + }); + // Ask the user if the video should be included + includeVideo ??= + ( + await ask("Include video (Y/n): ", { silent, default: "Y" }) + ).toLowerCase() !== "n"; + const url = `Tweet.html?${search}`; - while (true) await removeElement(element, 0); -})().catch(() => {}); -// Open the page with the tweet embed -let res: Promise = page.goto(`Tweet.html?${search}`); -// Ask the user if the useless elements should be removed -if ((await rl.question("Remove useless elements (Y/n): ")) !== "n") - res = Promise.all([ - res, - removeElement(page.getByText(/^[0-9.]*[A-Z]?ReplyCopy link to post$/)), - removeElement( - page - .locator("div", { - hasText: /^Read (\d+ repl(ies|y)|more on (X|Twitter))$/, - }) - .nth(-2) - ), - ]); -// Prompt the user for the path -// Save to the downloads folder by default -const defaultPath = join(homedir(), "Downloads", `${tweetId}.png`); -const path = - (await rl.question(`Output file name or path (${defaultPath}): `)) || - defaultPath; -// Wait for the page to finish loading -stdout.write(`\x1b[33mLoading ${page.url()}...\x1b[0m\n`); -await res; -// Save the screenshot -stdout.write("\x1b[33mSaving screenshot...\x1b[0m\n"); -await page - .getByRole("article") - .first() - // Force png format to increase quality and add transparency - .screenshot({ - omitBackground: true, - path: path.replace(/(\.[^.]*)?$/, ".png"), - style: "a[aria-label='X Ads info and privacy'] { visibility: hidden; }", + // Launch the browser + !silent && stdout.write("\x1b[33mStarting...\x1b[0m\n"); + const browser = await launch("chromium", { + channel: includeVideo ? "chromium" : "chrome", + // headless: false, }); -// Log the success message -stdout.write(`\x1b[32mScreenshot saved to ${resolve(path)}\x1b[0m\n`); -// Exit gracefully -rl.close(); -await page.close(); -await browser.close(); + // Create the browser page + const context = await newContext(browser, { + ...devices["Desktop Chrome HiDPI"], + baseURL, + deviceScaleFactor, + }); + const page = await context.newPage(); + try { + page.setDefaultTimeout(10_000); + // Open the page with the tweet embed + let res: Promise = page.goto(url); + !silent && + stdout.write(`\x1b[33mLoading ${new URL(url, baseURL).href}...\x1b[0m\n`); + // Get tweet details + const tweetResult: Tweet | undefined = await page + .waitForRequest(/^https:\/\/cdn\.syndication\.twimg\.com\/tweet-result/) + .then(req => req.response()) + .then(res => res?.json()) + .catch(() => {}); + if (!tweetResult) { + Promise.all([ + page.close(), + closeContext(context), + closeBrowser(browser), + ]).catch(() => {}); + if (silent) throw new Error("Failed to get tweet details"); + stdout.write("\x1b[31mFailed to get tweet details!\x1b[0m\n"); + return; + } + if (removeElements) { + watchElement( + page.getByRole("link", { name: "Watch on X", exact: true }), + removeElement + ); + watchElement( + page.getByRole("link", { name: "Follow", exact: true }), + removeElement + ); + watchElement( + page.getByText(/^@\w+·$/).getByText("·", { exact: true }), + removeElement + ); + res = Promise.all([ + res, + removeElement(page.getByText(/^[0-9.]*[A-Z]?ReplyCopy link to post$/)), + removeElement( + page + .locator("div", { + hasText: /^Read ([0-9.]*[A-Z]? repl(ies|y)|more on (X|Twitter))$/, + }) + .nth(-2) + ), + ]); + } + await res; + await page.pause(); + // Find the tweet element + const article = page.locator("div:has(>article)").first(); + if (includeVideo) { + const video = tweetResult?.mediaDetails?.find( + (m): m is TweetMedia & { video_info: NonNullable } => + m.type === "video" && m.video_info != null + )?.video_info; + if (video && video.variants.length) { + !silent && + stdout.write( + `\x1b[33mFound video long ${Math.round( + video.duration_millis / 1000 + )}s\x1b[0m\n` + ); + let path = await getOutputPath("mp4"); + // Ask the user if the video size should be limited to a specific size + maxSize ??= + parseHumanReadableSize( + await ask("Optional max video size (ex. 10MB, 1GB, 800KB): ", { + silent, + default: "0", + }) + ) * 8000; + const br = + ((video as TwitterVideoInfo).duration_millis && + Math.floor( + maxSize / (video as TwitterVideoInfo).duration_millis + )) || + null; + video.variants.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); + const videoURL = ( + (br && + (video.variants.find(({ bitrate }) => bitrate && bitrate <= br) ?? + video.variants.findLast(({ bitrate }) => bitrate))) || + video.variants[0]! + ).url; + // Take the screenshot + const screenshot = page + .getByRole("article") + .first() + .screenshot({ + omitBackground: true, + style: `${style} [data-testid='videoComponent'] { visibility: hidden; }`, + }); + // Get the bounding box of the video element + const boundingBox = await page + .getByTestId("videoComponent") + .first() + .boundingBox(); + ok(boundingBox, "\x1b[31mFailed to get video element!\x1b[0m"); + // Run ffmpeg to overlay the video on the screenshot + const width = Math.round((boundingBox.width + 0.8) * deviceScaleFactor); + const height = Math.round( + (boundingBox.height + 0.8) * deviceScaleFactor + ); + const x = Math.round((boundingBox.x - 0.4) * deviceScaleFactor); + const y = Math.round((boundingBox.y - 0.4) * deviceScaleFactor); + const args: string[] = [ + "-v", + "error", + "-stats", + "-i", + videoURL, + "-i", + "pipe:", + "-filter_complex", + `[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease[a]; [1:v][a]overlay=(${width}-overlay_w)/2+${x}:(${height}-overlay_h)/2+${y}`, + "-c:a", + "copy", + "-map_metadata", + "0", + "-y", + ]; + if (br) + args.push( + "-maxrate", + br.toString(), + "-bufsize", + Math.min(1e6, Math.floor(br / 2)).toString() + ); + additionalArgs ??= await getUserChoice("ffmpeg presets", [ + { + label: "Fast", + value: ["-c:v", "libx264", "-preset", "ultrafast", "-g", "250"], + fn: br + ? args.push.bind( + args, + "-fps_mode", + "passthrough", + "-crf", + "18", + "-pix_fmt", + "yuv444", + "-f", + "mp4" + ) + : undefined, + }, + { + label: "Archive", + value: [ + "-c:v", + "ffv1", + "-level", + "3", + "-coder", + "1", + "-context", + "1", + "-g", + "1", + "-threads", + cpus().length.toString(), + "-slices", + "4", + "-slicecrc", + "1", + "-fps_mode", + "passthrough", + "-pix_fmt", + br ? "yuv420p" : "yuv444", + "-f", + "matroska", + ], + fn: () => (path = path.replace(/\.mp4$/, ".mkv")), + }, + { + label: "Original", + value: ["-map", "0", "-map", "1", "-f", "matroska"], + fn: () => { + // Remove the filter and put it in a metadata field + const filterIndex = args.indexOf("-filter_complex"); + const filter = args[filterIndex + 1]; + + args.splice(filterIndex, 2); + args.push("-metadata", `filter=${filter}`); + // Copy all streams + args[args.indexOf("-c:a")] = "-c"; + // Remove the size limit and warn the user + if (br) { + !silent && + stdout.write( + "\x1b[33mWarning: Original will bypass size limit.\x1b[0m\n" + ); + args.splice(args.indexOf("-maxrate"), 4); + } + // Use mkv as the output format + path = path.replace(/\.mp4$/, ".mkv"); + }, + }, + { + label: "Custom", + value: null, + }, + ]); + additionalArgs ??= parseArgs( + await ask("Custom ffmpeg args: ", { silent }) + ); + args.push(...additionalArgs, path); + stdin.resume(); + const child = spawn("ffmpeg", args, { + stdio: [ + "overlapped", + path === "-" ? "overlapped" : "ignore", + "inherit", + ], + }); + return await new Promise((resolve, reject) => { + if (path === "-") resolve(child.stdout!); + screenshot + .then(b => { + child.stdin!.write(b); + child.stdin!.end(); + return Promise.all([ + once(child, "exit"), + page.close(), + closeContext(context), + closeBrowser(browser), + ]); + }) + .then(() => { + !silent && stdout.write("\x1b[?25h"); + if (child.exitCode === 0) + !silent && + stdout.write(`\x1b[32mVideo saved to ${path}\x1b[0m\n`); + else process.exitCode = child.exitCode ?? 1; + resolve(); + }) + .catch(reject); + !silent && + stdout.write( + `\x1b[33mSaving video...\x1b[0m\nffmpeg ${args.join( + " " + )}\n\x1b[?25l` + ); + }); + } + } + let path = await getOutputPath("png"); + if (path !== "-") path = path.replace(/(\.[^.]*)?$/, ".png"); + stdin.resume(); + await page.waitForLoadState("networkidle"); + // Save the screenshot + !silent && stdout.write("\x1b[33mSaving screenshot...\x1b[0m\n"); + const buffer = await article.screenshot({ + omitBackground: true, + path: path === "-" ? undefined : path, + style, + timeout: 20_000, + }); + return await new Promise(resolve => { + if (path === "-") resolve(Readable.from(buffer, { objectMode: false })); + // Log the success message + !silent && stdout.write(`\x1b[32mScreenshot saved to ${path}\x1b[0m\n`); + // Exit gracefully + resolve( + Promise.all([ + page.close(), + closeContext(context), + closeBrowser(browser), + ]).then(() => {}) + ); + }); + } catch (err) { + Promise.all([ + page.close(), + closeContext(context), + closeBrowser(browser), + ]).catch(() => {}); + throw err; + } +}; + +export default tweetEmbed; diff --git a/src/types.d.ts b/src/types.d.ts index b9f3481..b36f600 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1 +1,30 @@ type Awaitable = Promise | T; +type TwitterVideoInfo = { + duration_millis: number; + variants: { + bitrate?: number; + content_type: string; + url: string; + }[]; +}; +type TweetMedia = { + type: string; + video_info?: TwitterVideoInfo; + original_info: { + height: number; + width: number; + }; +}; +type Tweet = { + id_str: string; + created_at: string; + quoted_tweet?: Tweet; + parent?: Tweet; + mediaDetails?: TweetMedia[]; +}; +type Choice = { + label: string; + value: T; + default?: boolean; + fn?: () => void; +}; diff --git a/src/utils/ask.ts b/src/utils/ask.ts deleted file mode 100644 index 688de2c..0000000 --- a/src/utils/ask.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { once } from "node:events"; -import { stdin, stdout } from "node:process"; - -export const ask = async (question: string) => { - stdin.setRawMode(false); - stdout.write(question); - const [answer] = await once(stdin, "data"); - stdin.setRawMode(true); - return answer.toString().trim(); -}; diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 0000000..f04fd69 --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,96 @@ +import { + chromium, + firefox, + webkit, + type Browser, + type BrowserContext, + type BrowserContextOptions, + type LaunchOptions, +} from "playwright"; + +export type SharedContext = BrowserContext & { + key?: string; + using?: number; +}; +export type SharedBrowser = Browser & { + using?: number; + key?: string; + sharedContexts?: Record>; +}; +const engines = { chromium, firefox, webkit }; +const browsers: Record> = {}; +const copy = (original: T, additional: S) => + new Proxy(original, { + get: (original, p) => { + if (p in additional) + return (additional as Record)[p]; + return (original as Record)[p]; + }, + set: (original, p, value) => { + if (p in additional) + (additional as Record)[p] = value; + else (original as Record)[p] = value; + return true; + }, + }) as T & S; + +export const launch = async ( + browser: "chromium" | "firefox" | "webkit", + options?: LaunchOptions +): Promise => { + const key = browser + JSON.stringify(options); + + if (browsers[key]) { + const newBrowser = await browsers[key]; + newBrowser.using!++; + return copy(newBrowser, { closed: false }); + } + browsers[key] = engines[browser].launch(options); + const newBrowser = await browsers[key]; + newBrowser.using = 1; + newBrowser.key = key; + newBrowser.sharedContexts = {}; + return copy(newBrowser, { closed: false }); +}; + +export const newContext = async ( + browser: SharedBrowser, + options?: BrowserContextOptions +): Promise => { + const key = JSON.stringify(options); + + if (browser.sharedContexts?.[key]) { + const newContext = await browser.sharedContexts[key]; + newContext.using!++; + return copy(newContext, { closed: false }); + } + browser.sharedContexts![key] = browser.newContext(options); + const newContext = await browser.sharedContexts![key]; + newContext.using = 1; + newContext.key = key; + return copy(newContext, { closed: false }); +}; + +export const closeBrowser = async ( + browser: SharedBrowser & { closed?: boolean } +) => { + if (browser.closed) return; + browser.closed = true; + browser.using!--; + if (!browser.using) { + delete browsers[browser.key!]; + await browser.close().catch(() => {}); + } +}; + +export const closeContext = async ( + context: SharedContext & { closed?: boolean } +) => { + if (context.closed) return; + context.closed = true; + context.using!--; + if (!context.using) { + delete (context.browser() as SharedBrowser).sharedContexts![context.key!]; + await context.close().catch(() => {}); + } +}; diff --git a/src/utils/getUserChoice.ts b/src/utils/options.ts similarity index 55% rename from src/utils/getUserChoice.ts rename to src/utils/options.ts index e0262f8..c22b88b 100644 --- a/src/utils/getUserChoice.ts +++ b/src/utils/options.ts @@ -1,20 +1,51 @@ -import { stdin, stdout } from "process"; +import { once } from "node:events"; +import { stdin, stdout } from "node:process"; + +/** + * Ask a question. + * @param id - The option ID + * @returns The value of the option + */ +export const ask = async ( + question: string, + { + default: defaultValue = "", + silent = false, + }: { default?: string; silent?: boolean } = {} +): Promise => { + if (silent) return defaultValue; + const promise = once(stdin, "data"); + + stdout.write(question); + stdin.setRawMode(false); + stdin.resume(); + const [answer] = await promise; + stdin.pause(); + stdin.setRawMode(true); + return answer.toString().trim() || defaultValue; +}; /** * Prompt user with a question and choices - * @param question The question to prompt - * @param choices Array of choices - * @returns Promise resolving to the selected value + * @param question - The question to prompt + * @param choices - Array of choices + * @returns The selected value */ export const getUserChoice = async ( question: string, - choices: { label: string; value: T; default?: boolean }[] + choices: Choice[], + { skip = false }: Partial<{ skip: boolean }> = {} ) => { // Find default index or use first option let selectedIndex = Math.max( 0, choices.findIndex(c => c.default) ); + + if (skip) { + choices[selectedIndex]!.fn?.(); + return choices[selectedIndex]!.value; + } const render = (clear = true) => stdout.write( // Move cursor up for each choice + question line @@ -41,13 +72,16 @@ export const getUserChoice = async ( selectedIndex++; render(); } else if (key.name === "return") { + stdin.pause(); stdout.write("\x1b[?25h"); - resolve(choices[selectedIndex]!.value); + choices[selectedIndex]!.fn?.(); + resolve(choices[selectedIndex]!.value as T); stdin.removeListener("keypress", listener); } }; // Handle key presses stdin.on("keypress", listener); + stdin.resume(); }); }; diff --git a/src/utils/parseArgs.ts b/src/utils/parseArgs.ts new file mode 100644 index 0000000..6a83dcb --- /dev/null +++ b/src/utils/parseArgs.ts @@ -0,0 +1,23 @@ +/** + * Parses a string into an array of arguments, respecting quotes and escapes. + * @param line The command line string to parse + * @returns An array of arguments + */ +export const parseArgs = (line: string): string[] => { + const regex = /(?:\\.|[^'"\s])+|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g; + const args = line.match(regex) || []; + + return args.map(arg => { + // Remove surrounding quotes if present and unescape contents + if (arg.startsWith('"') && arg.endsWith('"')) + return arg.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + if (arg.startsWith("'") && arg.endsWith("'")) + return arg.slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\"); + // Unescape characters in unquoted arguments + return arg + .replace(/\\ /g, " ") + .replace(/\\"/g, '"') + .replace(/\\'/g, "'") + .replace(/\\\\/g, "\\"); + }); +}; diff --git a/src/utils/removeElement.ts b/src/utils/removeElement.ts new file mode 100644 index 0000000..43ef516 --- /dev/null +++ b/src/utils/removeElement.ts @@ -0,0 +1,9 @@ +import type { Locator } from "playwright"; + +/** + * Removes an element from the DOM. + * @param element - The element to remove + * @param timeout - Optional timeout in milliseconds + */ +export const removeElement = (element: Locator, timeout?: number) => + element.evaluate(el => el.remove(), null, { timeout }); diff --git a/src/utils/sizes.ts b/src/utils/sizes.ts new file mode 100644 index 0000000..479bcf0 --- /dev/null +++ b/src/utils/sizes.ts @@ -0,0 +1,50 @@ +import { ok } from "node:assert"; + +const baseSizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; +const searchSizes = ["BYTES", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; +const bytesRegex = /^(\d*(?:\.\d+)?)\s*([a-zA-Z]+)?$/u; + +/** + * Formats a number of bytes into a human-readable string. + * @param bytes The number of bytes to format + * @param param1 Additional options for formatting + * @returns A string representing the formatted size + */ +export const formatBytes = ( + bytes: number, + { + fractionDigits = 1, + sizes = baseSizes, + k = 1_000, + }: Partial<{ fractionDigits: number; sizes: string[]; k: number }> = {} +): string => { + if (!bytes) return "0 Bytes"; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${(bytes / k ** i).toFixed(fractionDigits)}${sizes[i]}`; +}; + +/** + * Parses a human-readable size string into bytes. + * @param sizeStr The size string to parse + * @param param1 Additional options for parsing + * @returns The size in bytes + */ +export const parseHumanReadableSize = ( + sizeStr: string, + { + sizes = searchSizes, + k = 1_000, + }: Partial<{ sizes: string[]; k: number }> = {} +): number => { + const match = bytesRegex.exec(sizeStr); + + ok(match, "Invalid size format"); + const [, value, unit] = match; + ok(value); + const numericValue = parseFloat(value); + const index = sizes.indexOf(unit?.toUpperCase() || "BYTES"); + + ok(index !== -1, `Invalid size unit: ${unit}`); + return numericValue * k ** index; +}; diff --git a/src/utils/watchElement.ts b/src/utils/watchElement.ts new file mode 100644 index 0000000..498455a --- /dev/null +++ b/src/utils/watchElement.ts @@ -0,0 +1,25 @@ +import type { Locator } from "playwright"; + +/** + * Watch an element. + * @param element Locator to watch + * @param fn Function to call when the element is found + * @param state State to wait for (default: "visible") + */ +export const watchElement = ( + element: Locator, + fn: (element: Locator) => Awaitable, + state?: "attached" | "detached" | "visible" | "hidden" +) => { + const loop = async () => { + try { + while (true) { + await element.waitFor({ timeout: 0, state }); + await fn(element); + } + } catch (_) {} + }; + + element = element.first(); + loop(); +}; diff --git a/test/asset/Darwin/1909700761124028890-default.png b/test/asset/Darwin/1909700761124028890-default.png new file mode 100644 index 0000000..a54f0cd Binary files /dev/null and b/test/asset/Darwin/1909700761124028890-default.png differ diff --git a/test/asset/Darwin/1909700761124028890-elements.png b/test/asset/Darwin/1909700761124028890-elements.png new file mode 100644 index 0000000..3dfb8cb Binary files /dev/null and b/test/asset/Darwin/1909700761124028890-elements.png differ diff --git a/test/asset/Darwin/1909700761124028890-hd.png b/test/asset/Darwin/1909700761124028890-hd.png new file mode 100644 index 0000000..a45a6d8 Binary files /dev/null and b/test/asset/Darwin/1909700761124028890-hd.png differ diff --git a/test/asset/Darwin/1909700761124028890-it.png b/test/asset/Darwin/1909700761124028890-it.png new file mode 100644 index 0000000..999d656 Binary files /dev/null and b/test/asset/Darwin/1909700761124028890-it.png differ diff --git a/test/asset/Darwin/1909700761124028890-light.png b/test/asset/Darwin/1909700761124028890-light.png new file mode 100644 index 0000000..8d8fb60 Binary files /dev/null and b/test/asset/Darwin/1909700761124028890-light.png differ diff --git a/test/asset/Darwin/1912709411937669320.png b/test/asset/Darwin/1912709411937669320.png new file mode 100644 index 0000000..3847349 Binary files /dev/null and b/test/asset/Darwin/1912709411937669320.png differ diff --git a/test/asset/Darwin/1913216122314236361-hideThread.png b/test/asset/Darwin/1913216122314236361-hideThread.png new file mode 100644 index 0000000..5ca6ea6 Binary files /dev/null and b/test/asset/Darwin/1913216122314236361-hideThread.png differ diff --git a/test/asset/Darwin/1913216122314236361.png b/test/asset/Darwin/1913216122314236361.png new file mode 100644 index 0000000..db03711 Binary files /dev/null and b/test/asset/Darwin/1913216122314236361.png differ diff --git a/test/asset/Linux/1909700761124028890-default.png b/test/asset/Linux/1909700761124028890-default.png new file mode 100644 index 0000000..c324a6b Binary files /dev/null and b/test/asset/Linux/1909700761124028890-default.png differ diff --git a/test/asset/Linux/1909700761124028890-elements.png b/test/asset/Linux/1909700761124028890-elements.png new file mode 100644 index 0000000..c7f8e5d Binary files /dev/null and b/test/asset/Linux/1909700761124028890-elements.png differ diff --git a/test/asset/Linux/1909700761124028890-hd.png b/test/asset/Linux/1909700761124028890-hd.png new file mode 100644 index 0000000..60b360b Binary files /dev/null and b/test/asset/Linux/1909700761124028890-hd.png differ diff --git a/test/asset/Linux/1909700761124028890-it.png b/test/asset/Linux/1909700761124028890-it.png new file mode 100644 index 0000000..4a26f91 Binary files /dev/null and b/test/asset/Linux/1909700761124028890-it.png differ diff --git a/test/asset/Linux/1909700761124028890-light.png b/test/asset/Linux/1909700761124028890-light.png new file mode 100644 index 0000000..63e6268 Binary files /dev/null and b/test/asset/Linux/1909700761124028890-light.png differ diff --git a/test/asset/Linux/1912709411937669320.png b/test/asset/Linux/1912709411937669320.png new file mode 100644 index 0000000..1ce6632 Binary files /dev/null and b/test/asset/Linux/1912709411937669320.png differ diff --git a/test/asset/Linux/1913216122314236361-hideThread.png b/test/asset/Linux/1913216122314236361-hideThread.png new file mode 100644 index 0000000..cf953fa Binary files /dev/null and b/test/asset/Linux/1913216122314236361-hideThread.png differ diff --git a/test/asset/Linux/1913216122314236361.png b/test/asset/Linux/1913216122314236361.png new file mode 100644 index 0000000..2856559 Binary files /dev/null and b/test/asset/Linux/1913216122314236361.png differ diff --git a/test/asset/Windows_NT/1909700761124028890-default.png b/test/asset/Windows_NT/1909700761124028890-default.png new file mode 100644 index 0000000..1ebb1dc Binary files /dev/null and b/test/asset/Windows_NT/1909700761124028890-default.png differ diff --git a/test/asset/Windows_NT/1909700761124028890-elements.png b/test/asset/Windows_NT/1909700761124028890-elements.png new file mode 100644 index 0000000..2a1aed7 Binary files /dev/null and b/test/asset/Windows_NT/1909700761124028890-elements.png differ diff --git a/test/asset/Windows_NT/1909700761124028890-hd.png b/test/asset/Windows_NT/1909700761124028890-hd.png new file mode 100644 index 0000000..69fd833 Binary files /dev/null and b/test/asset/Windows_NT/1909700761124028890-hd.png differ diff --git a/test/asset/Windows_NT/1909700761124028890-it.png b/test/asset/Windows_NT/1909700761124028890-it.png new file mode 100644 index 0000000..d03e9d4 Binary files /dev/null and b/test/asset/Windows_NT/1909700761124028890-it.png differ diff --git a/test/asset/Windows_NT/1909700761124028890-light.png b/test/asset/Windows_NT/1909700761124028890-light.png new file mode 100644 index 0000000..24eb690 Binary files /dev/null and b/test/asset/Windows_NT/1909700761124028890-light.png differ diff --git a/test/asset/Windows_NT/1912709411937669320.png b/test/asset/Windows_NT/1912709411937669320.png new file mode 100644 index 0000000..1ce6632 Binary files /dev/null and b/test/asset/Windows_NT/1912709411937669320.png differ diff --git a/test/asset/Windows_NT/1913216122314236361-hideThread.png b/test/asset/Windows_NT/1913216122314236361-hideThread.png new file mode 100644 index 0000000..973b44b Binary files /dev/null and b/test/asset/Windows_NT/1913216122314236361-hideThread.png differ diff --git a/test/asset/Windows_NT/1913216122314236361.png b/test/asset/Windows_NT/1913216122314236361.png new file mode 100644 index 0000000..f533bc2 Binary files /dev/null and b/test/asset/Windows_NT/1913216122314236361.png differ diff --git a/test/tweetEmbed.test.ts b/test/tweetEmbed.test.ts new file mode 100644 index 0000000..2efe9ce --- /dev/null +++ b/test/tweetEmbed.test.ts @@ -0,0 +1,155 @@ +import { ok, rejects } from "node:assert"; +import { spawn } from "node:child_process"; +import { on } from "node:events"; +import { mkdir, rename, rm } from "node:fs/promises"; +import { type } from "node:os"; +import { resolve } from "node:path"; +import { argv, env } from "node:process"; +import { finished } from "node:stream/promises"; +import { after, suite, test } from "node:test"; +import tweetEmbed from "../src/tweetEmbed.ts"; + +const os = type(); +process.stdin.unref(); +env.NODE_ENV = "test"; +mkdir("test/tmp", { recursive: true }); +suite("tweetEmbed", { concurrency: true, timeout: 40_000 }, async () => { + const successful: string[] = []; + const failed = new Set(); + const compareImages = async ( + options: NonNullable[0]>, + filename: string + ) => { + const tmpFile = resolve(`test/tmp/${filename}`); + const child = spawn( + "ffmpeg", + [ + "-hide_banner", + "-i", + "pipe:", + "-map", + "0", + "-update", + "1", + "-frames:v", + "1", + tmpFile, + "-i", + `test/asset/${os}/${filename}`, + "-filter_complex", + "[0:v][1:v]scale=iw:rh[a]; [a][1:v]ssim", + "-f", + "null", + "-", + "-y", + ], + { stdio: ["pipe", "ignore", "pipe"] } + ); + const errorPromise = tweetEmbed({ + ...options, + outputPath: "-", + silent: true, + }) + .then(async r => void r!.pipe(child.stdin)) + .catch((error: Error) => { + child.stdin.destroy(error); + child.kill(); + return error; + }); + let message = ""; + + failed.add(filename); + for await (let [data] of on(child.stderr, "data", { + close: ["close", "error", "end"], + })) { + data = data.toString(); + message += data; + if (message.includes("Width and height of input videos must be same")) + throw new Error(`Width of input videos do not match\n${message}`); + const ssim = Number(message.match(/All:(\d\.\d+)/)?.[1]); + + if (!Number.isNaN(ssim)) { + ok(ssim >= 0.9, `SSIM < 0.9: ${ssim} (${tmpFile})`); + failed.delete(filename); + successful.push(filename); + return finished(child.stderr); + } + } + throw (await errorPromise) ?? new Error(`Failed to parse SSIM\n${message}`); + }; + + after(async () => { + await Promise.all( + successful.map(async filename => + argv.includes("--test-update-asset") + ? rename(`test/tmp/${filename}`, `test/asset/${os}/${filename}`) + : rm(`test/tmp/${filename}`, { force: true }) + ) + ); + }); + test("Basic screenshot", async () => { + await compareImages( + { tweet: "https://x.com/Spotify/status/1909700761124028890" }, + "1909700761124028890-default.png" + ); + }); + test("High quality screenshot", async () => { + await compareImages( + { + tweet: "https://x.com/Spotify/status/1909700761124028890", + deviceScaleFactor: 8, + }, + "1909700761124028890-hd.png" + ); + }); + test( + "Quote tweet", + { todo: "Visual bug only when running tests" }, + async () => { + await compareImages( + { tweet: "x.com/simonsarris/status/1912709411937669320" }, + "1912709411937669320.png" + ); + } + ); + test("Reply tweet", async () => { + await compareImages( + { tweet: "twitter.com/wrongName/status/1913216122314236361" }, + "1913216122314236361.png" + ); + }); + test("With additional elements", async () => { + await compareImages( + { tweet: "1909700761124028890", removeElements: false }, + "1909700761124028890-elements.png" + ); + }); + test("Different language", async () => { + await compareImages( + { tweet: "1909700761124028890", removeElements: false, lang: "it" }, + "1909700761124028890-it.png" + ); + }); + test("Light theme", async () => { + await compareImages( + { tweet: "1909700761124028890", theme: "light" }, + "1909700761124028890-light.png" + ); + }); + test("Hide thread", async () => { + await compareImages( + { tweet: "1913216122314236361", hideThread: "true" }, + "1913216122314236361-hideThread.png" + ); + }); + test("Tweet not found", async () => { + await rejects( + tweetEmbed({ + tweet: "1913216122314236360", + outputPath: "-", + silent: true, + }), + { name: "Error", message: "Failed to get tweet details" } + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8069be5..a81f478 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*"], + "include": ["src", "test"], "extends": [ "@tsconfig/recommended/tsconfig.json", "@tsconfig/strictest/tsconfig.json", @@ -9,6 +9,7 @@ "allowImportingTsExtensions": true, "exactOptionalPropertyTypes": false, "noEmit": true, + "noPropertyAccessFromIndexSignature": false, "outDir": "dist", "pretty": true, "sourceMap": true