From e413ade164bbe6c2d0b5de653d5f48f8e77761d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Sat, 21 Mar 2026 22:42:43 -0500 Subject: [PATCH] feat(baoyu-youtube-transcript): add sentence segmentation and improve caching --- README.md | 36 +++- README.zh.md | 36 +++- skills/baoyu-youtube-transcript/SKILL.md | 14 +- .../prompts/speaker-transcript.md | 6 +- .../baoyu-youtube-transcript/scripts/main.ts | 195 +++++++++++++++--- 5 files changed, 244 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9d88689..ba28783 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Simply tell Claude Code: |--------|-------------|--------| | **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) | | **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) | -| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) | +| **utility-skills** | Utility tools for content processing | [youtube-transcript](#baoyu-youtube-transcript), [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) | ## Update Skills @@ -766,6 +766,40 @@ Interacts with Gemini Web to generate text and images. Utility tools for content processing. +#### baoyu-youtube-transcript + +Download YouTube video transcripts/subtitles and cover images. Supports multiple languages, translation, chapters, and speaker identification. Caches raw data for fast re-formatting. + +```bash +# Default: markdown with timestamps +/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID + +# Specify languages (priority order) +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja + +# With chapters and speaker identification +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers + +# SRT subtitle format +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt + +# List available transcripts +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list +``` + +**Options**: +| Option | Description | Default | +|--------|-------------|---------| +| `` | YouTube URL or video ID | Required | +| `--languages ` | Language codes, comma-separated | `en` | +| `--format ` | Output format: `text`, `srt` | `text` | +| `--translate ` | Translate to specified language | | +| `--chapters` | Chapter segmentation from video description | | +| `--speakers` | Speaker identification (requires AI post-processing) | | +| `--no-timestamps` | Disable timestamps | | +| `--list` | List available transcripts | | +| `--refresh` | Force re-fetch, ignore cache | | + #### baoyu-url-to-markdown Fetch any URL via Chrome CDP and convert to clean markdown. Saves rendered HTML snapshot alongside the markdown, and automatically falls back to a legacy extractor when Defuddle fails. diff --git a/README.zh.md b/README.zh.md index 780577e..6a9abac 100644 --- a/README.zh.md +++ b/README.zh.md @@ -76,7 +76,7 @@ clawhub install baoyu-markdown-to-html |------|------|----------| | **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) | | **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) | -| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) | +| **utility-skills** | 内容处理工具 | [youtube-transcript](#baoyu-youtube-transcript), [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) | ## 更新技能 @@ -766,6 +766,40 @@ AI 驱动的生成后端。 内容处理工具。 +#### baoyu-youtube-transcript + +下载 YouTube 视频字幕/转录文本和封面图片。支持多语言、翻译、章节分段和说话人识别。缓存原始数据以便快速重新格式化。 + +```bash +# 默认:带时间戳的 Markdown +/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID + +# 指定语言(按优先级排列) +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja + +# 章节分段 + 说话人识别 +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers + +# SRT 字幕格式 +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt + +# 列出可用字幕 +/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list +``` + +**选项**: +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `` | YouTube URL 或视频 ID | 必填 | +| `--languages ` | 语言代码,逗号分隔 | `en` | +| `--format ` | 输出格式:`text`、`srt` | `text` | +| `--translate ` | 翻译为指定语言 | | +| `--chapters` | 根据视频描述进行章节分段 | | +| `--speakers` | 说话人识别(需 AI 后处理) | | +| `--no-timestamps` | 禁用时间戳 | | +| `--list` | 列出可用字幕 | | +| `--refresh` | 强制重新获取,忽略缓存 | | + #### baoyu-url-to-markdown 通过 Chrome CDP 抓取任意 URL 并转换为 Markdown。同时保存渲染后的 HTML 快照,Defuddle 失败时自动回退到旧版提取器。 diff --git a/skills/baoyu-youtube-transcript/SKILL.md b/skills/baoyu-youtube-transcript/SKILL.md index ee4c59a..c169da8 100644 --- a/skills/baoyu-youtube-transcript/SKILL.md +++ b/skills/baoyu-youtube-transcript/SKILL.md @@ -98,11 +98,12 @@ youtube-transcript/ ├── .index.json # Video ID → directory path mapping (for cache lookup) └── {channel-slug}/{title-full-slug}/ ├── meta.json # Video metadata (title, channel, description, duration, chapters, etc.) - ├── transcript-raw.srt # Raw transcript in SRT format (cached, token-efficient for LLM) + ├── transcript-raw.json # Raw transcript snippets from YouTube API (cached) + ├── transcript-sentences.json # Sentence-segmented transcript (split by punctuation, merged across snippets) ├── imgs/ │ └── cover.jpg # Video thumbnail - ├── transcript.md # Markdown transcript - └── transcript.srt # SRT subtitle (if --format srt) + ├── transcript.md # Markdown transcript (generated from sentences) + └── transcript.srt # SRT subtitle (generated from raw snippets, if --format srt) ``` - `{channel-slug}`: Channel name in kebab-case @@ -114,11 +115,14 @@ The `--list` mode outputs to stdout only (no file saved). On first fetch, the script saves: - `meta.json` — video metadata, chapters, cover image path, language info -- `transcript-raw.srt` — raw transcript in SRT format (pre-computed timestamps, token-efficient for LLM processing) +- `transcript-raw.json` — raw transcript snippets from YouTube API (`{ text, start, duration }[]`) +- `transcript-sentences.json` — sentence-segmented transcript (`{ text, start: "HH:mm:ss", end: "HH:mm:ss" }[]`), split by sentence-ending punctuation (`.?!…。?!` etc.), timestamps proportionally allocated by character length, CJK-aware text merging - `imgs/cover.jpg` — video thumbnail 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. +SRT output (`--format srt`) is generated from `transcript-raw.json`. Text/markdown output uses `transcript-sentences.json` for natural sentence boundaries. + ## Workflow When user provides a YouTube URL and wants the transcript: @@ -148,7 +152,7 @@ Speaker identification requires AI processing. The script outputs a raw `.md` fi - Chapter list from description (if available) - Raw transcript in SRT format (pre-computed start/end timestamps, token-efficient) -After the script saves the raw file: +After the script saves the raw file, spawn a sub-agent (use a cheaper model like Sonnet for cost efficiency) to process speaker identification: 1. Read the saved `.md` file 2. Read the prompt template at `{baseDir}/prompts/speaker-transcript.md` diff --git a/skills/baoyu-youtube-transcript/prompts/speaker-transcript.md b/skills/baoyu-youtube-transcript/prompts/speaker-transcript.md index 9e4808f..16e3159 100644 --- a/skills/baoyu-youtube-transcript/prompts/speaker-transcript.md +++ b/skills/baoyu-youtube-transcript/prompts/speaker-transcript.md @@ -44,7 +44,7 @@ Use the same language as the transcription for the title and ToC. **Chapters:** ``` -## [HH:MM:SS] Chapter Title +## Chapter Title [HH:MM:SS] ``` Two blank lines between chapters. @@ -87,14 +87,14 @@ language: en * [00:00:12] Overview of the New Research -## [00:00:00] Introduction and Welcome +## Introduction and Welcome [00:00:00] **Host:** Welcome back to the show. Today, we have a, uh, very special guest, Jane Doe. [00:00:00 → 00:00:03] **Jane Doe:** Thank you for having me. I'm excited to be here and discuss the findings. [00:00:03 → 00:00:07] -## [00:00:12] Overview of the New Research +## Overview of the New Research [00:00:12] **Host:** So, Jane, before we get into the nitty-gritty, could you, you know, give us a brief overview for our audience? [00:00:12 → 00:00:16] diff --git a/skills/baoyu-youtube-transcript/scripts/main.ts b/skills/baoyu-youtube-transcript/scripts/main.ts index 2a01c8a..a7a9ce6 100644 --- a/skills/baoyu-youtube-transcript/scripts/main.ts +++ b/skills/baoyu-youtube-transcript/scripts/main.ts @@ -26,6 +26,12 @@ interface Snippet { duration: number; } +interface Sentence { + text: string; + start: string; + end: string; +} + interface TranscriptInfo { language: string; languageCode: string; @@ -255,14 +261,18 @@ function parseChapters(description: string): Chapter[] { return chapters.length >= 2 ? chapters : []; } -function getBestThumbnailUrl(videoId: string, data: any): string { +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)); - return sorted[0].url; + for (const t of sorted) if (t.url && !urls.includes(t.url)) urls.push(t.url); } - return `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; + return urls; } function buildVideoMeta(data: any, videoId: string, langInfo: { code: string; name: string; isGenerated: boolean }, chapters: Chapter[]): VideoMeta { @@ -278,15 +288,13 @@ function buildVideoMeta(data: any, videoId: string, langInfo: { code: string; na publishDate: mf.publishDate || mf.uploadDate || "", url: `https://www.youtube.com/watch?v=${videoId}`, coverImage: "", - thumbnailUrl: getBestThumbnailUrl(videoId, data), + thumbnailUrl: getThumbnailUrls(videoId, data)[0], language: langInfo, chapters, }; } -async function downloadCoverImage(url: string, outputPath: string): Promise { - const urls = [url]; - if (url.includes("maxresdefault")) urls.push(url.replace("maxresdefault", "hqdefault")); +async function downloadCoverImage(urls: string[], outputPath: string): Promise { for (const u of urls) { try { const r = await fetch(u); @@ -356,6 +364,129 @@ function groupIntoParagraphs(snippets: Snippet[]): Paragraph[] { 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 { @@ -374,7 +505,7 @@ function yamlEscape(s: string): string { return s; } -function formatMarkdown(snippets: Snippet[], meta: VideoMeta, opts: { timestamps: boolean; chapters: boolean; speakers: boolean }, rawSrt?: string): string { +function formatMarkdown(sentences: Sentence[], meta: VideoMeta, opts: { timestamps: boolean; chapters: boolean; speakers: boolean }, snippets?: Snippet[]): string { let md = "---\n"; md += `title: ${yamlEscape(meta.title)}\n`; md += `channel: ${yamlEscape(meta.channel)}\n`; @@ -392,7 +523,7 @@ function formatMarkdown(snippets: Snippet[], meta: VideoMeta, opts: { timestamps md += "\n"; } md += "# Transcript\n\n"; - md += rawSrt || formatSrt(snippets); + md += snippets ? formatSrt(snippets) : ""; return md; } @@ -404,8 +535,8 @@ function formatMarkdown(snippets: Snippet[], meta: VideoMeta, opts: { timestamps md += "\n\n"; for (let i = 0; i < chapters.length; i++) { const nextStart = i < chapters.length - 1 ? chapters[i + 1].start : Infinity; - const segs = snippets.filter(s => s.start >= chapters[i].start && s.start < nextStart); - const paras = groupIntoParagraphs(segs); + 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`; @@ -413,7 +544,7 @@ function formatMarkdown(snippets: Snippet[], meta: VideoMeta, opts: { timestamps md += "\n"; } } else { - const paras = groupIntoParagraphs(snippets); + 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`; } @@ -471,7 +602,7 @@ function registerVideoDir(videoId: string, channelSlug: string, titleSlug: strin } function hasCachedData(videoDir: string): boolean { - return existsSync(join(videoDir, "meta.json")) && existsSync(join(videoDir, "transcript-raw.srt")); + return existsSync(join(videoDir, "meta.json")) && existsSync(join(videoDir, "transcript-raw.json")); } function loadMeta(videoDir: string): VideoMeta { @@ -479,16 +610,16 @@ function loadMeta(videoDir: string): VideoMeta { } function loadSnippets(videoDir: string): Snippet[] { - return parseSrt(readFileSync(join(videoDir, "transcript-raw.srt"), "utf-8")); + return JSON.parse(readFileSync(join(videoDir, "transcript-raw.json"), "utf-8")); } -function loadRawSrt(videoDir: string): string { - return readFileSync(join(videoDir, "transcript-raw.srt"), "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[]; videoDir: string }> { +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); @@ -501,23 +632,22 @@ async function fetchAndCache(videoId: string, baseDir: string, opts: Options): P const langInfo = { code: result.languageCode, name: result.language, isGenerated: info.isGenerated }; const meta = buildVideoMeta(data, videoId, langInfo, chapters); - // Compute directory: {baseDir}/{channel-slug}/{title-slug}/ const videoDir = registerVideoDir(videoId, slugify(meta.channel), slugify(meta.title), baseDir); - - // Save raw data as SRT (pre-computed timestamps, token-efficient for LLM) ensureDir(join(videoDir, "meta.json")); - writeFileSync(join(videoDir, "transcript-raw.srt"), formatSrt(result.snippets)); - // Download cover image + writeFileSync(join(videoDir, "transcript-raw.json"), JSON.stringify(result.snippets, null, 2)); + + const sentences = segmentIntoSentences(result.snippets); + writeFileSync(join(videoDir, "transcript-sentences.json"), JSON.stringify(sentences, null, 2)); + const imgPath = join(videoDir, "imgs", "cover.jpg"); ensureDir(imgPath); - const downloaded = await downloadCoverImage(meta.thumbnailUrl, imgPath); + const downloaded = await downloadCoverImage(getThumbnailUrls(videoId, data), imgPath); meta.coverImage = downloaded ? "imgs/cover.jpg" : ""; - // Save meta (after cover image result is known) writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2)); - return { meta, snippets: result.snippets, videoDir }; + return { meta, snippets: result.snippets, sentences, videoDir }; } async function processVideo(videoId: string, opts: Options): Promise { @@ -534,17 +664,16 @@ async function processVideo(videoId: string, opts: Options): Promise