Compare commits

...

11 Commits

Author SHA1 Message Date
Jim Liu 宝玉 6a4b312146 chore: release v1.77.0 2026-03-22 15:17:12 -05:00
Jim Liu 宝玉 2d6fe533eb Merge pull request #105 from jzOcb/feat/chapter-end-times
feat(youtube-transcript): add end times to chapter data
2026-03-22 15:13:44 -05:00
jzocb f53af25e65 fix: address review feedback on chapter end times
- Guard last chapter end against duration=0: use Math.max(duration, ch.start)
- Remove unnecessary 'as any' cast in backfill
- Check all chapters for missing end (not just first) via .some()
- Skip backfill when needsFetch is true (about to refetch anyway)
- Wrap backfill writeFileSync in try/catch (best-effort persistence)
2026-03-22 16:07:05 -04:00
jzocb c7e32b4590 fix: backfill chapter end times for cached videos
Videos cached before the chapter end-time change would silently
lack the 'end' field when loaded from cache. This adds a migration
that detects missing 'end' fields on cache hit, computes them from
adjacent chapters, and persists the updated meta.json.

This ensures consistent output regardless of whether the data was
freshly fetched or loaded from cache.
2026-03-22 15:58:13 -04:00
jzocb 8d973f2bc5 feat(youtube-transcript): add end times to chapter data
Add 'end' field to Chapter interface and parseChapters output.
Each chapter's end is derived from the next chapter's start time,
with the last chapter ending at the video's total duration.

This makes chapter data complete and ready for downstream consumers
(e.g. video clipping with ffmpeg) without requiring them to compute
end times from adjacent chapters.

Before: { title: 'Overview', start: 0 }
After:  { title: 'Overview', start: 0, end: 21 }
2026-03-22 15:52:30 -04:00
Jim Liu 宝玉 ba20cf89f2 fix(sync-clawhub): skip failed skills instead of aborting 2026-03-21 23:25:45 -05:00
Jim Liu 宝玉 1827be9234 chore: release v1.76.1 2026-03-21 23:17:04 -05:00
Jim Liu 宝玉 93c98dfc3c docs(baoyu-youtube-transcript): fix zsh glob issue for YouTube URLs 2026-03-21 23:16:51 -05:00
Jim Liu 宝玉 fbd9f9b622 chore: release v1.76.0 2026-03-21 23:08:21 -05:00
Jim Liu 宝玉 b6e293d059 fix(baoyu-markdown-to-html): use process.execPath and tsx import in test runner 2026-03-21 23:07:46 -05:00
Jim Liu 宝玉 bb78aab095 feat(baoyu-youtube-transcript): add title heading, description summary, and cover image to markdown output 2026-03-21 23:07:44 -05:00
9 changed files with 127 additions and 26 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.75.0"
"version": "1.77.0"
},
"plugins": [
{
+21
View File
@@ -2,6 +2,27 @@
English | [中文](./CHANGELOG.zh.md)
## 1.77.0 - 2026-03-22
### Features
- `baoyu-youtube-transcript`: add end times to chapter data (by @jzOcb)
### Fixes
- `sync-clawhub`: skip failed skills instead of aborting
## 1.76.1 - 2026-03-21
### Documentation
- `baoyu-youtube-transcript`: fix zsh glob issue — always single-quote YouTube URLs when running the script
## 1.76.0 - 2026-03-21
### Features
- `baoyu-youtube-transcript`: add title heading, description summary, and cover image to markdown output
### Fixes
- `baoyu-markdown-to-html`: use process.execPath and tsx import in test runner
## 1.75.0 - 2026-03-21
### Features
+21
View File
@@ -2,6 +2,27 @@
[English](./CHANGELOG.md) | 中文
## 1.77.0 - 2026-03-22
### 新功能
- `baoyu-youtube-transcript`:为章节数据添加结束时间 (by @jzOcb)
### 修复
- `sync-clawhub`:跳过失败的技能而不是中止同步
## 1.76.1 - 2026-03-21
### 文档
- `baoyu-youtube-transcript`:修复 zsh glob 问题 — 运行脚本时始终对 YouTube URL 使用单引号
## 1.76.0 - 2026-03-21
### 新功能
- `baoyu-youtube-transcript`:Markdown 输出中新增标题、描述摘要和封面图片
### 修复
- `baoyu-markdown-to-html`:测试运行器改用 process.execPath 和 tsx import
## 1.75.0 - 2026-03-21
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.75.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.77.0**.
## Architecture
+24 -11
View File
@@ -151,6 +151,9 @@ async function main() {
.map((tag) => tag.trim())
.filter(Boolean);
let succeeded = 0;
const failed = [];
for (const candidate of actionable) {
const version =
candidate.status === "new"
@@ -158,20 +161,30 @@ async function main() {
: bumpSemver(candidate.latestVersion, options.bump);
console.log(`Publishing ${candidate.slug}@${version}`);
const files = await listTextFiles(candidate.folder);
await publishSkill({
registry,
token: config.token,
skill: candidate,
files,
version,
changelog: options.changelog,
tags,
});
try {
const files = await listTextFiles(candidate.folder);
await publishSkill({
registry,
token: config.token,
skill: candidate,
files,
version,
changelog: options.changelog,
tags,
});
succeeded++;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`SKIPPED ${candidate.slug}: ${msg}`);
failed.push(candidate.slug);
}
}
console.log("");
console.log(`Uploaded ${actionable.length} skill(s).`);
console.log(`Uploaded ${succeeded}/${actionable.length} skill(s).`);
if (failed.length > 0) {
console.log(`Failed (${failed.length}): ${failed.join(", ")}`);
}
}
function parseArgs(argv) {
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -21,8 +22,10 @@ test("CLI forwards wrapper title and vendor render options", async () => {
await fs.writeFile(markdownPath, "## Section\n\nParagraph with **bold** text.\n", "utf-8");
const { stdout } = await execFileAsync(
"bun",
process.execPath,
[
"--import",
"tsx",
SCRIPT_PATH,
markdownPath,
"--theme", "grace",
+4 -3
View File
@@ -88,7 +88,7 @@ Accepts any of these as video input:
| Format | Extension | Description |
|--------|-----------|-------------|
| `text` | `.md` | Markdown with frontmatter, natural paragraphs, optional timestamps/chapters/speakers |
| `text` | `.md` | Markdown with frontmatter (incl. `description`), title heading, summary, optional TOC/cover/timestamps/chapters/speakers |
| `srt` | `.srt` | SubRip subtitle format for video players |
## Output Directory
@@ -128,7 +128,8 @@ SRT output (`--format srt`) is generated from `transcript-raw.json`. Text/markdo
When user provides a YouTube URL and wants the transcript:
1. Run with `--list` first if the user hasn't specified a language, to show available options
2. Default: run with `--chapters --speakers` for the richest output (chapters + speaker identification)
2. **Always single-quote the URL** when running the script — zsh treats `?` as a glob wildcard, so an unquoted YouTube URL causes "no matches found": use `'https://www.youtube.com/watch?v=ID'`
3. Default: run with `--chapters --speakers` for the richest output (chapters + speaker identification)
3. The script auto-saves cached data + output file and prints the file path
4. For `--speakers` mode: after the script saves the raw file, follow the speaker identification workflow below to post-process with speaker labels
@@ -147,7 +148,7 @@ If no chapter timestamps exist in the description, the transcript is output as g
### Speaker Identification (`--speakers`)
Speaker identification requires AI processing. The script outputs a raw `.md` file containing:
- YAML frontmatter with video metadata (title, channel, date, cover, language)
- YAML frontmatter with video metadata (title, channel, date, cover, description, language)
- Video description (for speaker name extraction)
- Chapter list from description (if available)
- Raw transcript in SRT format (pre-computed start/end timestamps, token-efficient)
@@ -5,9 +5,12 @@ You are an expert transcript specialist. Process the raw transcript file (with Y
## Output Structure
Produce a single cohesive markdown file containing:
1. YAML frontmatter (keep the original frontmatter from the raw file)
2. Table of Contents
3. Full chapter-segmented transcript with speaker labels
1. YAML frontmatter (keep the original frontmatter from the raw file, which includes `description`)
2. `# Title` heading (from frontmatter title)
3. Description/summary paragraph (from frontmatter `description`)
4. Table of Contents
5. Cover image (if `cover` exists in frontmatter): `![cover](imgs/cover.jpg)` — right after the ToC
6. Full chapter-segmented transcript with speaker labels
Use the same language as the transcription for the title and ToC.
@@ -79,13 +82,20 @@ channel: "The Show"
date: 2024-04-15
url: "https://www.youtube.com/watch?v=xxx"
cover: imgs/cover.jpg
description: "Jane Doe discusses her groundbreaking five-year study on the long-term effects of dietary changes."
language: en
---
# Example Interview
Jane Doe discusses her groundbreaking five-year study on the long-term effects of dietary changes.
## Table of Contents
* [00:00:00] Introduction and Welcome
* [00:00:12] Overview of the New Research
![cover](imgs/cover.jpg)
## Introduction and Welcome [00:00:00]
@@ -44,6 +44,7 @@ interface TranscriptInfo {
interface Chapter {
title: string;
start: number;
end: number;
}
interface VideoMeta {
@@ -249,16 +250,21 @@ async function fetchTranscriptSnippets(info: TranscriptInfo, translateTo?: strin
// --- Metadata & chapters ---
function parseChapters(description: string): Chapter[] {
const chapters: Chapter[] = [];
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;
chapters.push({ title: m[4].trim(), start: h * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) });
raw.push({ title: m[4].trim(), start: h * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) });
}
}
return chapters.length >= 2 ? chapters : [];
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[] {
@@ -505,17 +511,28 @@ function yamlEscape(s: string): string {
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";
@@ -527,12 +544,17 @@ function formatMarkdown(sentences: Sentence[], meta: VideoMeta, opts: { timestam
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\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);
@@ -628,7 +650,8 @@ async function fetchAndCache(videoId: string, baseDir: string, opts: Options): P
const info = findTranscript(transcripts, opts.languages, opts.excludeGenerated, opts.excludeManual);
const result = await fetchTranscriptSnippets(info, opts.translate || undefined);
const description = data?.videoDetails?.shortDescription || "";
const chapters = parseChapters(description);
const duration = parseInt(data?.videoDetails?.lengthSeconds || "0");
const chapters = parseChapters(description, duration);
const langInfo = { code: result.languageCode, name: result.language, isGenerated: info.isGenerated };
const meta = buildVideoMeta(data, videoId, langInfo, chapters);
@@ -676,6 +699,15 @@ async function processVideo(videoId: string, opts: Options): Promise<VideoResult
sentences = loadSentences(videoDir);
const wantLangs = opts.translate ? [opts.translate] : opts.languages;
if (!wantLangs.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((ch: any) => ch.end === undefined)) {
for (let i = 0; i < meta.chapters.length; i++) {
meta.chapters[i].end = i < meta.chapters.length - 1
? meta.chapters[i + 1].start
: Math.max(meta.duration, meta.chapters[i].start);
}
try { writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2)); } catch {}
}
}
if (needsFetch) {