Compare commits

...

4 Commits

Author SHA1 Message Date
Jim Liu 宝玉 86a3d6521b chore: release v1.82.0 2026-03-24 22:40:23 -05:00
Jim Liu 宝玉 e99ce744cd feat(baoyu-url-to-markdown): add browser fallback strategy, content cleaner, and data URI support
- Browser strategy: headless first with automatic retry in visible Chrome on failure
- New --browser auto|headless|headed flag with --headless/--headed shortcuts
- Content cleaner module for HTML preprocessing (remove ads, base64 images, scripts)
- Media localizer now handles base64 data URIs alongside remote URLs
- Capture finalUrl from browser to track redirects for output path
- Agent quality gate documentation for post-capture validation
- Upgrade defuddle ^0.12.0 → ^0.14.0
- Add unit tests for content-cleaner, html-to-markdown, legacy-converter, media-localizer
2026-03-24 22:39:17 -05:00
Jim Liu 宝玉 40f9f05c22 chore: release v1.81.0 2026-03-24 20:59:56 -05:00
Jim Liu 宝玉 09ce80357f feat(baoyu-youtube-transcript): add yt-dlp fallback and modularize codebase
Retry with alternate InnerTube client identities when YouTube returns
anti-bot responses, then fall back to yt-dlp when available. Split
monolithic main.ts into typed modules (youtube, transcript, storage,
shared, types) and add unit tests.
2026-03-24 20:59:04 -05:00
24 changed files with 2277 additions and 769 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.80.1" "version": "1.82.0"
}, },
"plugins": [ "plugins": [
{ {
+23
View File
@@ -2,6 +2,29 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.82.0 - 2026-03-24
### Features
- `baoyu-url-to-markdown`: add browser fallback strategy — headless first, automatic retry in visible Chrome on technical failure; new `--browser auto|headless|headed` flag with `--headless`/`--headed` shortcuts
- `baoyu-url-to-markdown`: add content cleaner module for HTML preprocessing before extraction (remove ads, base64 images, scripts, styles)
- `baoyu-url-to-markdown`: support base64 data URI images in media localizer alongside remote URLs
- `baoyu-url-to-markdown`: capture final URL from browser to track redirects for output path generation
- `baoyu-url-to-markdown`: add agent quality gate documentation for post-capture content validation
### Dependencies
- `baoyu-url-to-markdown`: upgrade defuddle ^0.12.0 → ^0.14.0
### Tests
- `baoyu-url-to-markdown`: add unit tests for content-cleaner, html-to-markdown, legacy-converter, media-localizer
## 1.81.0 - 2026-03-24
### Features
- `baoyu-youtube-transcript`: add yt-dlp fallback when YouTube blocks direct InnerTube API, with alternate client identity retry and cookie support via `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` env var
### Refactor
- `baoyu-youtube-transcript`: split monolithic script into typed modules (youtube, transcript, storage, shared, types) and add unit tests
## 1.80.1 - 2026-03-24 ## 1.80.1 - 2026-03-24
### Fixes ### Fixes
+23
View File
@@ -2,6 +2,29 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.82.0 - 2026-03-24
### 新功能
- `baoyu-url-to-markdown`:新增浏览器回退策略 — 默认无头模式优先,技术故障时自动重试有头 Chrome;新增 `--browser auto|headless|headed` 参数及 `--headless`/`--headed` 快捷方式
- `baoyu-url-to-markdown`:新增内容清理模块,提取前预处理 HTML(移除广告、base64 图片、脚本、样式)
- `baoyu-url-to-markdown`:媒体本地化支持 base64 data URI 图片
- `baoyu-url-to-markdown`:从浏览器捕获最终 URL 以跟踪重定向,用于输出路径生成
- `baoyu-url-to-markdown`:新增 Agent 质量门控文档,规范捕获后的内容验证流程
### 依赖
- `baoyu-url-to-markdown`:升级 defuddle ^0.12.0 → ^0.14.0
### 测试
- `baoyu-url-to-markdown`:新增 content-cleaner、html-to-markdown、legacy-converter、media-localizer 单元测试
## 1.81.0 - 2026-03-24
### 新功能
- `baoyu-youtube-transcript`YouTube 封锁直连 InnerTube API 时自动回退到 yt-dlp,支持备用客户端身份重试及通过 `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` 环境变量传递浏览器 Cookie
### 重构
- `baoyu-youtube-transcript`:将单体脚本拆分为类型化模块(youtube、transcript、storage、shared、types)并添加单元测试
## 1.80.1 - 2026-03-24 ## 1.80.1 - 2026-03-24
### 修复 ### 修复
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.80.1**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.82.0**.
## Architecture ## Architecture
+51 -3
View File
@@ -118,6 +118,7 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
## Features ## Features
- Chrome CDP for full JavaScript rendering - Chrome CDP for full JavaScript rendering
- Browser strategy fallback: default headless first, then visible Chrome on technical failure
- URL-specific parser layer for sites that need custom HTML rules before generic extraction - URL-specific parser layer for sites that need custom HTML rules before generic extraction
- Two capture modes: auto or wait-for-user - Two capture modes: auto or wait-for-user
- Save rendered HTML as a sibling `-captured.html` file - Save rendered HTML as a sibling `-captured.html` file
@@ -137,6 +138,12 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
# Auto mode (default) - capture when page loads # Auto mode (default) - capture when page loads
${BUN_X} {baseDir}/scripts/main.ts <url> ${BUN_X} {baseDir}/scripts/main.ts <url>
# Force headless only
${BUN_X} {baseDir}/scripts/main.ts <url> --browser headless
# Force visible browser
${BUN_X} {baseDir}/scripts/main.ts <url> --browser headed
# Wait mode - wait for user signal before capture # Wait mode - wait for user signal before capture
${BUN_X} {baseDir}/scripts/main.ts <url> --wait ${BUN_X} {baseDir}/scripts/main.ts <url> --wait
@@ -158,6 +165,9 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
| `-o <path>` | Output file path — must be a **file** path, not directory (default: auto-generated) | | `-o <path>` | Output file path — must be a **file** path, not directory (default: auto-generated) |
| `--output-dir <dir>` | Base output directory — auto-generates `{dir}/{domain}/{slug}.md` (default: `./url-to-markdown/`) | | `--output-dir <dir>` | Base output directory — auto-generates `{dir}/{domain}/{slug}.md` (default: `./url-to-markdown/`) |
| `--wait` | Wait for user signal before capturing | | `--wait` | Wait for user signal before capturing |
| `--browser <mode>` | Browser strategy: `auto` (default), `headless`, or `headed` |
| `--headless` | Shortcut for `--browser headless` |
| `--headed` | Shortcut for `--browser headed` |
| `--timeout <ms>` | Page load timeout (default: 30000) | | `--timeout <ms>` | Page load timeout (default: 30000) |
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths | | `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
@@ -165,7 +175,7 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
| Mode | Behavior | Use When | | Mode | Behavior | Use When |
|------|----------|----------| |------|----------|----------|
| Auto (default) | Capture on network idle | Public pages, static content | | Auto (default) | Try headless first, then retry in visible Chrome if needed | Public pages, static content, unknown pages |
| Wait (`--wait`) | User signals when ready | Login-required, lazy loading, paywalls | | Wait (`--wait`) | User signals when ready | Login-required, lazy loading, paywalls |
**Wait mode workflow**: **Wait mode workflow**:
@@ -173,6 +183,43 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
2. Ask user to confirm page is ready 2. Ask user to confirm page is ready
3. Send newline to stdin to trigger capture 3. Send newline to stdin to trigger capture
**Default browser fallback**:
1. Auto mode starts with headless Chrome and captures on network idle
2. If headless capture fails technically, retry with visible Chrome
3. If a shared Chrome session for this profile already exists, reuse it instead of launching a new browser
4. The script does not hard-code login or paywall detection; the agent must inspect the captured markdown or HTML and decide whether to rerun with `--browser headed --wait`
## Agent Quality Gate
**CRITICAL**: The agent must treat headless capture as provisional. Some sites render differently in headless mode and can silently return an error shell, partially hydrated page, or low-quality extraction **without** causing the CLI to fail.
After every run that used `--browser auto` or `--browser headless`, the agent **MUST** inspect the saved markdown first, and inspect the saved `-captured.html` when the markdown looks suspicious.
### Quality checks the agent must perform
1. Confirm the markdown title matches the target page, not a generic site shell
2. Confirm the body contains the expected article or page content, not just navigation, footer, or a generic error
3. Watch for obvious failure signs such as:
- `Application error`
- `This page could not be found`
- login, signup, subscribe, or verification shells
- extremely short markdown for a page that should be long-form
- raw framework payloads or mostly boilerplate content
4. If the result is low quality, incomplete, or clearly wrong, do **not** accept the run as successful just because the CLI exited with code 0
### Recovery workflow the agent must follow
1. First run with default `auto` unless there is already a clear reason to use wait mode
2. Review markdown quality immediately after the run
3. If the content is low quality, rerun locally with visible Chrome:
- `--browser headed` for ordinary rendering issues
- `--browser headed --wait` when the page may need login, anti-bot interaction, cookie acceptance, or extra hydration time
4. If `--wait` is used, tell the user exactly what to do:
- if login is required, ask them to sign in
- if the page needs time to hydrate, ask them to wait until the full content is visible
- once ready, ask them to press Enter so capture can continue
5. Only fall back to hosted `defuddle.md` after the local browser strategies have failed or are clearly lower fidelity
## Output Format ## Output Format
Each run saves two files side by side: Each run saves two files side by side:
@@ -211,8 +258,9 @@ Conversion order:
2. If no specialized parser matches, try Defuddle 2. If no specialized parser matches, try Defuddle
3. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline 3. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
4. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor 4. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor
5. If the entire local browser capture flow fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly 5. If the agent determines the captured result is a login screen, verification screen, or paywall shell, rerun locally with `--browser headed --wait` and ask the user to complete access before capture
6. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history 6. If the entire local browser capture flow still fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly
7. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
CLI output will show: CLI output will show:
@@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"defuddle": "^0.12.0", "defuddle": "^0.14.0",
"jsdom": "^24.1.3", "jsdom": "^24.1.3",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"turndown": "^7.2.2", "turndown": "^7.2.2",
@@ -61,7 +61,7 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"defuddle": ["defuddle@0.12.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "peerDependencies": { "jsdom": "^24.0.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-Y/WgyGKBxwxFir+hWNth4nmWDDDb8BzQi3qASS2NWYPXsKU42Ku49/3M5yFYefnRef9prynnmasfnXjk99EWgA=="], "defuddle": ["defuddle@0.14.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-btavZGd1WgiVqrVM62WGRXMUi/aU7ckTZiq0xXWLZMHvzIqNZjwIFQEDRx8MarD7fIgsB90NXZ9xHJkKtapt2Q=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cleanContent } from "./content-cleaner.js";
const SAMPLE_HTML = `<!doctype html>
<html>
<head>
<title>Example Story</title>
<style>.cookie-banner { position: fixed; }</style>
<script>window.__noise = true;</script>
</head>
<body>
<!-- comment that should be removed -->
<header>
<nav>
<a href="/home">Home</a>
<a href="/topics">Topics</a>
</nav>
</header>
<div class="cookie-banner">Accept cookies</div>
<aside>Sidebar links</aside>
<main>
<article class="content">
<h1>Actual Story Title</h1>
<p>
This is the first paragraph of the real story body, and it is intentionally long enough
to survive the cleaner's main-content heuristics without being mistaken for navigation.
</p>
<p>
This is the second paragraph with more useful detail, a
<a href="/read-more">supporting link</a>, and a normal image.
</p>
<img src="/images/cover.jpg" alt="Cover">
<img src="data:image/png;base64,AAAA" alt="Inline data">
</article>
</main>
<footer>Footer boilerplate</footer>
</body>
</html>`;
test("cleanContent keeps the article body and removes obvious boilerplate", () => {
const cleaned = cleanContent(SAMPLE_HTML, "https://example.com/posts/story");
assert.match(cleaned, /Actual Story Title/);
assert.match(cleaned, /https:\/\/example\.com\/read-more/);
assert.match(cleaned, /https:\/\/example\.com\/images\/cover\.jpg/);
assert.doesNotMatch(cleaned, /Accept cookies/);
assert.doesNotMatch(cleaned, /Sidebar links/);
assert.doesNotMatch(cleaned, /Footer boilerplate/);
assert.doesNotMatch(cleaned, /window\.__noise/);
assert.doesNotMatch(cleaned, /comment that should be removed/);
assert.doesNotMatch(cleaned, /data:image\/png;base64/);
});
@@ -0,0 +1,432 @@
import { parseHTML } from "linkedom";
export interface CleaningOptions {
removeAds?: boolean;
removeBase64Images?: boolean;
onlyMainContent?: boolean;
includeTags?: string[];
excludeTags?: string[];
}
const ALWAYS_REMOVE_SELECTORS = [
"script",
"style",
"noscript",
"link[rel='stylesheet']",
"[hidden]",
"[aria-hidden='true']",
"[style*='display: none']",
"[style*='display:none']",
"[style*='visibility: hidden']",
"[style*='visibility:hidden']",
"svg[aria-hidden='true']",
"svg.icon",
"svg[class*='icon']",
"template",
"meta",
"iframe",
"canvas",
"object",
"embed",
"form",
"input",
"select",
"textarea",
"button",
];
const OVERLAY_SELECTORS = [
"[class*='modal']",
"[class*='popup']",
"[class*='overlay']",
"[class*='dialog']",
"[role='dialog']",
"[role='alertdialog']",
"[class*='cookie']",
"[class*='consent']",
"[class*='gdpr']",
"[class*='privacy-banner']",
"[class*='notification-bar']",
"[id*='cookie']",
"[id*='consent']",
"[id*='gdpr']",
"[style*='position: fixed']",
"[style*='position:fixed']",
"[style*='position: sticky']",
"[style*='position:sticky']",
];
const NAVIGATION_SELECTORS = [
"header",
"footer",
"nav",
"aside",
".header",
".top",
".navbar",
"#header",
".footer",
".bottom",
"#footer",
".sidebar",
".side",
".aside",
"#sidebar",
".modal",
".popup",
"#modal",
".overlay",
".ad",
".ads",
".advert",
"#ad",
".lang-selector",
".language",
"#language-selector",
".social",
".social-media",
".social-links",
"#social",
".menu",
".navigation",
"#nav",
".breadcrumbs",
"#breadcrumbs",
".share",
"#share",
".widget",
"#widget",
".cookie",
"#cookie",
];
const FORCE_INCLUDE_SELECTORS = [
"#main",
"#content",
"#main-content",
"#article",
"#post",
"#page-content",
"main",
"article",
"[role='main']",
".main-content",
".content",
".post-content",
".article-content",
".entry-content",
".page-content",
".article-body",
".post-body",
".story-content",
".blog-content",
];
const AD_SELECTORS = [
"ins.adsbygoogle",
".google-ad",
".adsense",
"[data-ad]",
"[data-ads]",
"[data-ad-slot]",
"[data-ad-client]",
".ad-container",
".ad-wrapper",
".advertisement",
".sponsored-content",
"img[width='1'][height='1']",
"img[src*='pixel']",
"img[src*='tracking']",
"img[src*='analytics']",
];
function getLinkDensity(element: Element): number {
const text = element.textContent || "";
const textLength = text.trim().length;
if (textLength === 0) return 1;
let linkLength = 0;
element.querySelectorAll("a").forEach((link: Element) => {
linkLength += (link.textContent || "").trim().length;
});
return linkLength / textLength;
}
function getContentScore(element: Element): number {
let score = 0;
const text = element.textContent || "";
const textLength = text.trim().length;
score += Math.min(textLength / 100, 50);
score += element.querySelectorAll("p").length * 3;
score += element.querySelectorAll("h1, h2, h3, h4, h5, h6").length * 2;
score += element.querySelectorAll("img").length;
score -= element.querySelectorAll("a").length * 0.5;
score -= element.querySelectorAll("li").length * 0.2;
const linkDensity = getLinkDensity(element);
if (linkDensity > 0.5) score -= 30;
else if (linkDensity > 0.3) score -= 15;
const className = typeof element.className === "string" ? element.className : "";
const classAndId = `${className} ${element.id || ""}`;
if (/article|content|post|body|main|entry/i.test(classAndId)) score += 25;
if (/comment|sidebar|footer|nav|menu|header|widget|ad/i.test(classAndId)) score -= 25;
return score;
}
function looksLikeNavigation(element: Element): boolean {
const linkDensity = getLinkDensity(element);
if (linkDensity > 0.5) return true;
const listItems = element.querySelectorAll("li");
const links = element.querySelectorAll("a");
return listItems.length > 5 && links.length > listItems.length * 0.8;
}
function removeElements(document: Document, selectors: string[]): void {
for (const selector of selectors) {
try {
document.querySelectorAll(selector).forEach((element: Element) => element.remove());
} catch {
// Ignore unsupported selectors from linkedom/jsdom differences.
}
}
}
function removeWithProtection(
document: Document,
selectorsToRemove: string[],
protectedSelectors: string[]
): void {
for (const selector of selectorsToRemove) {
try {
document.querySelectorAll(selector).forEach((element: Element) => {
const isProtected = protectedSelectors.some((protectedSelector) => {
try {
return element.matches(protectedSelector);
} catch {
return false;
}
});
if (isProtected) return;
const containsProtected = protectedSelectors.some((protectedSelector) => {
try {
return element.querySelector(protectedSelector) !== null;
} catch {
return false;
}
});
if (containsProtected) return;
element.remove();
});
} catch {
// Ignore unsupported selectors from linkedom/jsdom differences.
}
}
}
function findMainContent(document: Document): Element | null {
const isValidContent = (element: Element | null): element is Element => {
if (!element) return false;
const text = element.textContent || "";
if (text.trim().length < 100) return false;
return !looksLikeNavigation(element);
};
const main = document.querySelector("main");
if (isValidContent(main) && getLinkDensity(main) < 0.4) return main;
const roleMain = document.querySelector('[role="main"]');
if (isValidContent(roleMain) && getLinkDensity(roleMain) < 0.4) return roleMain;
const articles = document.querySelectorAll("article");
if (articles.length === 1 && isValidContent(articles[0] ?? null)) {
return articles[0] ?? null;
}
const contentSelectors = [
"#content",
"#main-content",
"#main",
".content",
".main-content",
".post-content",
".article-content",
".entry-content",
".page-content",
".article-body",
".post-body",
".story-content",
".blog-content",
];
for (const selector of contentSelectors) {
try {
const element = document.querySelector(selector);
if (isValidContent(element) && getLinkDensity(element) < 0.4) {
return element;
}
} catch {
// Ignore invalid selectors.
}
}
const candidates: Array<{ element: Element; score: number }> = [];
const containers = document.querySelectorAll("div, section, article");
containers.forEach((element: Element) => {
const text = element.textContent || "";
if (text.trim().length < 200) return;
const score = getContentScore(element);
if (score > 0) {
candidates.push({ element, score });
}
});
candidates.sort((left, right) => right.score - left.score);
if ((candidates[0]?.score ?? 0) > 20) {
return candidates[0]?.element ?? null;
}
return null;
}
function removeBase64ImagesFromDocument(document: Document): void {
document.querySelectorAll("img[src^='data:']").forEach((element: Element) => {
element.remove();
});
document.querySelectorAll("[style*='data:image']").forEach((element: Element) => {
const style = element.getAttribute("style");
if (!style) return;
const cleanedStyle = style.replace(
/background(-image)?:\s*url\([^)]*data:image[^)]*\)[^;]*;?/gi,
""
);
if (cleanedStyle.trim()) {
element.setAttribute("style", cleanedStyle);
} else {
element.removeAttribute("style");
}
});
document.querySelectorAll("source[src^='data:'], source[srcset*='data:']").forEach((element: Element) => {
element.remove();
});
}
function makeAbsoluteUrl(value: string, baseUrl: string): string | null {
try {
return new URL(value, baseUrl).toString();
} catch {
return null;
}
}
function convertRelativeUrls(document: Document, baseUrl: string): void {
document.querySelectorAll("[src]").forEach((element: Element) => {
const src = element.getAttribute("src");
if (!src || src.startsWith("http") || src.startsWith("//") || src.startsWith("data:")) return;
const absolute = makeAbsoluteUrl(src, baseUrl);
if (absolute) element.setAttribute("src", absolute);
});
document.querySelectorAll("[href]").forEach((element: Element) => {
const href = element.getAttribute("href");
if (
!href ||
href.startsWith("http") ||
href.startsWith("//") ||
href.startsWith("#") ||
href.startsWith("mailto:") ||
href.startsWith("tel:") ||
href.startsWith("javascript:")
) {
return;
}
const absolute = makeAbsoluteUrl(href, baseUrl);
if (absolute) element.setAttribute("href", absolute);
});
}
export function cleanHtml(html: string, baseUrl: string, options: CleaningOptions = {}): string {
const {
removeAds = true,
removeBase64Images = true,
onlyMainContent = true,
includeTags,
excludeTags,
} = options;
const { document } = parseHTML(html);
removeElements(document, ALWAYS_REMOVE_SELECTORS);
removeElements(document, OVERLAY_SELECTORS);
if (removeAds) {
removeElements(document, AD_SELECTORS);
}
if (excludeTags?.length) {
removeElements(document, excludeTags);
}
if (onlyMainContent) {
removeWithProtection(document, NAVIGATION_SELECTORS, FORCE_INCLUDE_SELECTORS);
const mainContent = findMainContent(document);
if (mainContent && document.body) {
const clone = mainContent.cloneNode(true) as Element;
document.body.innerHTML = "";
document.body.appendChild(clone);
}
}
if (includeTags?.length && document.body) {
const matchedElements: Element[] = [];
for (const selector of includeTags) {
try {
document.querySelectorAll(selector).forEach((element: Element) => {
matchedElements.push(element.cloneNode(true) as Element);
});
} catch {
// Ignore invalid selectors.
}
}
if (matchedElements.length > 0) {
document.body.innerHTML = "";
matchedElements.forEach((element) => document.body?.appendChild(element));
}
}
if (removeBase64Images) {
removeBase64ImagesFromDocument(document);
}
const walker = document.createTreeWalker(document, 128);
const comments: Node[] = [];
while (walker.nextNode()) {
comments.push(walker.currentNode);
}
comments.forEach((comment) => comment.parentNode?.removeChild(comment));
convertRelativeUrls(document, baseUrl);
return document.documentElement?.outerHTML || html;
}
export function cleanContent(html: string, baseUrl: string, options: CleaningOptions = {}): string {
return cleanHtml(html, baseUrl, options);
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractContent } from "./html-to-markdown.js";
const EMBEDDED_IMAGE_HTML = `<!doctype html>
<html>
<body>
<main>
<article>
<h1>Embedded Image Story</h1>
<p>
This paragraph is intentionally long enough to satisfy the extractor thresholds so the
resulting markdown keeps the main article body and the embedded image reference.
</p>
<img src="data:image/png;base64,AAAA" alt="inline">
</article>
</main>
</body>
</html>`;
test("extractContent preserves base64 images when requested for media download", async () => {
const result = await extractContent(EMBEDDED_IMAGE_HTML, "https://example.com/embedded", {
preserveBase64Images: true,
});
assert.match(result.markdown, /!\[inline\]\(data:image\/png;base64,AAAA\)/);
});
@@ -13,10 +13,15 @@ import {
shouldCompareWithLegacy, shouldCompareWithLegacy,
} from "./legacy-converter.js"; } from "./legacy-converter.js";
import { tryUrlRuleParsers } from "./parsers/index.js"; import { tryUrlRuleParsers } from "./parsers/index.js";
import { cleanContent } from "./content-cleaner.js";
export type { ConversionResult, PageMetadata }; export type { ConversionResult, PageMetadata };
export { createMarkdownDocument, formatMetadataYaml }; export { createMarkdownDocument, formatMetadataYaml };
export interface ExtractContentOptions {
preserveBase64Images?: boolean;
}
export const absolutizeUrlsScript = String.raw` export const absolutizeUrlsScript = String.raw`
(function() { (function() {
const baseUrl = document.baseURI || location.href; const baseUrl = document.baseURI || location.href;
@@ -85,7 +90,10 @@ export const absolutizeUrlsScript = String.raw`
absAttr(htmlClone, "video[poster]", "poster"); absAttr(htmlClone, "video[poster]", "poster");
absSrcset(htmlClone, "img[srcset], source[srcset]"); absSrcset(htmlClone, "img[srcset], source[srcset]");
return { html: "<!doctype html>\n" + htmlClone.outerHTML }; return {
html: "<!doctype html>\n" + htmlClone.outerHTML,
finalUrl: location.href,
};
})() })()
`; `;
@@ -102,7 +110,11 @@ function shouldPreferDefuddle(result: ConversionResult): boolean {
return /^##?\s+transcript\b/im.test(result.markdown); return /^##?\s+transcript\b/im.test(result.markdown);
} }
export async function extractContent(html: string, url: string): Promise<ConversionResult> { export async function extractContent(
html: string,
url: string,
options: ExtractContentOptions = {}
): Promise<ConversionResult> {
const capturedAt = new Date().toISOString(); const capturedAt = new Date().toISOString();
const baseMetadata = extractMetadataFromHtml(html, url, capturedAt); const baseMetadata = extractMetadataFromHtml(html, url, capturedAt);
@@ -111,14 +123,23 @@ export async function extractContent(html: string, url: string): Promise<Convers
return specializedResult; return specializedResult;
} }
const defuddleResult = await tryDefuddleConversion(html, url, baseMetadata); let cleanedHtml = html;
try {
cleanedHtml = cleanContent(html, url, {
removeBase64Images: !options.preserveBase64Images,
});
} catch {
cleanedHtml = html;
}
const defuddleResult = await tryDefuddleConversion(cleanedHtml, url, baseMetadata);
if (defuddleResult.ok) { if (defuddleResult.ok) {
if (shouldPreferDefuddle(defuddleResult.result)) { if (shouldPreferDefuddle(defuddleResult.result)) {
return defuddleResult.result; return { ...defuddleResult.result, rawHtml: html };
} }
if (shouldCompareWithLegacy(defuddleResult.result.markdown)) { if (shouldCompareWithLegacy(defuddleResult.result.markdown)) {
const legacyResult = convertWithLegacyExtractor(html, baseMetadata); const legacyResult = convertWithLegacyExtractor(html, baseMetadata, cleanedHtml);
const legacyScore = scoreMarkdownQuality(legacyResult.markdown); const legacyScore = scoreMarkdownQuality(legacyResult.markdown);
const defuddleScore = scoreMarkdownQuality(defuddleResult.result.markdown); const defuddleScore = scoreMarkdownQuality(defuddleResult.result.markdown);
@@ -130,10 +151,10 @@ export async function extractContent(html: string, url: string): Promise<Convers
} }
} }
return defuddleResult.result; return { ...defuddleResult.result, rawHtml: html };
} }
const fallbackResult = convertWithLegacyExtractor(html, baseMetadata); const fallbackResult = convertWithLegacyExtractor(html, baseMetadata, cleanedHtml);
return { return {
...fallbackResult, ...fallbackResult,
fallbackReason: defuddleResult.reason, fallbackReason: defuddleResult.reason,
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cleanContent } from "./content-cleaner.js";
import { convertWithLegacyExtractor } from "./legacy-converter.js";
import { extractMetadataFromHtml } from "./markdown-conversion-shared.js";
const CAPTURED_AT = "2026-03-24T03:00:00.000Z";
const NEXT_DATA_HTML = `<!doctype html>
<html>
<head>
<title>Hydrated Story</title>
</head>
<body>
<div class="cookie-banner">Accept cookies</div>
<main>
<p>Short teaser text that should not win over the structured article payload.</p>
</main>
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"article": {
"title": "Hydrated Story",
"description": "A structured article payload from Next.js",
"body": "<p>The full article lives in __NEXT_DATA__ and should still be extracted even when the cleaned HTML removes scripts before the selector and readability passes run.</p><p>A second paragraph keeps the content comfortably above the minimum extraction threshold and proves the legacy extractor still has access to the original structured payload.</p>"
}
}
}
}
</script>
</body>
</html>`;
test("legacy extractor still uses original __NEXT_DATA__ after HTML cleaning", () => {
const url = "https://example.com/posts/hydrated-story";
const baseMetadata = extractMetadataFromHtml(NEXT_DATA_HTML, url, CAPTURED_AT);
const cleanedHtml = cleanContent(NEXT_DATA_HTML, url);
const result = convertWithLegacyExtractor(NEXT_DATA_HTML, baseMetadata, cleanedHtml);
assert.equal(result.conversionMethod, "legacy:next-data");
assert.match(result.markdown, /The full article lives in .*NEXT.*DATA/);
assert.match(result.markdown, /A second paragraph keeps the content comfortably above the minimum extraction threshold/);
assert.doesNotMatch(result.markdown, /Short teaser text that should not win/);
assert.equal(result.rawHtml, NEXT_DATA_HTML);
});
@@ -336,29 +336,32 @@ function tryNextDataExtraction(document: Document): ExtractionCandidate | null {
function buildReadabilityCandidate( function buildReadabilityCandidate(
article: ReturnType<Readability["parse"]>, article: ReturnType<Readability["parse"]>,
document: Document, referenceDocument: Document,
method: string method: string
): ExtractionCandidate | null { ): ExtractionCandidate | null {
const textContent = article?.textContent?.trim() ?? ""; const textContent = article?.textContent?.trim() ?? "";
if (textContent.length < MIN_CONTENT_LENGTH) return null; if (textContent.length < MIN_CONTENT_LENGTH) return null;
return { return {
title: pickString(article?.title, extractTitle(document)), title: pickString(article?.title, extractTitle(referenceDocument)),
byline: pickString((article as { byline?: string } | null)?.byline), byline: pickString((article as { byline?: string } | null)?.byline),
excerpt: pickString(article?.excerpt, generateExcerpt(null, textContent)), excerpt: pickString(article?.excerpt, generateExcerpt(null, textContent)),
published: pickString((article as { publishedTime?: string } | null)?.publishedTime, extractPublishedTime(document)), published: pickString(
(article as { publishedTime?: string } | null)?.publishedTime,
extractPublishedTime(referenceDocument)
),
html: article?.content ? sanitizeHtml(article.content) : null, html: article?.content ? sanitizeHtml(article.content) : null,
textContent, textContent,
method, method,
}; };
} }
function tryReadability(document: Document): ExtractionCandidate | null { function tryReadability(document: Document, referenceDocument: Document = document): ExtractionCandidate | null {
try { try {
const strictClone = document.cloneNode(true) as Document; const strictClone = document.cloneNode(true) as Document;
const strictResult = buildReadabilityCandidate( const strictResult = buildReadabilityCandidate(
new Readability(strictClone).parse(), new Readability(strictClone).parse(),
document, referenceDocument,
"readability" "readability"
); );
if (strictResult) return strictResult; if (strictResult) return strictResult;
@@ -366,7 +369,7 @@ function tryReadability(document: Document): ExtractionCandidate | null {
const relaxedClone = document.cloneNode(true) as Document; const relaxedClone = document.cloneNode(true) as Document;
return buildReadabilityCandidate( return buildReadabilityCandidate(
new Readability(relaxedClone, { charThreshold: 120 }).parse(), new Readability(relaxedClone, { charThreshold: 120 }).parse(),
document, referenceDocument,
"readability-relaxed" "readability-relaxed"
); );
} catch { } catch {
@@ -471,14 +474,15 @@ function pickBestCandidate(candidates: ExtractionCandidate[]): ExtractionCandida
return ranked[0]; return ranked[0];
} }
function extractFromHtml(html: string): ExtractionCandidate | null { function extractFromHtml(html: string, cleanedHtml: string = html): ExtractionCandidate | null {
const document = parseDocument(html); const originalDocument = parseDocument(html);
const cleanedDocument = parseDocument(cleanedHtml);
const readabilityCandidate = tryReadability(document); const readabilityCandidate = tryReadability(cleanedDocument, originalDocument);
const nextDataCandidate = tryNextDataExtraction(document); const nextDataCandidate = tryNextDataExtraction(originalDocument);
const jsonLdCandidate = tryJsonLdExtraction(document); const jsonLdCandidate = tryJsonLdExtraction(originalDocument);
const selectorCandidate = trySelectorExtraction(document); const selectorCandidate = trySelectorExtraction(cleanedDocument);
const bodyCandidate = tryBodyExtraction(document); const bodyCandidate = tryBodyExtraction(cleanedDocument);
const candidates = [ const candidates = [
readabilityCandidate, readabilityCandidate,
@@ -493,8 +497,8 @@ function extractFromHtml(html: string): ExtractionCandidate | null {
return { return {
...winner, ...winner,
title: winner.title ?? extractTitle(document), title: winner.title ?? extractTitle(originalDocument),
published: winner.published ?? extractPublishedTime(document), published: winner.published ?? extractPublishedTime(originalDocument),
excerpt: winner.excerpt ?? generateExcerpt(null, winner.textContent), excerpt: winner.excerpt ?? generateExcerpt(null, winner.textContent),
}; };
} }
@@ -610,12 +614,16 @@ export function shouldCompareWithLegacy(markdown: string): boolean {
); );
} }
export function convertWithLegacyExtractor(html: string, baseMetadata: PageMetadata): ConversionResult { export function convertWithLegacyExtractor(
const extracted = extractFromHtml(html); html: string,
baseMetadata: PageMetadata,
cleanedHtml: string = html
): ConversionResult {
const extracted = extractFromHtml(html, cleanedHtml);
let markdown = extracted?.html ? convertHtmlFragmentToMarkdown(extracted.html) : ""; let markdown = extracted?.html ? convertHtmlFragmentToMarkdown(extracted.html) : "";
if (!markdown.trim()) { if (!markdown.trim()) {
markdown = extracted?.textContent?.trim() || fallbackPlainText(html); markdown = extracted?.textContent?.trim() || fallbackPlainText(cleanedHtml);
} }
return { return {
+121 -16
View File
@@ -29,10 +29,33 @@ interface Args {
wait: boolean; wait: boolean;
timeout: number; timeout: number;
downloadMedia: boolean; downloadMedia: boolean;
browserMode: BrowserMode;
} }
type BrowserMode = "auto" | "headless" | "headed";
interface CaptureAttemptOptions {
headless: boolean;
wait: boolean;
existingPort?: number;
waitPrompt?: string;
}
interface CaptureSnapshot {
html: string;
finalUrl: string;
}
const BROWSER_MODES = new Set<BrowserMode>(["auto", "headless", "headed"]);
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const args: Args = { url: "", wait: false, timeout: DEFAULT_TIMEOUT_MS, downloadMedia: false }; const args: Args = {
url: "",
wait: false,
timeout: DEFAULT_TIMEOUT_MS,
downloadMedia: false,
browserMode: "auto",
};
for (let i = 2; i < argv.length; i++) { for (let i = 2; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === "--wait" || arg === "-w") { if (arg === "--wait" || arg === "-w") {
@@ -45,6 +68,12 @@ function parseArgs(argv: string[]): Args {
args.outputDir = argv[++i]; args.outputDir = argv[++i];
} else if (arg === "--download-media") { } else if (arg === "--download-media") {
args.downloadMedia = true; args.downloadMedia = true;
} else if (arg === "--browser") {
args.browserMode = (argv[++i] as BrowserMode | undefined) ?? "auto";
} else if (arg === "--headless") {
args.browserMode = "headless";
} else if (arg === "--headed" || arg === "--noheadless" || arg === "--no-headless") {
args.browserMode = "headed";
} else if (!arg.startsWith("-") && !args.url) { } else if (!arg.startsWith("-") && !args.url) {
args.url = arg; args.url = arg;
} }
@@ -194,21 +223,28 @@ async function generateOutputPath(url: string, title: string, outputDir?: string
return path.join(dataDir, domain, timestampSlug, `${timestampSlug}.md`); return path.join(dataDir, domain, timestampSlug, `${timestampSlug}.md`);
} }
async function waitForUserSignal(): Promise<void> { function defaultWaitPrompt(): string {
console.log("Page opened. Press Enter when ready to capture..."); return "A browser window has been opened. If the page requires login or verification, complete it first, then press Enter to capture.";
}
async function waitForUserSignal(prompt: string): Promise<void> {
console.log(prompt);
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
rl.once("line", () => { rl.close(); resolve(); }); rl.once("line", () => { rl.close(); resolve(); });
}); });
} }
async function captureUrl(args: Args): Promise<ConversionResult> { async function captureUrlOnce(args: Args, options: CaptureAttemptOptions): Promise<ConversionResult> {
const existingPort = await findExistingChromePort(); const reusing = options.existingPort !== undefined;
const reusing = existingPort !== null; const port = options.existingPort ?? await getFreePort();
const port = existingPort ?? await getFreePort(); const chrome = reusing ? null : await launchChrome(args.url, port, options.headless);
const chrome = reusing ? null : await launchChrome(args.url, port, false);
if (reusing) console.log(`Reusing existing Chrome on port ${port}`); if (reusing) {
console.log(`Reusing existing Chrome on port ${port}`);
} else {
console.log(`Launching Chrome (${options.headless ? "headless" : "headed"})...`);
}
let cdp: CdpConnection | null = null; let cdp: CdpConnection | null = null;
let targetId: string | null = null; let targetId: string | null = null;
@@ -235,8 +271,8 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
await cdp.send("Page.enable", {}, { sessionId }); await cdp.send("Page.enable", {}, { sessionId });
} }
if (args.wait) { if (options.wait) {
await waitForUserSignal(); await waitForUserSignal(options.waitPrompt ?? defaultWaitPrompt());
} else { } else {
console.log("Waiting for page to load..."); console.log("Waiting for page to load...");
await Promise.race([ await Promise.race([
@@ -251,11 +287,12 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
} }
console.log("Capturing page content..."); console.log("Capturing page content...");
const { html } = await evaluateScript<{ html: string }>( const snapshot = await evaluateScript<CaptureSnapshot>(
cdp, sessionId, absolutizeUrlsScript, args.timeout cdp, sessionId, absolutizeUrlsScript, args.timeout
); );
return await extractContent(snapshot.html, snapshot.finalUrl || args.url, {
return await extractContent(html, args.url); preserveBase64Images: args.downloadMedia,
});
} finally { } finally {
if (reusing) { if (reusing) {
if (cdp && targetId) { if (cdp && targetId) {
@@ -272,10 +309,67 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
} }
} }
async function runHeadedFlow(
args: Args,
options: { existingPort?: number; wait: boolean; waitPrompt?: string }
): Promise<ConversionResult> {
return await captureUrlOnce(args, {
headless: false,
wait: options.wait,
existingPort: options.existingPort,
waitPrompt: options.waitPrompt,
});
}
async function captureUrl(args: Args): Promise<ConversionResult> {
const existingPort = await findExistingChromePort();
if (existingPort !== null) {
console.log("Found an existing Chrome session for this profile. Reusing it instead of launching a new browser.");
return await runHeadedFlow(args, {
existingPort,
wait: args.wait,
waitPrompt: args.wait ? defaultWaitPrompt() : undefined,
});
}
if (args.browserMode === "headless") {
return await captureUrlOnce(args, { headless: true, wait: false });
}
if (args.browserMode === "headed") {
return await runHeadedFlow(args, {
wait: args.wait,
waitPrompt: args.wait ? defaultWaitPrompt() : undefined,
});
}
if (args.wait) {
return await runHeadedFlow(args, {
wait: true,
waitPrompt: defaultWaitPrompt(),
});
}
try {
return await captureUrlOnce(args, { headless: true, wait: false });
} catch (error) {
const headlessMessage = error instanceof Error ? error.message : String(error);
console.warn(`Headless capture failed: ${headlessMessage}`);
console.log("Retrying with a visible browser window...");
try {
return await runHeadedFlow(args, { wait: false });
} catch (headedError) {
const headedMessage = headedError instanceof Error ? headedError.message : String(headedError);
throw new Error(`Headless capture failed (${headlessMessage}); headed retry failed (${headedMessage})`);
}
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
if (!args.url) { if (!args.url) {
console.error("Usage: bun main.ts <url> [-o output.md] [--output-dir dir] [--wait] [--timeout ms] [--download-media]"); console.error("Usage: bun main.ts <url> [-o output.md] [--output-dir dir] [--wait] [--browser auto|headless|headed] [--timeout ms] [--download-media]");
process.exit(1); process.exit(1);
} }
@@ -286,6 +380,16 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
if (!BROWSER_MODES.has(args.browserMode)) {
console.error(`Invalid --browser mode: ${args.browserMode}. Expected auto, headless, or headed.`);
process.exit(1);
}
if (args.wait && args.browserMode === "headless") {
console.error("Error: --wait requires a visible browser. Use --browser auto or --browser headed.");
process.exit(1);
}
if (args.output) { if (args.output) {
const stat = await import("node:fs").then(fs => fs.statSync(args.output!, { throwIfNoEntry: false })); const stat = await import("node:fs").then(fs => fs.statSync(args.output!, { throwIfNoEntry: false }));
if (stat?.isDirectory()) { if (stat?.isDirectory()) {
@@ -296,6 +400,7 @@ async function main(): Promise<void> {
console.log(`Fetching: ${args.url}`); console.log(`Fetching: ${args.url}`);
console.log(`Mode: ${args.wait ? "wait" : "auto"}`); console.log(`Mode: ${args.wait ? "wait" : "auto"}`);
console.log(`Browser: ${args.browserMode}`);
let outputPath: string; let outputPath: string;
let htmlSnapshotPath: string | null = null; let htmlSnapshotPath: string | null = null;
@@ -306,7 +411,7 @@ async function main(): Promise<void> {
try { try {
const result = await captureUrl(args); const result = await captureUrl(args);
document = createMarkdownDocument(result); document = createMarkdownDocument(result);
outputPath = args.output || await generateOutputPath(args.url, result.metadata.title, args.outputDir, document); outputPath = args.output || await generateOutputPath(result.metadata.url || args.url, result.metadata.title, args.outputDir, document);
const outputDir = path.dirname(outputPath); const outputDir = path.dirname(outputPath);
htmlSnapshotPath = deriveHtmlSnapshotPath(outputPath); htmlSnapshotPath = deriveHtmlSnapshotPath(outputPath);
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { localizeMarkdownMedia } from "./media-localizer.js";
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0ioAAAAASUVORK5CYII=";
test("localizeMarkdownMedia saves embedded base64 images into imgs directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "url-to-markdown-media-"));
const dataUri = `data:image/png;base64,${PNG_1X1_BASE64}`;
const markdown = [
"---",
`coverImage: "${dataUri}"`,
"---",
"",
"# Embedded Image",
"",
`![inline](${dataUri})`,
"",
].join("\n");
const result = await localizeMarkdownMedia(markdown, {
markdownPath: path.join(tempDir, "post.md"),
});
assert.equal(result.downloadedImages, 1);
assert.equal(result.downloadedVideos, 0);
assert.match(result.markdown, /coverImage: "imgs\/img-001\.png"/);
assert.match(result.markdown, /!\[inline\]\(imgs\/img-001\.png\)/);
const files = await readdir(path.join(tempDir, "imgs"));
assert.deepEqual(files, ["img-001.png"]);
const bytes = await readFile(path.join(tempDir, "imgs", "img-001.png"));
assert.equal(bytes.length, Buffer.from(PNG_1X1_BASE64, "base64").length);
});
@@ -3,10 +3,12 @@ import { mkdir, writeFile } from "node:fs/promises";
type MediaKind = "image" | "video"; type MediaKind = "image" | "video";
type MediaHint = "image" | "unknown"; type MediaHint = "image" | "unknown";
type MediaSource = "remote" | "data";
type MarkdownLinkCandidate = { type MarkdownLinkCandidate = {
url: string; url: string;
hint: MediaHint; hint: MediaHint;
source: MediaSource;
}; };
export type LocalizeMarkdownMediaOptions = { export type LocalizeMarkdownMediaOptions = {
@@ -22,8 +24,9 @@ export type LocalizeMarkdownMediaResult = {
videoDir: string | null; videoDir: string | null;
}; };
const MARKDOWN_LINK_RE = /(!?\[[^\]\n]*\])\((<)?(https?:\/\/[^)\s>]+)(>)?\)/g; const MARKDOWN_LINK_RE =
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m; /(!?\[[^\]\n]*\])\((<)?((?:https?:\/\/[^)\s>]+)|(?:data:[^)>\s]+))(>)?\)/g;
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")((?:https?:\/\/[^"]+)|(?:data:[^"]+))(")/m;
const IMAGE_EXTENSIONS = new Set([ const IMAGE_EXTENSIONS = new Set([
"jpg", "jpg",
@@ -86,6 +89,10 @@ function resolveExtensionFromUrl(rawUrl: string): string | undefined {
return undefined; return undefined;
} }
function resolveExtensionFromContentType(contentType: string): string | undefined {
return normalizeExtension(MIME_EXTENSION_MAP[contentType]);
}
function resolveKindFromContentType(contentType: string): MediaKind | undefined { function resolveKindFromContentType(contentType: string): MediaKind | undefined {
if (!contentType) return undefined; if (!contentType) return undefined;
if (contentType.startsWith("image/")) return "image"; if (contentType.startsWith("image/")) return "image";
@@ -124,7 +131,7 @@ function resolveOutputExtension(
extension: string | undefined, extension: string | undefined,
kind: MediaKind kind: MediaKind
): string { ): string {
const extFromMime = normalizeExtension(MIME_EXTENSION_MAP[contentType]); const extFromMime = resolveExtensionFromContentType(contentType);
if (extFromMime) return extFromMime; if (extFromMime) return extFromMime;
const normalizedExt = normalizeExtension(extension); const normalizedExt = normalizeExtension(extension);
@@ -150,6 +157,10 @@ function sanitizeFileSegment(input: string): string {
} }
function resolveFileStem(rawUrl: string, extension: string): string { function resolveFileStem(rawUrl: string, extension: string): string {
if (isDataUri(rawUrl)) {
return "";
}
try { try {
const parsed = new URL(rawUrl); const parsed = new URL(rawUrl);
const base = path.posix.basename(parsed.pathname); const base = path.posix.basename(parsed.pathname);
@@ -172,6 +183,26 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
return `${prefix}-${serial}${suffix}.${extension}`; return `${prefix}-${serial}${suffix}.${extension}`;
} }
function isDataUri(value: string): boolean {
return value.startsWith("data:");
}
function parseBase64DataUri(rawUrl: string): { contentType: string; bytes: Buffer } | null {
const match = rawUrl.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
if (!match?.[1] || !match[2]) return null;
const contentType = normalizeContentType(match[1]);
if (!contentType) return null;
try {
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
if (bytes.length === 0) return null;
return { contentType, bytes };
} catch {
return null;
}
}
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] { function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
const candidates: MarkdownLinkCandidate[] = []; const candidates: MarkdownLinkCandidate[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -181,7 +212,11 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE); const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
if (coverMatch?.[2] && !seen.has(coverMatch[2])) { if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
seen.add(coverMatch[2]); seen.add(coverMatch[2]);
candidates.push({ url: coverMatch[2], hint: "image" }); candidates.push({
url: coverMatch[2],
hint: "image",
source: isDataUri(coverMatch[2]) ? "data" : "remote",
});
} }
} }
@@ -195,6 +230,7 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
candidates.push({ candidates.push({
url: rawUrl, url: rawUrl,
hint: label.startsWith("![") ? "image" : "unknown", hint: label.startsWith("![") ? "image" : "unknown",
source: isDataUri(rawUrl) ? "data" : "remote",
}); });
} }
@@ -244,24 +280,45 @@ export async function localizeMarkdownMedia(
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const response = await fetch(candidate.url, { let sourceUrl = candidate.url;
method: "GET", let contentType = "";
redirect: "follow", let extension: string | undefined;
headers: { let kind: MediaKind | undefined;
"user-agent": DOWNLOAD_USER_AGENT, let bytes: Buffer | null = null;
},
});
if (!response.ok) { if (candidate.source === "data") {
log(`[url-to-markdown] Skip media (${response.status}): ${candidate.url}`); const parsed = parseBase64DataUri(candidate.url);
continue; if (!parsed) {
log("[url-to-markdown] Skip embedded media: unsupported or invalid data URI");
continue;
}
contentType = parsed.contentType;
extension = resolveExtensionFromContentType(contentType);
kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
bytes = parsed.bytes;
} else {
const response = await fetch(candidate.url, {
method: "GET",
redirect: "follow",
headers: {
"user-agent": DOWNLOAD_USER_AGENT,
},
});
if (!response.ok) {
log(`[url-to-markdown] Skip media (${response.status}): ${candidate.url}`);
continue;
}
sourceUrl = response.url || candidate.url;
contentType = normalizeContentType(response.headers.get("content-type"));
extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
bytes = Buffer.from(await response.arrayBuffer());
} }
const sourceUrl = response.url || candidate.url; if (!kind || !bytes) {
const contentType = normalizeContentType(response.headers.get("content-type"));
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
if (!kind) {
continue; continue;
} }
@@ -274,7 +331,6 @@ export async function localizeMarkdownMedia(
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension); const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
const absolutePath = path.join(targetDir, fileName); const absolutePath = path.join(targetDir, fileName);
const relativePath = path.posix.join(dirName, fileName); const relativePath = path.posix.join(dirName, fileName);
const bytes = Buffer.from(await response.arrayBuffer());
await writeFile(absolutePath, bytes); await writeFile(absolutePath, bytes);
replacements.set(candidate.url, relativePath); replacements.set(candidate.url, relativePath);
@@ -305,6 +361,7 @@ export function countRemoteMedia(markdown: string): { images: number; videos: nu
let images = 0; let images = 0;
let videos = 0; let videos = 0;
for (const c of candidates) { for (const c of candidates) {
if (c.source !== "remote") continue;
const ext = resolveExtensionFromUrl(c.url); const ext = resolveExtensionFromUrl(c.url);
const kind = resolveKindFromExtension(ext); const kind = resolveKindFromExtension(ext);
if (kind === "video") { if (kind === "video") {
@@ -5,7 +5,7 @@
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"defuddle": "^0.12.0", "defuddle": "^0.14.0",
"jsdom": "^24.1.3", "jsdom": "^24.1.3",
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"turndown": "^7.2.2", "turndown": "^7.2.2",
+10 -1
View File
@@ -13,7 +13,7 @@ metadata:
# YouTube Transcript # YouTube Transcript
Downloads transcripts (subtitles/captions) from YouTube videos. Works with both manually created and auto-generated transcripts. No API key or browser required — uses YouTube's InnerTube API directly. Downloads transcripts (subtitles/captions) from YouTube videos. Works with both manually created and auto-generated transcripts. No API key or browser required — uses YouTube's InnerTube API directly and automatically falls back to `yt-dlp` when YouTube blocks the direct API path.
Fetches video metadata and cover image on first run, caches raw data for fast re-formatting. Fetches video metadata and cover image on first run, caches raw data for fast re-formatting.
@@ -75,6 +75,12 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --refresh
| `-o, --output <path>` | Save to specific file path | auto-generated | | `-o, --output <path>` | Save to specific file path | auto-generated |
| `--output-dir <dir>` | Base output directory | `youtube-transcript` | | `--output-dir <dir>` | Base output directory | `youtube-transcript` |
## Optional Environment Variables
| Variable | Description |
|----------|-------------|
| `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` | Passed to `yt-dlp --cookies-from-browser` during fallback, e.g. `chrome`, `safari`, `firefox`, or `chrome:Profile 1` |
## Input Formats ## Input Formats
Accepts any of these as video input: Accepts any of these as video input:
@@ -121,6 +127,8 @@ On first fetch, the script saves:
Subsequent runs for the same video use cached data (no network calls). Use `--refresh` to force re-fetch. If a different language is requested, the cache is automatically refreshed. Subsequent runs for the same video use cached data (no network calls). Use `--refresh` to force re-fetch. If a different language is requested, the cache is automatically refreshed.
When YouTube returns anti-bot / blocked responses on the direct InnerTube path, the script retries with alternate client identities and then falls back to `yt-dlp` if available. If fallback is needed but `yt-dlp` is unavailable, the agent should decide how to make `yt-dlp` available and continue rather than pushing the installation decision to the user.
SRT output (`--format srt`) is generated from `transcript-raw.json`. Text/markdown output uses `transcript-sentences.json` for natural sentence boundaries. SRT output (`--format srt`) is generated from `transcript-raw.json`. Text/markdown output uses `transcript-sentences.json` for natural sentence boundaries.
## Workflow ## Workflow
@@ -175,3 +183,4 @@ When `--speakers` is used, `--chapters` is implied — the processed output alwa
| Video unavailable | Video deleted, private, or region-locked | | Video unavailable | Video deleted, private, or region-locked |
| IP blocked | Too many requests, try again later | | IP blocked | Too many requests, try again later |
| Age restricted | Video requires login for age verification | | Age restricted | Video requires login for age verification |
| bot detected | The script retries alternate clients and then `yt-dlp`; if fallback tooling is missing, the agent should resolve that itself, otherwise if it still fails try `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER=safari` (or your browser) |
@@ -0,0 +1,125 @@
import test from "node:test";
import assert from "node:assert/strict";
import { findTranscript, parseTranscriptJson3, parseWebVtt } from "./transcript.ts";
import { buildTranscriptListFromYtDlp, resolveVideoSource, selectYtDlpTrack } from "./youtube.ts";
test("selectYtDlpTrack prefers json3 over xml and vtt", () => {
const track = selectYtDlpTrack([
{ ext: "vtt", url: "https://example.com/subs.vtt" },
{ ext: "srv3", url: "https://example.com/subs.srv3" },
{ ext: "json3", url: "https://example.com/subs.json3" },
]);
assert.equal(track?.ext, "json3");
});
test("buildTranscriptListFromYtDlp keeps manual and generated tracks separate", () => {
const transcripts = buildTranscriptListFromYtDlp({
subtitles: {
en: [
{ ext: "json3", url: "https://example.com/en.json3", name: "English" },
],
},
automatic_captions: {
"zh-Hans": [
{ ext: "json3", url: "https://example.com/zh.json3", name: "Chinese (Simplified)" },
],
},
});
assert.equal(transcripts.length, 2);
assert.equal(transcripts[0].isGenerated, false);
assert.equal(transcripts[1].isGenerated, true);
assert.equal(transcripts[0].translationLanguages[0]?.languageCode, "zh-Hans");
const translated = findTranscript(transcripts, ["zh-Hans"], false, false);
assert.equal(translated.languageCode, "zh-Hans");
assert.equal(translated.isGenerated, true);
});
test("parseTranscriptJson3 reads youtube timedtext json3 payloads", () => {
const snippets = parseTranscriptJson3(JSON.stringify({
events: [
{
tStartMs: 80,
dDurationMs: 3120,
segs: [{ utf8: "hello\nworld" }],
},
{
tStartMs: 4000,
dDurationMs: 1800,
segs: [{ utf8: "again" }],
},
],
}));
assert.deepEqual(snippets, [
{ text: "hello world", start: 0.08, duration: 3.12 },
{ text: "again", start: 4, duration: 1.8 },
]);
});
test("parseWebVtt strips tags and cue settings", () => {
const snippets = parseWebVtt(`WEBVTT
00:00:00.080 --> 00:00:03.200 align:start position:0%
<c.colorE5E5E5>Hello</c> world
00:00:04.000 --> 00:00:05.800
Again
`);
assert.equal(snippets.length, 2);
assert.equal(snippets[0].text, "Hello world");
assert.equal(snippets[0].start, 0.08);
assert.equal(snippets[0].duration, 3.12);
assert.equal(snippets[1].text, "Again");
assert.equal(snippets[1].start, 4);
assert.equal(Number(snippets[1].duration.toFixed(1)), 1.8);
});
test("resolveVideoSource prefers primary InnerTube result before fallback", async () => {
let fallbackCalled = false;
const source = await resolveVideoSource(
"video12345ab",
async () => ({ kind: "innertube", data: { videoDetails: { title: "Primary" } }, transcripts: [] }),
() => {
fallbackCalled = true;
return {
subtitles: {
en: [{ ext: "json3", url: "https://example.com/en.json3", name: "English" }],
},
};
},
() => {}
);
assert.equal(source.kind, "innertube");
assert.equal(fallbackCalled, false);
});
test("resolveVideoSource falls back to yt-dlp only after fallback-eligible errors", async () => {
let fallbackCalled = false;
const source = await resolveVideoSource(
"video12345ab",
async () => {
const error = new Error("Request blocked for video12345ab: bot detected");
(error as Error & { code?: string }).code = "BOT_DETECTED";
throw error;
},
() => {
fallbackCalled = true;
return {
automatic_captions: {
en: [{ ext: "json3", url: "https://example.com/en.json3", name: "English (auto-generated)" }],
},
};
},
() => {}
);
assert.equal(source.kind, "yt-dlp");
assert.equal(fallbackCalled, true);
assert.equal(source.transcripts[0].languageCode, "en");
});
+93 -699
View File
@@ -1,659 +1,55 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { writeFileSync } from "fs";
import { dirname, join, resolve } from "path"; import { join, resolve } from "path";
type Format = "text" | "srt"; import { extractVideoId, slugify } from "./shared.ts";
import {
interface Options { ensureDir,
videoIds: string[]; hasCachedData,
languages: string[]; loadMeta,
format: Format; loadSentences,
translate: string; loadSnippets,
list: boolean; lookupVideoDir,
excludeGenerated: boolean; registerVideoDir,
excludeManual: boolean; resolveBaseDir,
output: string; } from "./storage.ts";
outputDir: string; import { findTranscript, formatListOutput, formatMarkdown, formatSrt, segmentIntoSentences } from "./transcript.ts";
timestamps: boolean; import type { Options, Sentence, Snippet, VideoMeta, VideoResult } from "./types.ts";
chapters: boolean; import {
speakers: boolean; buildVideoMeta,
refresh: boolean; buildVideoMetaFromYtDlp,
} downloadCoverImage,
fetchTranscriptSnippets,
interface Snippet { fetchVideoSource,
text: string; getThumbnailUrls,
start: number; getYtDlpThumbnailUrls,
duration: number; parseChapters,
} } from "./youtube.ts";
interface Sentence { async function fetchAndCache(
text: string; videoId: string,
start: string; baseDir: string,
end: string; opts: Options
} ): Promise<{ meta: VideoMeta; snippets: Snippet[]; sentences: Sentence[]; videoDir: string }> {
const source = await fetchVideoSource(videoId);
interface TranscriptInfo { const requestedLanguages = source.kind === "yt-dlp" && opts.translate ? [opts.translate] : opts.languages;
language: string; const transcript = findTranscript(source.transcripts, requestedLanguages, opts.excludeGenerated, opts.excludeManual);
languageCode: string; const result = await fetchTranscriptSnippets(transcript, source.kind === "yt-dlp" ? undefined : opts.translate || undefined);
isGenerated: boolean; const description = source.kind === "yt-dlp"
isTranslatable: boolean; ? source.info.description || ""
baseUrl: string; : source.data?.videoDetails?.shortDescription || "";
translationLanguages: { language: string; languageCode: string }[]; const duration = source.kind === "yt-dlp"
} ? Number(source.info.duration || 0)
: parseInt(source.data?.videoDetails?.lengthSeconds || "0");
interface Chapter {
title: string;
start: number;
end: number;
}
interface VideoMeta {
videoId: string;
title: string;
channel: string;
channelId: string;
description: string;
duration: number;
publishDate: string;
url: string;
coverImage: string;
thumbnailUrl: string;
language: { code: string; name: string; isGenerated: boolean };
chapters: Chapter[];
}
interface VideoResult {
videoId: string;
title?: string;
filePath?: string;
content?: string;
error?: string;
}
const WATCH_URL = "https://www.youtube.com/watch?v=";
const INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/player";
const INNERTUBE_CTX = { client: { clientName: "ANDROID", clientVersion: "20.10.38" } };
function extractVideoId(input: string): string {
input = input.replace(/\\/g, "").trim();
const patterns = [
/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const p of patterns) {
const m = input.match(p);
if (m) return m[1];
}
return input;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "untitled";
}
function htmlUnescape(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function stripTags(s: string): string {
return s.replace(/<[^>]*>/g, "");
}
function parseTranscriptXml(xml: string): Snippet[] {
const snippets: Snippet[] = [];
const re = /<text\s+start="([^"]*)"(?:\s+dur="([^"]*)")?[^>]*>([\s\S]*?)<\/text>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
const raw = m[3];
if (!raw) continue;
snippets.push({
text: htmlUnescape(stripTags(raw)),
start: parseFloat(m[1]),
duration: parseFloat(m[2] || "0"),
});
}
return snippets;
}
// --- YouTube API ---
async function fetchHtml(videoId: string): Promise<string> {
const r = await fetch(WATCH_URL + videoId, {
headers: { "Accept-Language": "en-US", "User-Agent": "Mozilla/5.0" },
});
if (!r.ok) throw new Error(`HTTP ${r.status} fetching video page`);
let html = await r.text();
if (html.includes('action="https://consent.youtube.com/s"')) {
const cv = html.match(/name="v" value="(.*?)"/);
if (!cv) throw new Error("Failed to create consent cookie");
const r2 = await fetch(WATCH_URL + videoId, {
headers: {
"Accept-Language": "en-US",
"User-Agent": "Mozilla/5.0",
Cookie: `CONSENT=YES+${cv[1]}`,
},
});
if (!r2.ok) throw new Error(`HTTP ${r2.status} fetching video page (consent)`);
html = await r2.text();
}
return html;
}
function extractApiKey(html: string, videoId: string): string {
const m = html.match(/"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/);
if (!m) {
if (html.includes('class="g-recaptcha"')) throw new Error(`IP blocked for ${videoId} (reCAPTCHA)`);
throw new Error(`Cannot extract API key for ${videoId}`);
}
return m[1];
}
async function fetchInnertubeData(videoId: string, apiKey: string): Promise<any> {
const r = await fetch(`${INNERTUBE_URL}?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context: INNERTUBE_CTX, videoId }),
});
if (r.status === 429) throw new Error(`IP blocked for ${videoId} (429)`);
if (!r.ok) throw new Error(`HTTP ${r.status} from InnerTube API`);
return r.json();
}
function assertPlayability(data: any, videoId: string) {
const ps = data?.playabilityStatus;
if (!ps) return;
const status = ps.status;
if (status === "OK" || !status) return;
const reason = ps.reason || "";
if (status === "LOGIN_REQUIRED") {
if (reason.includes("bot")) throw new Error(`Request blocked for ${videoId}: bot detected`);
if (reason.includes("inappropriate")) throw new Error(`Age restricted: ${videoId}`);
}
if (status === "ERROR" && reason.includes("unavailable")) {
if (videoId.startsWith("http")) throw new Error(`Invalid video ID: pass the ID, not the URL`);
throw new Error(`Video unavailable: ${videoId}`);
}
const subreasons = ps.errorScreen?.playerErrorMessageRenderer?.subreason?.runs?.map((r: any) => r.text).join("") || "";
throw new Error(`Video unplayable (${videoId}): ${reason} ${subreasons}`.trim());
}
function extractCaptionsJson(data: any, videoId: string): any {
assertPlayability(data, videoId);
const cj = data?.captions?.playerCaptionsTracklistRenderer;
if (!cj || !cj.captionTracks) throw new Error(`Transcripts disabled for ${videoId}`);
return cj;
}
function buildTranscriptList(captionsJson: any): TranscriptInfo[] {
const tlLangs = (captionsJson.translationLanguages || []).map((tl: any) => ({
language: tl.languageName?.runs?.[0]?.text || tl.languageName?.simpleText || "",
languageCode: tl.languageCode,
}));
return (captionsJson.captionTracks || []).map((t: any) => ({
language: t.name?.runs?.[0]?.text || t.name?.simpleText || "",
languageCode: t.languageCode,
isGenerated: t.kind === "asr",
isTranslatable: !!t.isTranslatable,
baseUrl: (t.baseUrl || "").replace(/&fmt=srv3/g, ""),
translationLanguages: t.isTranslatable ? tlLangs : [],
}));
}
function findTranscript(
transcripts: TranscriptInfo[],
languages: string[],
excludeGenerated: boolean,
excludeManual: boolean
): TranscriptInfo {
let filtered = transcripts;
if (excludeGenerated) filtered = filtered.filter((t) => !t.isGenerated);
if (excludeManual) filtered = filtered.filter((t) => t.isGenerated);
for (const lang of languages) {
const found = filtered.find((t) => t.languageCode === lang);
if (found) return found;
}
const available = filtered.map((t) => `${t.languageCode} ("${t.language}")`).join(", ");
throw new Error(`No transcript found for languages [${languages.join(", ")}]. Available: ${available || "none"}`);
}
async function fetchTranscriptSnippets(info: TranscriptInfo, translateTo?: string): Promise<{ snippets: Snippet[]; language: string; languageCode: string }> {
let url = info.baseUrl;
let lang = info.language;
let langCode = info.languageCode;
if (translateTo) {
if (!info.isTranslatable) throw new Error(`Transcript ${info.languageCode} is not translatable`);
const tl = info.translationLanguages.find((t) => t.languageCode === translateTo);
if (!tl) throw new Error(`Translation language ${translateTo} not available`);
url += `&tlang=${translateTo}`;
lang = tl.language;
langCode = translateTo;
}
const r = await fetch(url, { headers: { "Accept-Language": "en-US" } });
if (!r.ok) throw new Error(`HTTP ${r.status} fetching transcript`);
return { snippets: parseTranscriptXml(await r.text()), language: lang, languageCode: langCode };
}
// --- Metadata & chapters ---
function parseChapters(description: string, duration: number = 0): Chapter[] {
const raw: { title: string; start: number }[] = [];
for (const line of description.split("\n")) {
const m = line.trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s+(.+)$/);
if (m) {
const h = m[1] ? parseInt(m[1]) : 0;
raw.push({ title: m[4].trim(), start: h * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) });
}
}
if (raw.length < 2) return [];
return raw.map((ch, i) => ({
title: ch.title,
start: ch.start,
end: i < raw.length - 1 ? raw[i + 1].start : Math.max(duration, ch.start),
}));
}
function getThumbnailUrls(videoId: string, data: any): string[] {
const urls = [
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
];
const thumbnails = data?.videoDetails?.thumbnail?.thumbnails ||
data?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails || [];
if (thumbnails.length) {
const sorted = [...thumbnails].sort((a: any, b: any) => (b.width || 0) - (a.width || 0));
for (const t of sorted) if (t.url && !urls.includes(t.url)) urls.push(t.url);
}
return urls;
}
function buildVideoMeta(data: any, videoId: string, langInfo: { code: string; name: string; isGenerated: boolean }, chapters: Chapter[]): VideoMeta {
const vd = data?.videoDetails || {};
const mf = data?.microformat?.playerMicroformatRenderer || {};
return {
videoId,
title: vd.title || mf.title?.simpleText || "",
channel: vd.author || mf.ownerChannelName || "",
channelId: vd.channelId || mf.externalChannelId || "",
description: vd.shortDescription || mf.description?.simpleText || "",
duration: parseInt(vd.lengthSeconds || "0"),
publishDate: mf.publishDate || mf.uploadDate || "",
url: `https://www.youtube.com/watch?v=${videoId}`,
coverImage: "",
thumbnailUrl: getThumbnailUrls(videoId, data)[0],
language: langInfo,
chapters,
};
}
async function downloadCoverImage(urls: string[], outputPath: string): Promise<boolean> {
for (const u of urls) {
try {
const r = await fetch(u);
if (r.ok) {
writeFileSync(outputPath, Buffer.from(await r.arrayBuffer()));
return true;
}
} catch {}
}
return false;
}
function parseSrt(srt: string): Snippet[] {
const blocks = srt.trim().split(/\n\n+/);
const snippets: Snippet[] = [];
for (const block of blocks) {
const lines = block.split("\n");
if (lines.length < 3) continue;
const m = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/);
if (!m) continue;
const start = parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000;
const end = parseInt(m[5]) * 3600 + parseInt(m[6]) * 60 + parseInt(m[7]) + parseInt(m[8]) / 1000;
snippets.push({ text: lines.slice(2).join(" "), start, duration: end - start });
}
return snippets;
}
// --- Timestamp formatting ---
function ts(t: number): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function tsMs(t: number, sep: string): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
const ms = Math.round((t - Math.floor(t)) * 1000);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}${sep}${String(ms).padStart(3, "0")}`;
}
// --- Paragraph grouping ---
interface Paragraph {
text: string;
start: number;
end: number;
}
function groupIntoParagraphs(snippets: Snippet[]): Paragraph[] {
if (!snippets.length) return [];
const paras: Paragraph[] = [];
let buf: Snippet[] = [];
for (let i = 0; i < snippets.length; i++) {
buf.push(snippets[i]);
const last = i === snippets.length - 1;
const gap = !last && snippets[i + 1].start - (snippets[i].start + snippets[i].duration) > 1.5;
if (last || gap || buf.length >= 8) {
const lastS = buf[buf.length - 1];
paras.push({ text: buf.map(s => s.text).join(" "), start: buf[0].start, end: lastS.start + lastS.duration });
buf = [];
}
}
return paras;
}
// --- Sentence segmentation ---
const SENTENCE_END_RE = /[.?!…。?!⁈⁇‼‽.]/;
function isCJK(ch: string): boolean {
const code = ch.charCodeAt(0);
return (code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3040 && code <= 0x309F) ||
(code >= 0x30A0 && code <= 0x30FF) ||
(code >= 0xAC00 && code <= 0xD7AF) ||
(code >= 0x3400 && code <= 0x4DBF) ||
(code >= 0xF900 && code <= 0xFAFF);
}
function splitSnippetAtPunctuation(s: Snippet): { text: string; start: number; end: number }[] {
const { text, start, duration } = s;
const end = start + duration;
if (!text.length) return [];
const splitPoints: number[] = [];
for (let i = 0; i < text.length; i++) {
if (SENTENCE_END_RE.test(text[i])) {
while (i + 1 < text.length && SENTENCE_END_RE.test(text[i + 1])) i++;
if (i < text.length - 1) splitPoints.push(i);
}
}
if (!splitPoints.length) return [{ text, start, end }];
const parts: { text: string; start: number; end: number }[] = [];
let prev = 0;
for (const pos of splitPoints) {
const partText = text.slice(prev, pos + 1).trim();
if (partText) {
parts.push({
text: partText,
start: start + (prev / text.length) * duration,
end: start + ((pos + 1) / text.length) * duration,
});
}
prev = pos + 1;
}
const remaining = text.slice(prev).trim();
if (remaining) {
parts.push({ text: remaining, start: start + (prev / text.length) * duration, end });
}
return parts;
}
function mergeTexts(texts: string[]): string {
if (!texts.length) return "";
let result = texts[0];
for (let i = 1; i < texts.length; i++) {
const next = texts[i];
if (!next) continue;
const lastChar = result[result.length - 1];
const firstChar = next[0];
if (isCJK(lastChar) || isCJK(firstChar)) {
result += next;
} else {
result = result.trimEnd() + " " + next.trimStart();
}
}
return result.replace(/ {2,}/g, " ");
}
function segmentIntoSentences(snippets: Snippet[]): Sentence[] {
const parts: { text: string; start: number; end: number }[] = [];
for (const s of snippets) parts.push(...splitSnippetAtPunctuation(s));
const sentences: Sentence[] = [];
let buf: { text: string; start: number; end: number }[] = [];
for (const part of parts) {
buf.push(part);
if (SENTENCE_END_RE.test(part.text[part.text.length - 1])) {
sentences.push({
text: mergeTexts(buf.map(b => b.text)),
start: ts(buf[0].start),
end: ts(buf[buf.length - 1].end),
});
buf = [];
}
}
if (buf.length) {
sentences.push({
text: mergeTexts(buf.map(b => b.text)),
start: ts(buf[0].start),
end: ts(buf[buf.length - 1].end),
});
}
return sentences;
}
function parseTs(t: string): number {
const [h, m, s] = t.split(":").map(Number);
return h * 3600 + m * 60 + s;
}
function groupSentenceParas(sentences: Sentence[]): Paragraph[] {
if (!sentences.length) return [];
const paras: Paragraph[] = [];
let buf: Sentence[] = [];
for (let i = 0; i < sentences.length; i++) {
buf.push(sentences[i]);
const last = i === sentences.length - 1;
const gap = !last && parseTs(sentences[i + 1].start) - parseTs(sentences[i].end) > 2;
if (last || gap || buf.length >= 5) {
paras.push({
text: mergeTexts(buf.map(s => s.text)),
start: parseTs(buf[0].start),
end: parseTs(buf[buf.length - 1].end),
});
buf = [];
}
}
return paras;
}
// --- Format functions ---
function formatSrt(snippets: Snippet[]): string {
return snippets
.map((s, i) => {
const end = i < snippets.length - 1 && snippets[i + 1].start < s.start + s.duration
? snippets[i + 1].start
: s.start + s.duration;
return `${i + 1}\n${tsMs(s.start, ",")} --> ${tsMs(end, ",")}\n${s.text}`;
})
.join("\n\n") + "\n";
}
function yamlEscape(s: string): string {
if (/[:"'{}\[\]#&*!|>%@`\n]/.test(s) || s.trim() !== s) return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
return s;
}
function extractSummary(description: string): string {
if (!description) return "";
const firstPara = description.split(/\n\s*\n/)[0].trim();
const lines = firstPara.split("\n").filter(l => !/^\s*(https?:\/\/|#|@|\d+:\d+)/.test(l) && l.trim());
return lines.join(" ").slice(0, 300).trim();
}
function formatMarkdown(sentences: Sentence[], meta: VideoMeta, opts: { timestamps: boolean; chapters: boolean; speakers: boolean }, snippets?: Snippet[]): string {
const summary = extractSummary(meta.description);
let md = "---\n";
md += `title: ${yamlEscape(meta.title)}\n`;
md += `channel: ${yamlEscape(meta.channel)}\n`;
if (meta.publishDate) md += `date: ${meta.publishDate}\n`;
md += `url: ${yamlEscape(meta.url)}\n`;
if (meta.coverImage) md += `cover: ${meta.coverImage}\n`;
if (summary) md += `description: ${yamlEscape(summary)}\n`;
if (meta.language) md += `language: ${meta.language.code}\n`;
md += "---\n\n";
if (opts.speakers) {
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
if (meta.description) md += "# Description\n\n" + meta.description.trim() + "\n\n";
if (meta.chapters.length) {
md += "# Chapters\n\n";
for (const ch of meta.chapters) md += `* [${ts(ch.start)}] ${ch.title}\n`;
md += "\n";
}
md += "# Transcript\n\n";
md += snippets ? formatSrt(snippets) : "";
return md;
}
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
const chapters = opts.chapters ? meta.chapters : [];
if (chapters.length) {
md += "## Table of Contents\n\n";
for (const ch of chapters) md += opts.timestamps ? `* [${ts(ch.start)}] ${ch.title}\n` : `* ${ch.title}\n`;
md += "\n";
if (meta.coverImage) md += `\n![cover](${meta.coverImage})\n`;
md += "\n";
for (let i = 0; i < chapters.length; i++) {
const nextStart = i < chapters.length - 1 ? chapters[i + 1].start : Infinity;
const chSentences = sentences.filter(s => parseTs(s.start) >= chapters[i].start && parseTs(s.start) < nextStart);
const paras = groupSentenceParas(chSentences);
md += opts.timestamps
? `## [${ts(chapters[i].start)}] ${chapters[i].title}\n\n`
: `## ${chapters[i].title}\n\n`;
for (const p of paras) md += opts.timestamps ? `${p.text} [${ts(p.start)}${ts(p.end)}]\n\n` : `${p.text}\n\n`;
md += "\n";
}
} else {
const paras = groupSentenceParas(sentences);
for (const p of paras) md += opts.timestamps ? `${p.text} [${ts(p.start)}${ts(p.end)}]\n\n` : `${p.text}\n\n`;
}
return md.trimEnd() + "\n";
}
function formatListOutput(videoId: string, title: string, transcripts: TranscriptInfo[]): string {
const manual = transcripts.filter((t) => !t.isGenerated);
const generated = transcripts.filter((t) => t.isGenerated);
const tlLangs = transcripts.find((t) => t.translationLanguages.length > 0)?.translationLanguages || [];
const fmtList = (list: TranscriptInfo[]) =>
list.length ? list.map((t) => ` - ${t.languageCode} ("${t.language}")${t.isTranslatable ? " [TRANSLATABLE]" : ""}`).join("\n") : "None";
const fmtTl = tlLangs.length
? tlLangs.map((t) => ` - ${t.languageCode} ("${t.language}")`).join("\n")
: "None";
return `Transcripts for ${videoId}${title ? ` (${title})` : ""}:\n\n(MANUALLY CREATED)\n${fmtList(manual)}\n\n(GENERATED)\n${fmtList(generated)}\n\n(TRANSLATION LANGUAGES)\n${fmtTl}`;
}
// --- File helpers ---
function ensureDir(p: string) {
const dir = dirname(p);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
function resolveBaseDir(outputDir: string): string {
return resolve(outputDir || "youtube-transcript");
}
function loadIndex(baseDir: string): Record<string, string> {
try { return JSON.parse(readFileSync(join(baseDir, ".index.json"), "utf-8")); } catch { return {}; }
}
function saveIndex(baseDir: string, index: Record<string, string>) {
const p = join(baseDir, ".index.json");
ensureDir(p);
writeFileSync(p, JSON.stringify(index, null, 2));
}
function lookupVideoDir(videoId: string, baseDir: string): string | null {
const rel = loadIndex(baseDir)[videoId];
if (rel) {
const dir = resolve(baseDir, rel);
if (existsSync(dir)) return dir;
}
return null;
}
function registerVideoDir(videoId: string, channelSlug: string, titleSlug: string, baseDir: string): string {
const rel = join(channelSlug, titleSlug);
const index = loadIndex(baseDir);
index[videoId] = rel;
saveIndex(baseDir, index);
return resolve(baseDir, rel);
}
function hasCachedData(videoDir: string): boolean {
return existsSync(join(videoDir, "meta.json")) && existsSync(join(videoDir, "transcript-raw.json"));
}
function loadMeta(videoDir: string): VideoMeta {
return JSON.parse(readFileSync(join(videoDir, "meta.json"), "utf-8"));
}
function loadSnippets(videoDir: string): Snippet[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-raw.json"), "utf-8"));
}
function loadSentences(videoDir: string): Sentence[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-sentences.json"), "utf-8"));
}
// --- Main processing ---
async function fetchAndCache(videoId: string, baseDir: string, opts: Options): Promise<{ meta: VideoMeta; snippets: Snippet[]; sentences: Sentence[]; videoDir: string }> {
const html = await fetchHtml(videoId);
const apiKey = extractApiKey(html, videoId);
const data = await fetchInnertubeData(videoId, apiKey);
const captionsJson = extractCaptionsJson(data, videoId);
const transcripts = buildTranscriptList(captionsJson);
const info = findTranscript(transcripts, opts.languages, opts.excludeGenerated, opts.excludeManual);
const result = await fetchTranscriptSnippets(info, opts.translate || undefined);
const description = data?.videoDetails?.shortDescription || "";
const duration = parseInt(data?.videoDetails?.lengthSeconds || "0");
const chapters = parseChapters(description, duration); const chapters = parseChapters(description, duration);
const langInfo = { code: result.languageCode, name: result.language, isGenerated: info.isGenerated }; const language = {
const meta = buildVideoMeta(data, videoId, langInfo, chapters); code: result.languageCode,
name: result.language,
isGenerated: transcript.isGenerated,
};
const meta = source.kind === "yt-dlp"
? buildVideoMetaFromYtDlp(source.info, videoId, language, chapters)
: buildVideoMeta(source.data, videoId, language, chapters);
const videoDir = registerVideoDir(videoId, slugify(meta.channel), slugify(meta.title), baseDir); const videoDir = registerVideoDir(videoId, slugify(meta.channel), slugify(meta.title), baseDir);
ensureDir(join(videoDir, "meta.json")); ensureDir(join(videoDir, "meta.json"));
@@ -663,9 +59,12 @@ async function fetchAndCache(videoId: string, baseDir: string, opts: Options): P
const sentences = segmentIntoSentences(result.snippets); const sentences = segmentIntoSentences(result.snippets);
writeFileSync(join(videoDir, "transcript-sentences.json"), JSON.stringify(sentences, null, 2)); writeFileSync(join(videoDir, "transcript-sentences.json"), JSON.stringify(sentences, null, 2));
const imgPath = join(videoDir, "imgs", "cover.jpg"); const imagePath = join(videoDir, "imgs", "cover.jpg");
ensureDir(imgPath); ensureDir(imagePath);
const downloaded = await downloadCoverImage(getThumbnailUrls(videoId, data), imgPath); const downloaded = await downloadCoverImage(
source.kind === "yt-dlp" ? getYtDlpThumbnailUrls(videoId, source.info) : getThumbnailUrls(videoId, source.data),
imagePath
);
meta.coverImage = downloaded ? "imgs/cover.jpg" : ""; meta.coverImage = downloaded ? "imgs/cover.jpg" : "";
writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2)); writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2));
@@ -676,15 +75,10 @@ async function fetchAndCache(videoId: string, baseDir: string, opts: Options): P
async function processVideo(videoId: string, opts: Options): Promise<VideoResult> { async function processVideo(videoId: string, opts: Options): Promise<VideoResult> {
const baseDir = resolveBaseDir(opts.outputDir); const baseDir = resolveBaseDir(opts.outputDir);
// --list: always fetch fresh
if (opts.list) { if (opts.list) {
const html = await fetchHtml(videoId); const source = await fetchVideoSource(videoId);
const apiKey = extractApiKey(html, videoId); const title = source.kind === "yt-dlp" ? source.info.title || "" : source.data?.videoDetails?.title || "";
const data = await fetchInnertubeData(videoId, apiKey); return { videoId, title, content: formatListOutput(videoId, title, source.transcripts) };
const title = data?.videoDetails?.title || "";
const captionsJson = extractCaptionsJson(data, videoId);
const transcripts = buildTranscriptList(captionsJson);
return { videoId, title, content: formatListOutput(videoId, title, transcripts) };
} }
let videoDir = lookupVideoDir(videoId, baseDir); let videoDir = lookupVideoDir(videoId, baseDir);
@@ -697,16 +91,17 @@ async function processVideo(videoId: string, opts: Options): Promise<VideoResult
meta = loadMeta(videoDir); meta = loadMeta(videoDir);
snippets = loadSnippets(videoDir); snippets = loadSnippets(videoDir);
sentences = loadSentences(videoDir); sentences = loadSentences(videoDir);
const wantLangs = opts.translate ? [opts.translate] : opts.languages; const wantedLanguages = opts.translate ? [opts.translate] : opts.languages;
if (!wantLangs.includes(meta.language.code)) needsFetch = true; if (!wantedLanguages.includes(meta.language.code)) needsFetch = true;
// Backfill chapter end times for caches created before this field existed if (!needsFetch && meta.chapters.length > 0 && meta.chapters.some((chapter: any) => chapter.end === undefined)) {
if (!needsFetch && meta.chapters.length > 0 && meta.chapters.some((ch: any) => ch.end === undefined)) {
for (let i = 0; i < meta.chapters.length; i++) { for (let i = 0; i < meta.chapters.length; i++) {
meta.chapters[i].end = i < meta.chapters.length - 1 meta.chapters[i].end = i < meta.chapters.length - 1
? meta.chapters[i + 1].start ? meta.chapters[i + 1].start
: Math.max(meta.duration, meta.chapters[i].start); : Math.max(meta.duration, meta.chapters[i].start);
} }
try { writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2)); } catch {} try {
writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2));
} catch {}
} }
} }
@@ -722,21 +117,19 @@ async function processVideo(videoId: string, opts: Options): Promise<VideoResult
sentences = sentences!; sentences = sentences!;
} }
let content: string; const content = opts.format === "srt"
let ext: string; ? formatSrt(snippets)
: formatMarkdown(
if (opts.format === "srt") { sentences,
content = formatSrt(snippets); meta,
ext = "srt"; {
} else { timestamps: opts.timestamps,
content = formatMarkdown(sentences, meta, { chapters: opts.chapters,
timestamps: opts.timestamps, speakers: opts.speakers,
chapters: opts.chapters, },
speakers: opts.speakers, snippets
}, snippets); );
ext = "md"; const ext = opts.format === "srt" ? "srt" : "md";
}
const filePath = opts.output ? resolve(opts.output) : join(videoDir!, `transcript.${ext}`); const filePath = opts.output ? resolve(opts.output) : join(videoDir!, `transcript.${ext}`);
ensureDir(filePath); ensureDir(filePath);
writeFileSync(filePath, content); writeFileSync(filePath, content);
@@ -744,8 +137,6 @@ async function processVideo(videoId: string, opts: Options): Promise<VideoResult
return { videoId, title: meta.title, filePath }; return { videoId, title: meta.title, filePath };
} }
// --- CLI ---
function printHelp() { function printHelp() {
console.log(`Usage: bun main.ts <video-url-or-id> [options] console.log(`Usage: bun main.ts <video-url-or-id> [options]
@@ -789,13 +180,13 @@ function parseArgs(argv: string[]): Options | null {
printHelp(); printHelp();
process.exit(0); process.exit(0);
} else if (arg === "--languages") { } else if (arg === "--languages") {
const v = argv[++i]; const value = argv[++i];
if (v) opts.languages = v.split(",").map((s) => s.trim()); if (value) opts.languages = value.split(",").map((entry) => entry.trim());
} else if (arg === "--format") { } else if (arg === "--format") {
const v = argv[++i]?.toLowerCase(); const value = argv[++i]?.toLowerCase();
if (v === "text" || v === "srt") opts.format = v; if (value === "text" || value === "srt") opts.format = value;
else { else {
console.error(`Invalid format: ${v}. Use: text, srt`); console.error(`Invalid format: ${value}. Use: text, srt`);
return null; return null;
} }
} else if (arg === "--translate") { } else if (arg === "--translate") {
@@ -830,6 +221,7 @@ function parseArgs(argv: string[]): Options | null {
printHelp(); printHelp();
return null; return null;
} }
return opts; return opts;
} }
@@ -844,14 +236,16 @@ async function main() {
for (const videoId of opts.videoIds) { for (const videoId of opts.videoIds) {
try { try {
const r = await processVideo(videoId, opts); const result = await processVideo(videoId, opts);
if (r.error) console.error(`Error (${r.videoId}): ${r.error}`); if (result.error) console.error(`Error (${result.videoId}): ${result.error}`);
else if (r.filePath) console.log(r.filePath); else if (result.filePath) console.log(result.filePath);
else if (r.content) console.log(r.content); else if (result.content) console.log(result.content);
} catch (e) { } catch (error) {
console.error(`Error (${videoId}): ${(e as Error).message}`); console.error(`Error (${videoId}): ${(error as Error).message}`);
} }
} }
} }
main(); if (import.meta.main) {
main();
}
@@ -0,0 +1,83 @@
import type { TranscriptError } from "./types.ts";
export function extractVideoId(input: string): string {
input = input.replace(/\\/g, "").trim();
const patterns = [
/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const pattern of patterns) {
const match = input.match(pattern);
if (match) return match[1];
}
return input;
}
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "untitled";
}
export function htmlUnescape(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
export function stripTags(value: string): string {
return value.replace(/<[^>]*>/g, "");
}
export function makeError(message: string, code?: string): Error {
const error = new Error(message) as TranscriptError;
if (code) error.code = code;
return error;
}
export function normalizeError(error: unknown): TranscriptError {
if (error instanceof Error) {
const known = error as TranscriptError;
if (known.code) return known;
const message = known.message || String(error);
const lower = message.toLowerCase();
if (lower.includes("bot detected")) known.code = "BOT_DETECTED";
else if (lower.includes("age restricted")) known.code = "AGE_RESTRICTED";
else if (lower.includes("video unavailable")) known.code = "VIDEO_UNAVAILABLE";
else if (lower.includes("transcripts disabled")) known.code = "TRANSCRIPTS_DISABLED";
else if (lower.includes("no transcript found")) known.code = "NO_TRANSCRIPT";
else if (lower.includes("invalid video id")) known.code = "INVALID_VIDEO_ID";
else if (lower.includes("ip blocked") || lower.includes("recaptcha") || lower.includes("http 429")) known.code = "IP_BLOCKED";
else if (lower.includes("cannot extract api key")) known.code = "PAGE_FETCH_FAILED";
else if (lower.includes("innertube api") || lower.includes("http 403")) known.code = "INNERTUBE_REJECTED";
else if (lower.includes("yt-dlp fallback failed")) known.code = "YT_DLP_FAILED";
return known;
}
return makeError(String(error), "UNKNOWN") as TranscriptError;
}
export function shouldTryAlternateClient(error: unknown): boolean {
const code = normalizeError(error).code;
return code === "BOT_DETECTED" || code === "IP_BLOCKED" || code === "INNERTUBE_REJECTED" || code === "AGE_RESTRICTED" || code === "VIDEO_UNAVAILABLE";
}
export function shouldTryYtDlpFallback(error: unknown): boolean {
const code = normalizeError(error).code;
return code === "BOT_DETECTED" || code === "IP_BLOCKED" || code === "INNERTUBE_REJECTED" || code === "PAGE_FETCH_FAILED" || code === "AGE_RESTRICTED" || code === "VIDEO_UNAVAILABLE";
}
export function normalizePublishDate(uploadDate?: string): string {
if (!uploadDate || !/^\d{8}$/.test(uploadDate)) return uploadDate || "";
return `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`;
}
@@ -0,0 +1,60 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
import type { Sentence, Snippet, VideoMeta } from "./types.ts";
export function ensureDir(path: string) {
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
export function resolveBaseDir(outputDir: string): string {
return resolve(outputDir || "youtube-transcript");
}
function loadIndex(baseDir: string): Record<string, string> {
try {
return JSON.parse(readFileSync(join(baseDir, ".index.json"), "utf-8"));
} catch {
return {};
}
}
function saveIndex(baseDir: string, index: Record<string, string>) {
const path = join(baseDir, ".index.json");
ensureDir(path);
writeFileSync(path, JSON.stringify(index, null, 2));
}
export function lookupVideoDir(videoId: string, baseDir: string): string | null {
const rel = loadIndex(baseDir)[videoId];
if (rel) {
const dir = resolve(baseDir, rel);
if (existsSync(dir)) return dir;
}
return null;
}
export function registerVideoDir(videoId: string, channelSlug: string, titleSlug: string, baseDir: string): string {
const rel = join(channelSlug, titleSlug);
const index = loadIndex(baseDir);
index[videoId] = rel;
saveIndex(baseDir, index);
return resolve(baseDir, rel);
}
export function hasCachedData(videoDir: string): boolean {
return existsSync(join(videoDir, "meta.json")) && existsSync(join(videoDir, "transcript-raw.json"));
}
export function loadMeta(videoDir: string): VideoMeta {
return JSON.parse(readFileSync(join(videoDir, "meta.json"), "utf-8"));
}
export function loadSnippets(videoDir: string): Snippet[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-raw.json"), "utf-8"));
}
export function loadSentences(videoDir: string): Sentence[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-sentences.json"), "utf-8"));
}
@@ -0,0 +1,349 @@
import { htmlUnescape, makeError, stripTags } from "./shared.ts";
import type { Sentence, Snippet, TranscriptInfo, VideoMeta } from "./types.ts";
interface Paragraph {
text: string;
start: number;
end: number;
}
const SENTENCE_END_RE = /[.?!…。?!⁈⁇‼‽.]/;
export function parseTranscriptXml(xml: string): Snippet[] {
const snippets: Snippet[] = [];
const pattern = /<text\s+start="([^"]*)"(?:\s+dur="([^"]*)")?[^>]*>([\s\S]*?)<\/text>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(xml)) !== null) {
const raw = match[3];
if (!raw) continue;
snippets.push({
text: htmlUnescape(stripTags(raw)),
start: parseFloat(match[1]),
duration: parseFloat(match[2] || "0"),
});
}
return snippets;
}
export function parseTranscriptJson3(text: string): Snippet[] {
const data = JSON.parse(text);
const events = Array.isArray(data?.events) ? data.events : [];
const snippets: Snippet[] = [];
for (const event of events) {
const segs = Array.isArray(event?.segs) ? event.segs : [];
const textParts = segs
.map((seg: any) => htmlUnescape(String(seg?.utf8 || "").replace(/\n+/g, " ").trim()))
.filter(Boolean);
const merged = mergeTexts(textParts).trim();
if (!merged) continue;
snippets.push({
text: merged,
start: Number(event?.tStartMs || 0) / 1000,
duration: Number(event?.dDurationMs || 0) / 1000,
});
}
return snippets;
}
function parseSrt(srt: string): Snippet[] {
const blocks = srt.trim().split(/\n\n+/);
const snippets: Snippet[] = [];
for (const block of blocks) {
const lines = block.split("\n");
if (lines.length < 3) continue;
const match = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/);
if (!match) continue;
const start = parseInt(match[1]) * 3600 + parseInt(match[2]) * 60 + parseInt(match[3]) + parseInt(match[4]) / 1000;
const end = parseInt(match[5]) * 3600 + parseInt(match[6]) * 60 + parseInt(match[7]) + parseInt(match[8]) / 1000;
snippets.push({ text: lines.slice(2).join(" "), start, duration: end - start });
}
return snippets;
}
export function parseWebVtt(vtt: string): Snippet[] {
const blocks = vtt
.replace(/^WEBVTT\s*/m, "")
.trim()
.split(/\n\n+/);
const snippets: Snippet[] = [];
for (const block of blocks) {
const lines = block.split("\n").map((line) => line.trim()).filter(Boolean);
const tsLine = lines.find((line) => line.includes("-->"));
if (!tsLine) continue;
const match = tsLine.match(
/(?:(\d{2}):)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(?:(\d{2}):)?(\d{2}):(\d{2})\.(\d{3})/
);
if (!match) continue;
const start =
(match[1] ? parseInt(match[1]) : 0) * 3600 +
parseInt(match[2]) * 60 +
parseInt(match[3]) +
parseInt(match[4]) / 1000;
const end =
(match[5] ? parseInt(match[5]) : 0) * 3600 +
parseInt(match[6]) * 60 +
parseInt(match[7]) +
parseInt(match[8]) / 1000;
const text = htmlUnescape(stripTags(lines.slice(lines.indexOf(tsLine) + 1).join(" ").replace(/\s+/g, " ").trim()));
if (!text) continue;
snippets.push({ text, start, duration: end - start });
}
return snippets;
}
export function parseTranscriptPayload(payload: string, url: string): Snippet[] {
const normalized = payload.trimStart();
if (url.includes("fmt=json3") || normalized.startsWith("{")) return parseTranscriptJson3(payload);
if (normalized.startsWith("WEBVTT")) return parseWebVtt(payload);
if (/^\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}\s*-->/.test(normalized)) return parseSrt(payload);
return parseTranscriptXml(payload);
}
function isCJK(ch: string): boolean {
const code = ch.charCodeAt(0);
return (code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3040 && code <= 0x309F) ||
(code >= 0x30A0 && code <= 0x30FF) ||
(code >= 0xAC00 && code <= 0xD7AF) ||
(code >= 0x3400 && code <= 0x4DBF) ||
(code >= 0xF900 && code <= 0xFAFF);
}
function splitSnippetAtPunctuation(snippet: Snippet): { text: string; start: number; end: number }[] {
const { text, start, duration } = snippet;
const end = start + duration;
if (!text.length) return [];
const splitPoints: number[] = [];
for (let i = 0; i < text.length; i++) {
if (SENTENCE_END_RE.test(text[i])) {
while (i + 1 < text.length && SENTENCE_END_RE.test(text[i + 1])) i++;
if (i < text.length - 1) splitPoints.push(i);
}
}
if (!splitPoints.length) return [{ text, start, end }];
const parts: { text: string; start: number; end: number }[] = [];
let prev = 0;
for (const pos of splitPoints) {
const partText = text.slice(prev, pos + 1).trim();
if (partText) {
parts.push({
text: partText,
start: start + (prev / text.length) * duration,
end: start + ((pos + 1) / text.length) * duration,
});
}
prev = pos + 1;
}
const remaining = text.slice(prev).trim();
if (remaining) parts.push({ text: remaining, start: start + (prev / text.length) * duration, end });
return parts;
}
function mergeTexts(texts: string[]): string {
if (!texts.length) return "";
let result = texts[0];
for (let i = 1; i < texts.length; i++) {
const next = texts[i];
if (!next) continue;
const lastChar = result[result.length - 1];
const firstChar = next[0];
if (isCJK(lastChar) || isCJK(firstChar)) {
result += next;
} else {
result = result.trimEnd() + " " + next.trimStart();
}
}
return result.replace(/ {2,}/g, " ");
}
export function ts(time: number): string {
const h = Math.floor(time / 3600);
const m = Math.floor((time % 3600) / 60);
const s = Math.floor(time % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function tsMs(time: number, sep: string): string {
const h = Math.floor(time / 3600);
const m = Math.floor((time % 3600) / 60);
const s = Math.floor(time % 60);
const ms = Math.round((time - Math.floor(time)) * 1000);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}${sep}${String(ms).padStart(3, "0")}`;
}
function parseTs(time: string): number {
const [h, m, s] = time.split(":").map(Number);
return h * 3600 + m * 60 + s;
}
export function segmentIntoSentences(snippets: Snippet[]): Sentence[] {
const parts: { text: string; start: number; end: number }[] = [];
for (const snippet of snippets) parts.push(...splitSnippetAtPunctuation(snippet));
const sentences: Sentence[] = [];
let buffer: { text: string; start: number; end: number }[] = [];
for (const part of parts) {
buffer.push(part);
if (SENTENCE_END_RE.test(part.text[part.text.length - 1])) {
sentences.push({
text: mergeTexts(buffer.map((entry) => entry.text)),
start: ts(buffer[0].start),
end: ts(buffer[buffer.length - 1].end),
});
buffer = [];
}
}
if (buffer.length) {
sentences.push({
text: mergeTexts(buffer.map((entry) => entry.text)),
start: ts(buffer[0].start),
end: ts(buffer[buffer.length - 1].end),
});
}
return sentences;
}
function groupSentenceParas(sentences: Sentence[]): Paragraph[] {
if (!sentences.length) return [];
const paragraphs: Paragraph[] = [];
let buffer: Sentence[] = [];
for (let i = 0; i < sentences.length; i++) {
buffer.push(sentences[i]);
const last = i === sentences.length - 1;
const gap = !last && parseTs(sentences[i + 1].start) - parseTs(sentences[i].end) > 2;
if (last || gap || buffer.length >= 5) {
paragraphs.push({
text: mergeTexts(buffer.map((sentence) => sentence.text)),
start: parseTs(buffer[0].start),
end: parseTs(buffer[buffer.length - 1].end),
});
buffer = [];
}
}
return paragraphs;
}
export function formatSrt(snippets: Snippet[]): string {
return snippets
.map((snippet, index) => {
const end = index < snippets.length - 1 && snippets[index + 1].start < snippet.start + snippet.duration
? snippets[index + 1].start
: snippet.start + snippet.duration;
return `${index + 1}\n${tsMs(snippet.start, ",")} --> ${tsMs(end, ",")}\n${snippet.text}`;
})
.join("\n\n") + "\n";
}
function yamlEscape(value: string): string {
if (/[:"'{}\[\]#&*!|>%@`\n]/.test(value) || value.trim() !== value) {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return value;
}
function extractSummary(description: string): string {
if (!description) return "";
const firstPara = description.split(/\n\s*\n/)[0].trim();
const lines = firstPara.split("\n").filter((line) => !/^\s*(https?:\/\/|#|@|\d+:\d+)/.test(line) && line.trim());
return lines.join(" ").slice(0, 300).trim();
}
export function formatMarkdown(
sentences: Sentence[],
meta: VideoMeta,
opts: { timestamps: boolean; chapters: boolean; speakers: boolean },
snippets?: Snippet[]
): string {
const summary = extractSummary(meta.description);
let md = "---\n";
md += `title: ${yamlEscape(meta.title)}\n`;
md += `channel: ${yamlEscape(meta.channel)}\n`;
if (meta.publishDate) md += `date: ${meta.publishDate}\n`;
md += `url: ${yamlEscape(meta.url)}\n`;
if (meta.coverImage) md += `cover: ${meta.coverImage}\n`;
if (summary) md += `description: ${yamlEscape(summary)}\n`;
if (meta.language) md += `language: ${meta.language.code}\n`;
md += "---\n\n";
if (opts.speakers) {
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
if (meta.description) md += `# Description\n\n${meta.description.trim()}\n\n`;
if (meta.chapters.length) {
md += "# Chapters\n\n";
for (const chapter of meta.chapters) md += `* [${ts(chapter.start)}] ${chapter.title}\n`;
md += "\n";
}
md += "# Transcript\n\n";
md += snippets ? formatSrt(snippets) : "";
return md;
}
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
const chapters = opts.chapters ? meta.chapters : [];
if (chapters.length) {
md += "## Table of Contents\n\n";
for (const chapter of chapters) md += opts.timestamps ? `* [${ts(chapter.start)}] ${chapter.title}\n` : `* ${chapter.title}\n`;
md += "\n";
if (meta.coverImage) md += `\n![cover](${meta.coverImage})\n`;
md += "\n";
for (let i = 0; i < chapters.length; i++) {
const nextStart = i < chapters.length - 1 ? chapters[i + 1].start : Infinity;
const chapterSentences = sentences.filter((sentence) => parseTs(sentence.start) >= chapters[i].start && parseTs(sentence.start) < nextStart);
const paragraphs = groupSentenceParas(chapterSentences);
md += opts.timestamps ? `## [${ts(chapters[i].start)}] ${chapters[i].title}\n\n` : `## ${chapters[i].title}\n\n`;
for (const paragraph of paragraphs) {
md += opts.timestamps ? `${paragraph.text} [${ts(paragraph.start)}${ts(paragraph.end)}]\n\n` : `${paragraph.text}\n\n`;
}
md += "\n";
}
} else {
const paragraphs = groupSentenceParas(sentences);
for (const paragraph of paragraphs) {
md += opts.timestamps ? `${paragraph.text} [${ts(paragraph.start)}${ts(paragraph.end)}]\n\n` : `${paragraph.text}\n\n`;
}
}
return md.trimEnd() + "\n";
}
export function formatListOutput(videoId: string, title: string, transcripts: TranscriptInfo[]): string {
const manual = transcripts.filter((transcript) => !transcript.isGenerated);
const generated = transcripts.filter((transcript) => transcript.isGenerated);
const translationLanguages = transcripts.find((transcript) => transcript.translationLanguages.length > 0)?.translationLanguages || [];
const formatList = (list: TranscriptInfo[]) =>
list.length
? list.map((transcript) => ` - ${transcript.languageCode} ("${transcript.language}")${transcript.isTranslatable ? " [TRANSLATABLE]" : ""}`).join("\n")
: "None";
const formatTranslations = translationLanguages.length
? translationLanguages.map((language) => ` - ${language.languageCode} ("${language.language}")`).join("\n")
: "None";
return `Transcripts for ${videoId}${title ? ` (${title})` : ""}:\n\n(MANUALLY CREATED)\n${formatList(manual)}\n\n(GENERATED)\n${formatList(generated)}\n\n(TRANSLATION LANGUAGES)\n${formatTranslations}`;
}
export function findTranscript(
transcripts: TranscriptInfo[],
languages: string[],
excludeGenerated: boolean,
excludeManual: boolean
): TranscriptInfo {
let filtered = transcripts;
if (excludeGenerated) filtered = filtered.filter((transcript) => !transcript.isGenerated);
if (excludeManual) filtered = filtered.filter((transcript) => transcript.isGenerated);
for (const language of languages) {
const found = filtered.find((transcript) => transcript.languageCode === language);
if (found) return found;
}
const available = filtered.map((transcript) => `${transcript.languageCode} ("${transcript.language}")`).join(", ");
throw makeError(`No transcript found for languages [${languages.join(", ")}]. Available: ${available || "none"}`, "NO_TRANSCRIPT");
}
@@ -0,0 +1,123 @@
export type Format = "text" | "srt";
export interface Options {
videoIds: string[];
languages: string[];
format: Format;
translate: string;
list: boolean;
excludeGenerated: boolean;
excludeManual: boolean;
output: string;
outputDir: string;
timestamps: boolean;
chapters: boolean;
speakers: boolean;
refresh: boolean;
}
export interface Snippet {
text: string;
start: number;
duration: number;
}
export interface Sentence {
text: string;
start: string;
end: string;
}
export interface TranscriptLanguage {
language: string;
languageCode: string;
}
export interface TranscriptInfo {
language: string;
languageCode: string;
isGenerated: boolean;
isTranslatable: boolean;
baseUrl: string;
translationLanguages: TranscriptLanguage[];
}
export interface Chapter {
title: string;
start: number;
end: number;
}
export interface LanguageMeta {
code: string;
name: string;
isGenerated: boolean;
}
export interface VideoMeta {
videoId: string;
title: string;
channel: string;
channelId: string;
description: string;
duration: number;
publishDate: string;
url: string;
coverImage: string;
thumbnailUrl: string;
language: LanguageMeta;
chapters: Chapter[];
}
export interface VideoResult {
videoId: string;
title?: string;
filePath?: string;
content?: string;
error?: string;
}
export interface InnerTubeSession {
apiKey: string;
webClientVersion: string;
visitorData: string;
}
export interface InnerTubeClient {
id: string;
clientName: string;
clientVersion?: string;
clientHeaderName?: string;
userAgent: string;
extraContext?: Record<string, any>;
}
export interface TranscriptError extends Error {
code?: string;
}
export interface YtDlpTrack {
ext?: string;
url?: string;
name?: string;
}
export interface YtDlpInfo {
title?: string;
channel?: string;
channel_id?: string;
uploader?: string;
uploader_id?: string;
description?: string;
duration?: number;
upload_date?: string;
webpage_url?: string;
thumbnail?: string;
thumbnails?: { url?: string; width?: number; height?: number }[];
subtitles?: Record<string, YtDlpTrack[]>;
automatic_captions?: Record<string, YtDlpTrack[]>;
}
export type VideoSource =
| { kind: "innertube"; data: any; transcripts: TranscriptInfo[] }
| { kind: "yt-dlp"; info: YtDlpInfo; transcripts: TranscriptInfo[] };
@@ -0,0 +1,477 @@
import { spawnSync } from "child_process";
import { writeFileSync } from "fs";
import { makeError, normalizeError, normalizePublishDate, shouldTryAlternateClient, shouldTryYtDlpFallback } from "./shared.ts";
import { parseTranscriptPayload } from "./transcript.ts";
import type {
Chapter,
InnerTubeClient,
InnerTubeSession,
LanguageMeta,
Snippet,
TranscriptInfo,
VideoMeta,
VideoSource,
YtDlpInfo,
YtDlpTrack,
} from "./types.ts";
const WATCH_URL = "https://www.youtube.com/watch?v=";
const INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/player";
const WATCH_PAGE_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
const DEFAULT_WEB_CLIENT_VERSION = "2.20260320.08.00";
const YT_DLP_MAX_BUFFER = 32 * 1024 * 1024;
let cachedYtDlpCommand: { command: string; args: string[]; label: string } | null | undefined;
const INNER_TUBE_CLIENTS: InnerTubeClient[] = [
{
id: "android",
clientName: "ANDROID",
clientHeaderName: "3",
clientVersion: "20.10.38",
userAgent:
"com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US; Pixel 8 Pro; Build/AP1A.240405.002)",
extraContext: {
clientFormFactor: "SMALL_FORM_FACTOR",
androidSdkVersion: 34,
osName: "Android",
osVersion: "14",
platform: "MOBILE",
},
},
{
id: "web",
clientName: "WEB",
clientHeaderName: "1",
userAgent: WATCH_PAGE_USER_AGENT,
},
{
id: "ios",
clientName: "IOS",
clientHeaderName: "5",
clientVersion: "20.10.4",
userAgent:
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3 like Mac OS X; en_US)",
extraContext: {
deviceMake: "Apple",
deviceModel: "iPhone16,2",
osName: "iPhone",
osVersion: "18.3.0.22D5054f",
platform: "MOBILE",
},
},
];
async function fetchHtml(videoId: string): Promise<string> {
const watchUrl = `${WATCH_URL}${videoId}&hl=en&persist_hl=1&has_verified=1&bpctr=9999999999`;
const baseHeaders = {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
Pragma: "no-cache",
"User-Agent": WATCH_PAGE_USER_AGENT,
};
const response = await fetch(watchUrl, { headers: baseHeaders });
if (!response.ok) throw new Error(`HTTP ${response.status} fetching video page`);
let html = await response.text();
if (html.includes('action="https://consent.youtube.com/s"')) {
const consentValue = html.match(/name="v" value="(.*?)"/);
if (!consentValue) throw new Error("Failed to create consent cookie");
const consentResponse = await fetch(watchUrl, {
headers: {
...baseHeaders,
Cookie: `CONSENT=YES+${consentValue[1]}`,
},
});
if (!consentResponse.ok) throw new Error(`HTTP ${consentResponse.status} fetching video page (consent)`);
html = await consentResponse.text();
}
return html;
}
function extractSession(html: string, videoId: string): InnerTubeSession {
const apiKey = html.match(/"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/)?.[1];
if (!apiKey) {
if (html.includes('class="g-recaptcha"')) throw new Error(`IP blocked for ${videoId} (reCAPTCHA)`);
throw new Error(`Cannot extract API key for ${videoId}`);
}
const webClientVersion =
html.match(/"INNERTUBE_CLIENT_VERSION":\s*"([^"]+)"/)?.[1] ||
html.match(/"clientVersion":"([^"]+)"/)?.[1] ||
DEFAULT_WEB_CLIENT_VERSION;
const visitorData =
html.match(/"VISITOR_DATA":"([^"]+)"/)?.[1] ||
html.match(/"visitorData":"([^"]+)"/)?.[1] ||
"";
return { apiKey, webClientVersion, visitorData };
}
function buildInnerTubeContext(client: InnerTubeClient, session: InnerTubeSession, videoId: string) {
return {
context: {
client: {
hl: "en",
gl: "US",
utcOffsetMinutes: 0,
visitorData: session.visitorData,
clientName: client.clientName,
clientVersion: client.clientVersion || session.webClientVersion,
...client.extraContext,
},
request: { useSsl: true },
},
videoId,
};
}
async function fetchInnertubeData(videoId: string, session: InnerTubeSession, client: InnerTubeClient): Promise<any> {
const clientVersion = client.clientVersion || session.webClientVersion;
const headers: Record<string, string> = {
Accept: "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Origin: "https://www.youtube.com",
Referer: `${WATCH_URL}${videoId}`,
"User-Agent": client.userAgent,
"X-YouTube-Client-Name": client.clientHeaderName || "1",
"X-YouTube-Client-Version": clientVersion,
};
if (session.visitorData) headers["X-Goog-Visitor-Id"] = session.visitorData;
const response = await fetch(`${INNERTUBE_URL}?key=${session.apiKey}&prettyPrint=false`, {
method: "POST",
headers,
body: JSON.stringify(buildInnerTubeContext(client, session, videoId)),
});
if (response.status === 429) throw new Error(`IP blocked for ${videoId} (429)`);
if (!response.ok) throw new Error(`HTTP ${response.status} from InnerTube API`);
return response.json();
}
function assertPlayability(data: any, videoId: string) {
const playabilityStatus = data?.playabilityStatus;
if (!playabilityStatus) return;
const status = playabilityStatus.status;
if (status === "OK" || !status) return;
const reason = playabilityStatus.reason || "";
const reasonLower = reason.toLowerCase();
if (status === "LOGIN_REQUIRED") {
if (reasonLower.includes("bot")) throw makeError(`Request blocked for ${videoId}: bot detected`, "BOT_DETECTED");
if (reasonLower.includes("inappropriate")) throw makeError(`Age restricted: ${videoId}`, "AGE_RESTRICTED");
}
if (status === "ERROR" && reasonLower.includes("unavailable")) {
if (videoId.startsWith("http")) throw makeError("Invalid video ID: pass the ID, not the URL", "INVALID_VIDEO_ID");
throw makeError(`Video unavailable: ${videoId}`, "VIDEO_UNAVAILABLE");
}
const subreasons = playabilityStatus.errorScreen?.playerErrorMessageRenderer?.subreason?.runs?.map((run: any) => run.text).join("") || "";
throw new Error(`Video unplayable (${videoId}): ${reason} ${subreasons}`.trim());
}
function extractCaptionsJson(data: any, videoId: string): any {
assertPlayability(data, videoId);
const captionsJson = data?.captions?.playerCaptionsTracklistRenderer;
if (!captionsJson || !captionsJson.captionTracks) throw makeError(`Transcripts disabled for ${videoId}`, "TRANSCRIPTS_DISABLED");
return captionsJson;
}
function buildTranscriptList(captionsJson: any): TranscriptInfo[] {
const translationLanguages = (captionsJson.translationLanguages || []).map((language: any) => ({
language: language.languageName?.runs?.[0]?.text || language.languageName?.simpleText || "",
languageCode: language.languageCode,
}));
return (captionsJson.captionTracks || []).map((track: any) => ({
language: track.name?.runs?.[0]?.text || track.name?.simpleText || "",
languageCode: track.languageCode,
isGenerated: track.kind === "asr",
isTranslatable: !!track.isTranslatable,
baseUrl: track.baseUrl || "",
translationLanguages: track.isTranslatable ? translationLanguages : [],
}));
}
export async function fetchTranscriptSnippets(
info: TranscriptInfo,
translateTo?: string
): Promise<{ snippets: Snippet[]; language: string; languageCode: string }> {
let url = info.baseUrl;
let language = info.language;
let languageCode = info.languageCode;
if (translateTo) {
if (!info.isTranslatable) throw new Error(`Transcript ${info.languageCode} is not translatable`);
const translatedLanguage = info.translationLanguages.find((entry) => entry.languageCode === translateTo);
if (!translatedLanguage) throw new Error(`Translation language ${translateTo} not available`);
url += `&tlang=${translateTo}`;
language = translatedLanguage.language;
languageCode = translateTo;
}
const response = await fetch(url, {
headers: {
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": WATCH_PAGE_USER_AGENT,
},
});
if (!response.ok) throw new Error(`HTTP ${response.status} fetching transcript`);
return {
snippets: parseTranscriptPayload(await response.text(), url),
language,
languageCode,
};
}
export function detectYtDlpCommand(): { command: string; args: string[]; label: string } | null {
if (cachedYtDlpCommand !== undefined) return cachedYtDlpCommand;
const candidates = [
{ command: "yt-dlp", args: [], label: "yt-dlp" },
{ command: "uvx", args: ["--from", "yt-dlp", "yt-dlp"], label: "uvx --from yt-dlp yt-dlp" },
{ command: "python3", args: ["-m", "yt_dlp"], label: "python3 -m yt_dlp" },
];
for (const candidate of candidates) {
const probe = spawnSync(candidate.command, [...candidate.args, "--version"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (probe.status !== 0) continue;
const helpProbe = spawnSync(candidate.command, [...candidate.args, "--help"], {
encoding: "utf8",
maxBuffer: 2 * 1024 * 1024,
});
const helpText = `${helpProbe.stdout || ""}\n${helpProbe.stderr || ""}`;
const supportsRequiredFlags =
helpProbe.status === 0 &&
helpText.includes("--js-runtimes") &&
helpText.includes("--remote-components");
if (supportsRequiredFlags) {
cachedYtDlpCommand = candidate;
return candidate;
}
}
cachedYtDlpCommand = null;
return cachedYtDlpCommand;
}
export function selectYtDlpTrack(entries: YtDlpTrack[]): YtDlpTrack | null {
const preferredExts = ["json3", "srv3", "srv2", "srv1", "ttml", "vtt"];
for (const ext of preferredExts) {
const match = entries.find((entry) => entry.url && entry.ext === ext);
if (match) return match;
}
return entries.find((entry) => !!entry.url) || null;
}
export function buildTranscriptListFromYtDlp(info: YtDlpInfo): TranscriptInfo[] {
const translationLanguages = Object.entries(info.automatic_captions || {}).map(([languageCode, entries]) => ({
language: entries.find((entry) => entry.name)?.name || languageCode,
languageCode,
}));
const manual = Object.entries(info.subtitles || {}).flatMap(([languageCode, entries]) => {
const selected = selectYtDlpTrack(entries);
if (!selected?.url) return [];
return [{
language: selected.name || languageCode,
languageCode,
isGenerated: false,
isTranslatable: translationLanguages.length > 0,
baseUrl: selected.url,
translationLanguages,
}];
});
const generated = Object.entries(info.automatic_captions || {}).flatMap(([languageCode, entries]) => {
const selected = selectYtDlpTrack(entries);
if (!selected?.url) return [];
return [{
language: selected.name || languageCode,
languageCode,
isGenerated: true,
isTranslatable: translationLanguages.length > 0,
baseUrl: selected.url,
translationLanguages,
}];
});
return [...manual, ...generated];
}
function fetchYtDlpInfo(videoId: string): YtDlpInfo {
const command = detectYtDlpCommand();
if (!command) {
throw makeError(
`Request blocked for ${videoId}: bot detected. yt-dlp fallback unavailable (install yt-dlp or uv).`,
"YT_DLP_UNAVAILABLE"
);
}
const args = [
...command.args,
"-J",
"--skip-download",
"--js-runtimes",
"bun",
"--remote-components",
"ejs:github",
];
const cookiesFromBrowser = process.env.YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER?.trim();
if (cookiesFromBrowser) args.push("--cookies-from-browser", cookiesFromBrowser);
args.push(`${WATCH_URL}${videoId}`);
const result = spawnSync(command.command, args, {
encoding: "utf8",
maxBuffer: YT_DLP_MAX_BUFFER,
});
if (result.status !== 0) {
const stderr = (result.stderr || "").trim();
const stdout = (result.stdout || "").trim();
const detail = stderr || stdout || `exit ${result.status ?? "unknown"}`;
throw makeError(`yt-dlp fallback failed for ${videoId} (${command.label}): ${detail}`, "YT_DLP_FAILED");
}
return JSON.parse(result.stdout);
}
async function fetchInnertubeSource(videoId: string): Promise<VideoSource> {
const html = await fetchHtml(videoId);
const session = extractSession(html, videoId);
const attempts: string[] = [];
let lastError: Error | null = null;
for (const client of INNER_TUBE_CLIENTS) {
try {
const data = await fetchInnertubeData(videoId, session, client);
const captionsJson = extractCaptionsJson(data, videoId);
return { kind: "innertube", data, transcripts: buildTranscriptList(captionsJson) };
} catch (error) {
const normalized = normalizeError(error);
attempts.push(`${client.id}: ${normalized.message}`);
lastError = normalized;
if (!shouldTryAlternateClient(normalized)) break;
}
}
if (!lastError) throw makeError(`Unable to fetch transcript metadata for ${videoId}`, "UNKNOWN");
if (attempts.length > 1) {
throw makeError(`${lastError.message}. Tried clients: ${attempts.join("; ")}`, normalizeError(lastError).code);
}
throw lastError;
}
export async function resolveVideoSource(
videoId: string,
fetchPrimary: (videoId: string) => Promise<VideoSource>,
fetchFallback: (videoId: string) => YtDlpInfo,
logWarning: (message: string) => void = (message) => console.error(message)
): Promise<VideoSource> {
try {
return await fetchPrimary(videoId);
} catch (error) {
const normalized = normalizeError(error);
if (!shouldTryYtDlpFallback(normalized)) throw normalized;
logWarning(`Warning (${videoId}): ${normalized.message}. Retrying with yt-dlp fallback.`);
const info = fetchFallback(videoId);
const transcripts = buildTranscriptListFromYtDlp(info);
if (!transcripts.length) throw makeError(`Transcripts disabled for ${videoId}`, "TRANSCRIPTS_DISABLED");
return { kind: "yt-dlp", info, transcripts };
}
}
export async function fetchVideoSource(videoId: string): Promise<VideoSource> {
return resolveVideoSource(videoId, fetchInnertubeSource, fetchYtDlpInfo);
}
export function parseChapters(description: string, duration: number = 0): Chapter[] {
const raw: { title: string; start: number }[] = [];
for (const line of description.split("\n")) {
const match = line.trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s+(.+)$/);
if (match) {
const hours = match[1] ? parseInt(match[1]) : 0;
raw.push({ title: match[4].trim(), start: hours * 3600 + parseInt(match[2]) * 60 + parseInt(match[3]) });
}
}
if (raw.length < 2) return [];
return raw.map((chapter, index) => ({
title: chapter.title,
start: chapter.start,
end: index < raw.length - 1 ? raw[index + 1].start : Math.max(duration, chapter.start),
}));
}
export function getThumbnailUrls(videoId: string, data: any): string[] {
const urls = [
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
];
const thumbnails = data?.videoDetails?.thumbnail?.thumbnails ||
data?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails ||
[];
if (thumbnails.length) {
const sorted = [...thumbnails].sort((a: any, b: any) => (b.width || 0) - (a.width || 0));
for (const thumbnail of sorted) {
if (thumbnail.url && !urls.includes(thumbnail.url)) urls.push(thumbnail.url);
}
}
return urls;
}
export function getYtDlpThumbnailUrls(videoId: string, info: YtDlpInfo): string[] {
const urls = getThumbnailUrls(videoId, null);
const thumbnails = Array.isArray(info.thumbnails) ? info.thumbnails : [];
const sorted = [...thumbnails].sort((a, b) => (b?.width || 0) - (a?.width || 0));
for (const thumbnail of sorted) {
if (thumbnail?.url && !urls.includes(thumbnail.url)) urls.push(thumbnail.url);
}
if (info.thumbnail && !urls.includes(info.thumbnail)) urls.push(info.thumbnail);
return urls;
}
export function buildVideoMeta(data: any, videoId: string, language: LanguageMeta, chapters: Chapter[]): VideoMeta {
const videoDetails = data?.videoDetails || {};
const microformat = data?.microformat?.playerMicroformatRenderer || {};
return {
videoId,
title: videoDetails.title || microformat.title?.simpleText || "",
channel: videoDetails.author || microformat.ownerChannelName || "",
channelId: videoDetails.channelId || microformat.externalChannelId || "",
description: videoDetails.shortDescription || microformat.description?.simpleText || "",
duration: parseInt(videoDetails.lengthSeconds || "0"),
publishDate: microformat.publishDate || microformat.uploadDate || "",
url: `${WATCH_URL}${videoId}`,
coverImage: "",
thumbnailUrl: getThumbnailUrls(videoId, data)[0],
language,
chapters,
};
}
export function buildVideoMetaFromYtDlp(
info: YtDlpInfo,
videoId: string,
language: LanguageMeta,
chapters: Chapter[]
): VideoMeta {
return {
videoId,
title: info.title || "",
channel: info.channel || info.uploader || "",
channelId: info.channel_id || info.uploader_id || "",
description: info.description || "",
duration: Number(info.duration || 0),
publishDate: normalizePublishDate(info.upload_date),
url: info.webpage_url || `${WATCH_URL}${videoId}`,
coverImage: "",
thumbnailUrl: getYtDlpThumbnailUrls(videoId, info)[0] || "",
language,
chapters,
};
}
export async function downloadCoverImage(urls: string[], outputPath: string): Promise<boolean> {
for (const url of urls) {
try {
const response = await fetch(url);
if (response.ok) {
writeFileSync(outputPath, Buffer.from(await response.arrayBuffer()));
return true;
}
} catch {}
}
return false;
}