Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit f6a1108d4804a421618645629ab93b55b095e78a
parent 8f95dd0d12d06280fff461b296a995898f604786
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 00:26:25 -0400

umtool: report-to-video's yt-dlp spawns carry the platform args

build-video.mjs (clip fetch) and check-availability.mjs (--simulate)
built their own argv with no `--impersonate`, so every Rumble clip fetch
and availability check 403'd at Cloudflare (yt-dlp #17496) — the gap
release 5 slice R left outside its scope.

They are `.mjs` run by bare `node` (20 in the runtime image, no type
stripping), so they cannot import common's TS. The table moves instead:
`PLATFORM_ARGS`/`platformArgs` into `common/ytdlp/platformArgs.mjs` and
`detectPlatform` into `common/lib/detectPlatform.mjs`, plain JS with
JSDoc types, re-exported from `channelArgs.ts` / `platform.ts` so no TS
caller changes. One copy of each. common's package.json exports the two
files by exact path; report-to-video depends on common (lockfile: that
importer entry only).

The argv builders are now exported (`clipFetchArgs`, `simulateArgs`) and
tested in platform-args.test.mjs: Rumble gets the table's args, before a
retry's `extra`; YouTube gets none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/lib/detectPlatform.mjs | 31+++++++++++++++++++++++++++++++
Mcommon/lib/platform.ts | 23++++-------------------
Mcommon/package.json | 2++
Mcommon/ytdlp/channelArgs.ts | 21++++-----------------
Acommon/ytdlp/platformArgs.mjs | 44++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 6+++++-
Mumtool/report-to-video/build-video.mjs | 49+++++++++++++++++++++++++++++++------------------
Mumtool/report-to-video/check-availability.mjs | 16+++++++++++++++-
Mumtool/report-to-video/package.json | 3+++
Aumtool/report-to-video/platform-args.test.mjs | 45+++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 184 insertions(+), 56 deletions(-)

diff --git a/common/lib/detectPlatform.mjs b/common/lib/detectPlatform.mjs @@ -0,0 +1,31 @@ +// detectPlatform — which platform a URL is on, by host. +// +// Plain JS (with JSDoc types) rather than TS for ONE reason: umtool's +// report-to-video scripts are `.mjs` run by bare `node` (20 in the runtime +// image — no type stripping), and they spawn yt-dlp too, so they need the same +// answer the editor gets. `lib/platform.ts` re-exports this; every TS caller +// keeps importing it from there. Do not grow a second host table anywhere. + +/** @typedef {import("./platform").Platform} Platform */ + +/** + * @param {string | undefined | null} url + * @returns {Platform | null} + */ +export function detectPlatform(url) { + if (!url) return null; + try { + const host = new URL(url).hostname.toLowerCase(); + if (host.endsWith("youtube.com") || host === "youtu.be") return "youtube"; + if (host.endsWith("rumble.com")) return "rumble"; + if (host.endsWith("odysee.com")) return "odysee"; + if (host.endsWith("twitch.tv")) return "twitch"; + if (host.endsWith("kick.com")) return "kick"; + if (host === "x.com" || host.endsWith(".x.com")) return "twitter"; + if (host.endsWith("twitter.com")) return "twitter"; + if (host === "bsky.app" || host.endsWith(".bsky.app")) return "bluesky"; + } catch { + /* fall through */ + } + return null; +} diff --git a/common/lib/platform.ts b/common/lib/platform.ts @@ -33,25 +33,10 @@ export function isSocialPlatform(platform: Platform | null | undefined): boolean return platform === "twitter" || platform === "bluesky"; } -export function detectPlatform( - url: string | undefined | null, -): Platform | null { - if (!url) return null; - try { - const host = new URL(url).hostname.toLowerCase(); - if (host.endsWith("youtube.com") || host === "youtu.be") return "youtube"; - if (host.endsWith("rumble.com")) return "rumble"; - if (host.endsWith("odysee.com")) return "odysee"; - if (host.endsWith("twitch.tv")) return "twitch"; - if (host.endsWith("kick.com")) return "kick"; - if (host === "x.com" || host.endsWith(".x.com")) return "twitter"; - if (host.endsWith("twitter.com")) return "twitter"; - if (host === "bsky.app" || host.endsWith(".bsky.app")) return "bluesky"; - } catch { - /* fall through */ - } - return null; -} +// Host → platform. Lives in `detectPlatform.mjs` (plain JS so umtool's `.mjs` +// scripts can import the same copy); every TS caller imports it from here. +import { detectPlatform } from "./detectPlatform.mjs"; +export { detectPlatform }; // Best-effort canonical webpage URL for a video given its platform and // canonical id. Used both as a metadata fallback in summarize() and to diff --git a/common/package.json b/common/package.json @@ -31,6 +31,8 @@ "./components/urlState": "./components/urlState.ts", "./components/virtualizer": "./components/virtualizer.ts", "./components/*": "./components/*.tsx", + "./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs", + "./ytdlp/platformArgs.mjs": "./ytdlp/platformArgs.mjs", "./lib/*": "./lib/*.ts", "./controller/*": "./controller/*.ts", "./jobs/*": "./jobs/*.ts", diff --git a/common/ytdlp/channelArgs.ts b/common/ytdlp/channelArgs.ts @@ -18,23 +18,10 @@ import type { ChannelConfig } from "../lib/channelConfig"; import { cookieArgs } from "../lib/cookiePolicy"; import { detectPlatform, type Platform } from "../lib/platform"; -// Args a platform needs on EVERY yt-dlp spawn, whatever the job. Code, not a -// setting: per-channel `ytdlpExtraArgs` is the tweak surface, and it comes -// AFTER these, so a channel override wins (yt-dlp is last-flag-wins for -// `--sleep-requests` and `--impersonate`). -// -// rumble: every request 403s at Cloudflare without a browser TLS fingerprint -// (yt-dlp #17496); a probe on 2026-09-24 got 200 with `--impersonate chrome` -// and 403 without. `--sleep-requests 1` paces the listing walk — the -// the-quartering-rumble full sweep 429'd at page 155 unpaced the same day. -export const PLATFORM_ARGS: Partial<Record<Platform, readonly string[]>> = { - rumble: ["--impersonate", "chrome", "--sleep-requests", "1"], -}; - -export function platformArgs(platform: Platform | null | undefined): string[] { - if (!platform) return []; - return [...(PLATFORM_ARGS[platform] ?? [])]; -} +// The platform args table lives in `platformArgs.mjs` (plain JS so umtool's +// `.mjs` scripts can import the same copy); re-exported here for TS callers. +import { platformArgs } from "./platformArgs.mjs"; +export { PLATFORM_ARGS, platformArgs, platformArgsForUrl } from "./platformArgs.mjs"; export function channelPlatform( config: Pick<ChannelConfig, "platform" | "url">, diff --git a/common/ytdlp/platformArgs.mjs b/common/ytdlp/platformArgs.mjs @@ -0,0 +1,44 @@ +// The per-platform yt-dlp args table — THE one copy. +// +// Plain JS (with JSDoc types) so umtool's report-to-video scripts, which are +// `.mjs` run by bare `node` (20 in the runtime image — no type stripping), can +// import it: they spawn yt-dlp for clip fetches and availability checks and hit +// the same Cloudflare wall. `ytdlp/channelArgs.ts` re-exports it for every TS +// caller. Never copy these args into another file. + +import { detectPlatform } from "../lib/detectPlatform.mjs"; + +/** @typedef {import("../lib/platform").Platform} Platform */ + +// Args a platform needs on EVERY yt-dlp spawn, whatever the job. Code, not a +// setting: per-channel `ytdlpExtraArgs` is the tweak surface, and it comes +// AFTER these, so a channel override wins (yt-dlp is last-flag-wins for +// `--sleep-requests` and `--impersonate`). +// +// rumble: every request 403s at Cloudflare without a browser TLS fingerprint +// (yt-dlp #17496); a probe on 2026-09-24 got 200 with `--impersonate chrome` +// and 403 without. `--sleep-requests 1` paces the listing walk — the +// the-quartering-rumble full sweep 429'd at page 155 unpaced the same day. +/** @type {Readonly<Partial<Record<Platform, readonly string[]>>>} */ +export const PLATFORM_ARGS = Object.freeze({ + rumble: Object.freeze(["--impersonate", "chrome", "--sleep-requests", "1"]), +}); + +/** + * @param {Platform | null | undefined} platform + * @returns {string[]} + */ +export function platformArgs(platform) { + if (!platform) return []; + return [...(PLATFORM_ARGS[platform] ?? [])]; +} + +/** + * The args for whatever platform `url` is on — for a spawn that has a URL and + * no channel config (umtool's clip fetch and availability check). + * @param {string | undefined | null} url + * @returns {string[]} + */ +export function platformArgsForUrl(url) { + return platformArgs(detectPlatform(url)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -360,7 +360,11 @@ importers: specifier: ^5.9.3 version: 5.9.3 - umtool/report-to-video: {} + umtool/report-to-video: + dependencies: + yt-dlp-transcript-common: + specifier: workspace:* + version: link:../../common packages: diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -63,6 +63,9 @@ import { cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; +// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, +// in common, plain JS so bare `node` can load it. +import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; // The header line, the clock in it and the title cleaner live in one module // the clip bench imports too -- a preview that shows a line this renderer // would never draw is worse than no preview. @@ -351,6 +354,32 @@ export async function findContainingWindow(rawDir, video, from, to) { return tightestContaining(await cachedWindowsFor(rawDir, video), from, to); } +// The yt-dlp argv for one clip window. The platform's fixed args come from +// common's table (`platformArgsForUrl`) -- the same copy every editor spawn +// uses, so a Rumble fetch carries `--impersonate chrome` here too instead of +// 403ing at Cloudflare. They go BEFORE `extra`, so a retry's own flags win. +export function clipFetchArgs({ url, from, to, fmt, dest, extra = [] }) { + return [ + // The operator's own yt-dlp config redirects output and attaches thumbnail + // and metadata post-processors; without this the clips land elsewhere. + "--ignore-config", + "--no-playlist", + "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`, + // Without this the cut snaps to the nearest preceding keyframe, which can be + // seconds early — fine for scrubbing, not fine when the clip IS the citation. + "--force-keyframes-at-cuts", + ...platformArgsForUrl(url), + ...extra, + // Pin H.264/AAC in mp4. Left alone yt-dlp picks VP9+Opus at these heights, + // and since --force-keyframes-at-cuts re-encodes, that means libvpx-vp9 — + // 27s to cut a 5s clip. It also writes .webm and appends that to -o. + "-f", fmt, + "--merge-output-format", "mp4", + "-o", dest, + "--", url, + ]; +} + /** * WHERE A CLIP IS CUT OUT OF A CACHED WINDOW, as ffmpeg input arguments. * @@ -415,24 +444,8 @@ async function fetchClip(entry, meta, render, rawDir, opts) { `b[height<=${maxH}]`, ].join("/"); - const argsWith = (extra) => [ - // The operator's own yt-dlp config redirects output and attaches thumbnail - // and metadata post-processors; without this the clips land elsewhere. - "--ignore-config", - "--no-playlist", - "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`, - // Without this the cut snaps to the nearest preceding keyframe, which can be - // seconds early — fine for scrubbing, not fine when the clip IS the citation. - "--force-keyframes-at-cuts", - ...extra, - // Pin H.264/AAC in mp4. Left alone yt-dlp picks VP9+Opus at these heights, - // and since --force-keyframes-at-cuts re-encodes, that means libvpx-vp9 — - // 27s to cut a 5s clip. It also writes .webm and appends that to -o. - "-f", fmt, - "--merge-output-format", "mp4", - "-o", dest, - "--", meta.webpageUrl, - ]; + const argsWith = (extra) => + clipFetchArgs({ url: meta.webpageUrl, from, to, fmt, dest, extra }); const attempt = async (extra) => { try { diff --git a/umtool/report-to-video/check-availability.mjs b/umtool/report-to-video/check-availability.mjs @@ -26,6 +26,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_CHANNELS_DIR } from "./cues.mjs"; +// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, +// in common, plain JS so bare `node` can load it. +import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; const execFileP = promisify(execFile); @@ -50,6 +53,17 @@ function classify(stderr) { return "maybe_missing"; } +// `--simulate` resolves formats without downloading. It carries the platform's +// fixed args like every other spawn: without `--impersonate` every Rumble source +// 403s here and reads as missing when it is not. +export function simulateArgs(url) { + return [ + "--ignore-config", "--no-playlist", "--simulate", "--quiet", "--no-warnings", + ...platformArgsForUrl(url), + "--", url, + ]; +} + async function cueMeta(videoId, channelSlug) { const p = path.join(CHANNELS_DIR, channelSlug, "data", videoId, "transcript.cues.json"); const d = JSON.parse(await readFile(p, "utf8")); @@ -118,7 +132,7 @@ export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 } try { await execFileP( YTDLP, - ["--ignore-config", "--no-playlist", "--simulate", "--quiet", "--no-warnings", "--", meta.webpageUrl], + simulateArgs(meta.webpageUrl), { maxBuffer: 1 << 24 }, ); sources.push({ diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "description": "Turn a cited sweep report into a narrated-by-text video.", + "dependencies": { + "yt-dlp-transcript-common": "workspace:*" + }, "bin": { "report-build-video": "./build-video.mjs", "report-resolve-windows": "./resolve-windows.mjs", diff --git a/umtool/report-to-video/platform-args.test.mjs b/umtool/report-to-video/platform-args.test.mjs @@ -0,0 +1,45 @@ +// Tests that umtool's two yt-dlp spawns carry the platform's fixed args from +// common's ONE table — a Rumble clip fetch or availability check without +// `--impersonate chrome` 403s at Cloudflare (yt-dlp #17496). +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PLATFORM_ARGS } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; +import { clipFetchArgs } from "./build-video.mjs"; +import { simulateArgs } from "./check-availability.mjs"; + +const RUMBLE = "https://rumble.com/v7e07us-some-title.html"; +const YOUTUBE = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + +/** The args between two markers, so the assertion names the position too. */ +const slice = (argv, from, to) => argv.slice(argv.indexOf(from) + 1, argv.indexOf(to)); + +test("a Rumble clip fetch carries the rumble platform args, before the retry's extra", () => { + const argv = clipFetchArgs({ + url: RUMBLE, from: 12, to: 20.5, fmt: "b", dest: "/x/out.mp4", + extra: ["--downloader-args", "ffmpeg_i:-extension_picky 0"], + }); + assert.deepEqual( + slice(argv, "--force-keyframes-at-cuts", "-f"), + [...PLATFORM_ARGS.rumble, "--downloader-args", "ffmpeg_i:-extension_picky 0"], + ); + assert.ok(argv.includes("--impersonate")); + assert.deepEqual(argv.slice(-2), ["--", RUMBLE]); + assert.equal(argv[argv.indexOf("--download-sections") + 1], "*12.00-20.50"); +}); + +test("a YouTube clip fetch gets no platform args", () => { + const argv = clipFetchArgs({ url: YOUTUBE, from: 1, to: 2, fmt: "b", dest: "/x/out.mp4" }); + assert.deepEqual(slice(argv, "--force-keyframes-at-cuts", "-f"), []); + assert.ok(!argv.includes("--impersonate")); +}); + +test("the availability simulate carries the platform args for Rumble only", () => { + const rumble = simulateArgs(RUMBLE); + assert.deepEqual(slice(rumble, "--no-warnings", "--"), [...PLATFORM_ARGS.rumble]); + assert.deepEqual(rumble.slice(-2), ["--", RUMBLE]); + assert.ok(rumble.includes("--simulate")); + assert.ok(!simulateArgs(YOUTUBE).includes("--impersonate")); +});