fix(transcript): pipeline URLs signees yt-dlp + fetch durci anti-429
CI / build-and-test (push) Successful in 14m1s

Les URLs timedtext InnerTube repondent 200-vide (throttle YouTube) :
sonde rapide max 2 sans retry, puis dump yt-dlp -> URLs signees en
direct (max 4), puis --write-sub. 200-vide traite comme 429
(retryAfterSec), fmt= plus jamais duplique (signature), cookies
YT_COOKIES_FILE reutilises par le fetch direct. Tests 26/26.
This commit is contained in:
2026-09-27 20:47:28 -04:00
parent 148a00e60f
commit a79831f406
7 changed files with 214 additions and 62 deletions
+54
View File
@@ -319,6 +319,60 @@ export function looksLikeHtmlError(text) {
return s.startsWith('<!doctype') || s.startsWith('<html');
}
/**
* Append `fmt=xx` to a timedtext URL only when it has no `fmt` param yet.
* Re-signing a signed yt-dlp URL by appending a duplicate `fmt` breaks its
* `signature` and YouTube answers 429/Sorry: always reuse the URL as-is.
* Pure and testable offline.
*/
export function ensureFmtParam(url, fmt = 'json3') {
const raw = String(url || '');
if (!raw) return raw;
try {
const u = new URL(raw);
if (u.searchParams.has('fmt')) return u.toString();
u.searchParams.set('fmt', fmt);
return u.toString();
} catch {
if (/[?&]fmt=/i.test(raw)) return raw;
return `${raw}${raw.includes('?') ? '&' : '?'}fmt=${encodeURIComponent(fmt)}`;
}
}
/**
* True when a timedtext HTTP 200 body carries no usable caption payload.
* YouTube throttles datacenter egress IPs with `200 + empty body` (instead
* of an explicit 429): callers must treat it as transient (same as 429),
* never as "no subtitles".
*/
export function isEmptyTimedTextBody(text) {
const s = String(text ?? '');
if (!s) return true;
if (looksLikeHtmlError(s)) return true;
return s.trim().length < 32;
}
/**
* Merge fetch candidates, signed yt-dlp URLs first (they carry a fresh
* `signature`/`expire` and are the only ones plain fetch can still read),
* then InnerTube base URLs as a quick probe. De-duplicated by URL, bounded.
* Pure and testable offline.
* @param {{ signed?: Array<{track:any,lang:string|null}>, innertube?: Array<{track:any,lang:string|null}>, max?: number }} opts
*/
export function mergeTranscriptCandidates({ signed = [], innertube = [], max = 5 } = {}) {
const n = Math.max(1, Number(max || 5));
const seen = new Set();
const out = [];
for (const c of [...(signed || []), ...(innertube || [])]) {
const u = String(c?.track?.url || '');
if (!u || seen.has(u)) continue;
seen.add(u);
out.push(c);
if (out.length >= n) break;
}
return out;
}
function vttTimestampToSeconds(h, m, s, ms) {
return Number(h) * 3600 + Number(m) * 60 + Number(s) + Number(ms) / 1000;
}