Compare commits

..

12 Commits

Author SHA1 Message Date
Jim Liu 宝玉 c5c54e26da chore: release v1.90.1 2026-04-05 13:55:58 -05:00
Jim Liu 宝玉 2a0bba6161 fix(baoyu-post-to-wechat): detect actual image format from buffer magic bytes
CDNs may serve WebP for URLs with .png extension. Detect real format
from magic bytes and correct content-type/extension before upload.
Also treat .webp as PNG-preferred for transparency handling.
2026-04-05 13:36:51 -05:00
Jim Liu 宝玉 c44a524fa6 chore: release v1.90.0 2026-04-03 23:45:44 -05:00
Jim Liu 宝玉 826535abe4 feat(baoyu-xhs-images): add --yes non-interactive mode for automation 2026-04-03 23:45:10 -05:00
Jim Liu 宝玉 fc50f31694 chore: release v1.89.2 2026-04-03 23:35:58 -05:00
Jim Liu 宝玉 204765a137 feat(baoyu-youtube-transcript): auto-retry with yt-dlp on empty InnerTube transcript 2026-04-03 23:35:42 -05:00
Jim Liu 宝玉 4874cd2dae feat(baoyu-post-to-wechat): use fallback chain for article summary field 2026-04-03 23:35:34 -05:00
Jim Liu 宝玉 b791ee5dc7 chore: release v1.89.1 2026-04-01 02:12:07 -05:00
Jim Liu 宝玉 450c76d955 chore(baoyu-url-to-markdown): sync vendor baoyu-fetch with login auto-detect 2026-04-01 02:12:04 -05:00
Jim Liu 宝玉 db33da26e7 feat(baoyu-fetch): auto-detect login state before extraction in interaction mode 2026-04-01 02:12:00 -05:00
Jim Liu 宝玉 c7c98ba034 chore: sync vendor baoyu-chrome-cdp across CDP skills 2026-04-01 02:11:56 -05:00
Jim Liu 宝玉 60ab574559 feat(baoyu-chrome-cdp): add gracefulKillChrome and fix killChrome process state check 2026-04-01 02:11:51 -05:00
22 changed files with 538 additions and 49 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.89.0"
"version": "1.90.1"
},
"plugins": [
{
+15
View File
@@ -2,6 +2,21 @@
English | [中文](./CHANGELOG.zh.md)
## 1.90.1 - 2026-04-05
### Fixes
- `baoyu-post-to-wechat`: detect actual image format from buffer magic bytes to fix CDN content-type mismatches (e.g. WebP served for .png URLs); treat WebP as PNG-preferred for transparency handling
## 1.89.1 - 2026-04-01
### Features
- `baoyu-chrome-cdp`: add `gracefulKillChrome` that waits for Chrome to exit and release its port; fix `killChrome` to use `exitCode`/`signalCode` instead of `.killed` for reliable process state detection
- `baoyu-fetch`: auto-detect login state before extraction in interaction wait mode
### Maintenance
- Sync vendor baoyu-chrome-cdp across CDP skills
- `baoyu-url-to-markdown`: sync vendor baoyu-fetch with login auto-detect
## 1.89.0 - 2026-03-31
### Features
+15
View File
@@ -2,6 +2,21 @@
[English](./CHANGELOG.md) | 中文
## 1.90.1 - 2026-04-05
### 修复
- `baoyu-post-to-wechat`:通过 magic bytes 检测实际图片格式,修复 CDN 返回与 URL 扩展名不一致的 content-type 问题(如 .png URL 实际返回 WebP);WebP 格式按 PNG 策略处理以保留透明度
## 1.89.1 - 2026-04-01
### 新功能
- `baoyu-chrome-cdp`:新增 `gracefulKillChrome`,等待 Chrome 进程退出并释放端口;修复 `killChrome` 使用 `exitCode`/`signalCode` 替代 `.killed` 以更可靠地检测进程状态
- `baoyu-fetch`:在交互等待模式下自动检测登录状态,未登录时提示用户先登录再提取内容
### 维护
- 同步 vendor baoyu-chrome-cdp 至所有 CDP 技能
- `baoyu-url-to-markdown`:同步 vendor baoyu-fetch 的登录自动检测功能
## 1.89.0 - 2026-03-31
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.89.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.90.1**.
## Architecture
+4
View File
@@ -116,6 +116,10 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1
# Direct content input
/baoyu-xhs-images 今日星座运势
# Non-interactive (skip all confirmations, for scheduled tasks)
/baoyu-xhs-images posts/ai-future/article.md --yes
/baoyu-xhs-images posts/ai-future/article.md --yes --preset knowledge-card
```
**Styles** (visual aesthetics): `cute` (default), `fresh`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`, `chalkboard`
+4
View File
@@ -116,6 +116,10 @@ clawhub install baoyu-markdown-to-html
# 直接输入内容
/baoyu-xhs-images 今日星座运势
# 非交互模式(跳过所有确认,适用于定时任务)
/baoyu-xhs-images posts/ai-future/article.md --yes
/baoyu-xhs-images posts/ai-future/article.md --yes --preset knowledge-card
```
**风格**(视觉美学):`cute`(默认)、`fresh``warm``bold``minimal``retro``pop``notion``chalkboard`
@@ -11,6 +11,7 @@ import {
discoverRunningChromeDebugPort,
findChromeExecutable,
findExistingChromeDebugPort,
gracefulKillChrome,
getFreePort,
openPageSession,
resolveSharedChromeProfileDir,
@@ -110,6 +111,44 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
await new Promise((resolve) => child.once("exit", resolve));
}
async function startPortHoldingProcess(port: number): Promise<ChildProcess> {
const child = spawn(
process.execPath,
[
"-e",
`
const http = require("node:http");
const port = Number(process.argv[1]);
const server = http.createServer((_req, res) => res.end("ok"));
server.listen(port, "127.0.0.1", () => process.stdout.write("ready\\n"));
setInterval(() => {}, 1000);
`,
String(port),
],
{
stdio: ["ignore", "pipe", "ignore"],
},
);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Timed out waiting for child server to start.")), 3_000);
child.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.stdout?.once("data", () => {
clearTimeout(timer);
resolve();
});
child.once("exit", () => {
clearTimeout(timer);
reject(new Error("Child server exited before becoming ready."));
});
});
return child;
}
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
useEnv(t, { TEST_FIXED_PORT: "45678" });
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
@@ -305,3 +344,19 @@ test("waitForChromeDebugPort retries until the debug endpoint becomes available"
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
});
test("gracefulKillChrome waits for the Chrome process to exit and release its port", async (t) => {
const port = await getFreePort();
const child = await startPortHoldingProcess(port);
t.after(async () => { await stopProcess(child); });
assert.equal(await waitForChromeDebugPort(port, 1_000).catch(() => null), null);
await gracefulKillChrome(child, port, 4_000);
assert.ok(child.exitCode !== null || child.signalCode !== null);
assert.equal(
await fetch(`http://127.0.0.1:${port}`).then(() => true).catch(() => false),
false,
);
});
+32 -1
View File
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
@@ -399,6 +399,22 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
if (restored) logger.info(`Restored ${adapter.name} session cookies from sidecar.`);
}
if (options.waitMode === "interaction" && adapter.checkLogin) {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
const preLogin = await adapter.checkLogin(context);
if (preLogin.state !== "logged_in") {
didLogin = true;
await waitForInteraction(adapter, context, {
type: "wait_for_interaction",
kind: "login",
provider: preLogin.provider ?? adapter.name,
prompt: `Please sign in to ${adapter.name === "x" ? "X" : adapter.name} in the opened Chrome window. Extraction will continue automatically once login is detected.`,
reason: preLogin.reason ?? `Not logged in to ${adapter.name}`,
requiresVisibleBrowser: true,
}, options);
}
}
if (options.waitMode === "force") {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
await waitForForceResume(adapter, context, options);
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
+1 -1
View File
@@ -352,7 +352,7 @@ WECHAT_APP_SECRET=<user_input>
| Field | If Missing |
|-------|------------|
| Title | Prompt: "Enter title, or press Enter to auto-generate from content" |
| Summary | Prompt: "Enter summary, or press Enter to auto-generate (recommended for SEO)" |
| Summary | Use fallback chain: frontmatter `description` → frontmatter `summary` → prompt user or auto-generate |
| Author | Use fallback chain: CLI `--author` → frontmatter `author` → EXTEND.md `default_author` |
**Auto-Generation Logic**:
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
@@ -7,6 +7,7 @@ import {
type WechatUploadAsset,
prepareWechatBodyImageUpload,
needsWechatBodyImageProcessing,
detectImageFormatFromBuffer,
} from "./wechat-image-processor.ts";
interface AccessTokenResponse {
@@ -138,6 +139,16 @@ async function loadUploadAsset(
contentType = mimeTypes[fileExt] || "image/jpeg";
}
// Detect actual format from magic bytes to fix extension/content-type mismatches
// (e.g. CDNs serving WebP for URLs with .png extension)
const detected = detectImageFormatFromBuffer(fileBuffer);
if (detected && detected.contentType !== contentType) {
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
contentType = detected.contentType;
fileExt = detected.fileExt;
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
}
return {
buffer: fileBuffer,
filename,
@@ -52,6 +52,39 @@ const MIME_TO_EXT: Record<string, string> = {
const JPEG_QUALITY_STEPS = [82, 74, 66, 58, 50, 42, 34];
const MAX_WIDTH_STEPS = [2560, 2048, 1600, 1280, 1024, 800, 640, 480];
/**
* Detect actual image format from buffer magic bytes.
* Returns corrected { contentType, fileExt } or null if unknown.
*/
export function detectImageFormatFromBuffer(buffer: Buffer): { contentType: string; fileExt: string } | null {
if (buffer.length < 12) return null;
// WebP: RIFF....WEBP
if (
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50
) {
return { contentType: "image/webp", fileExt: ".webp" };
}
// PNG: 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return { contentType: "image/png", fileExt: ".png" };
}
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { contentType: "image/jpeg", fileExt: ".jpg" };
}
// GIF: GIF8
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
return { contentType: "image/gif", fileExt: ".gif" };
}
// BMP: BM
if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
return { contentType: "image/bmp", fileExt: ".bmp" };
}
return null;
}
let webpDecoderReady: Promise<void> | undefined;
type JimpImage = Awaited<ReturnType<typeof Jimp.read>>;
@@ -209,7 +242,8 @@ export async function prepareWechatBodyImageUpload(
const image = await loadImageForProcessing(asset);
const widths = buildCandidateWidths(image.bitmap.width);
const preferPng = imageHasTransparency(image) || ensureFileExt(asset) === ".png";
const ext = ensureFileExt(asset);
const preferPng = imageHasTransparency(image) || ext === ".png" || ext === ".webp";
const processingNotes = buildProcessingNotes(asset);
for (const width of widths) {
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
@@ -478,7 +478,7 @@ export function killChrome(chrome: ChildProcess): void {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (!chrome.killed) {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
@@ -486,6 +486,37 @@ export function killChrome(chrome: ChildProcess): void {
}, 2_000).unref?.();
}
export async function gracefulKillChrome(
chrome: ChildProcess,
port?: number,
timeoutMs = 6_000,
): Promise<void> {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
const exitPromise = new Promise<void>((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null) return;
if (port !== undefined && !await isPortListening(port, 250)) return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false),
]);
if (exited) return;
}
await Promise.race([
exitPromise,
sleep(250),
]);
}
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
let targetId: string;
let createdTarget = false;
@@ -399,6 +399,22 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
if (restored) logger.info(`Restored ${adapter.name} session cookies from sidecar.`);
}
if (options.waitMode === "interaction" && adapter.checkLogin) {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
const preLogin = await adapter.checkLogin(context);
if (preLogin.state !== "logged_in") {
didLogin = true;
await waitForInteraction(adapter, context, {
type: "wait_for_interaction",
kind: "login",
provider: preLogin.provider ?? adapter.name,
prompt: `Please sign in to ${adapter.name === "x" ? "X" : adapter.name} in the opened Chrome window. Extraction will continue automatically once login is detected.`,
reason: preLogin.reason ?? `Not logged in to ${adapter.name}`,
requiresVisibleBrowser: true,
}, options);
}
}
if (options.waitMode === "force") {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
await waitForForceResume(adapter, context, options);
+32 -19
View File
@@ -39,6 +39,10 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
# Direct input with options
/baoyu-xhs-images --style bold --layout comparison
[paste content]
# Non-interactive (for scheduled tasks / automation)
/baoyu-xhs-images posts/ai-future/article.md --yes
/baoyu-xhs-images posts/ai-future/article.md --yes --preset knowledge-card
```
## Options
@@ -48,6 +52,7 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
| `--style <name>` | Visual style (see Style Gallery) |
| `--layout <name>` | Information layout (see Layout Gallery) |
| `--preset <name>` | Style + layout shorthand (see [Style Presets](references/style-presets.md)) |
| `--yes` | Non-interactive mode: skip all confirmations. Uses EXTEND.md preferences if found, otherwise uses defaults (no watermark, auto style/layout). Auto-confirms recommended plan (Path A). Suitable for scheduled tasks and automation. |
## Two Dimensions
@@ -237,11 +242,11 @@ Copy and track progress:
```
XHS Infographic Progress:
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING (--yes: use defaults if not found)
- [ ] Found → load preferences → continue
- [ ] Not found → run first-time setup → MUST complete before Step 1
- [ ] Not found → run first-time setup → MUST complete before Step 1 (--yes: skip setup, use defaults)
- [ ] Step 1: Analyze content → analysis.md
- [ ] Step 2: Smart Confirm ⚠️ REQUIRED
- [ ] Step 2: Smart Confirm ⚠️ REQUIRED (--yes: auto-confirm Path A)
- [ ] Path A: Quick confirm → generate recommended outline
- [ ] Path B: Customize → adjust then generate outline
- [ ] Path C: Detailed → 3 outlines → second confirm → generate outline
@@ -252,26 +257,30 @@ XHS Infographic Progress:
### Flow
```
Input → [Step 0: Preferences] ─┬─ Found → Continue
└─ Not found → First-Time Setup ⛔ BLOCKING
└─ Complete setup → Save EXTEND.md → Continue
┌───────────────────────────────────────────────────────────────────────────┘
Analyze → [Smart Confirm] ─┬─ Quick: confirm recommended → outline.md → Generate → Complete
├─ Customize: adjust options → outline.md → Generate → Complete
└─ Detailed: 3 outlines → [Confirm 2] → outline.md → Generate → Complete
Input → [--yes?] ─┬─ Yes → [Step 0: Load or defaults] → Analyze → Auto-confirm → Generate → Complete
└─ No → [Step 0: Preferences] ─┬─ Found → Continue
└─ Not found → First-Time Setup ⛔ BLOCKING
└─ Complete setup → Save EXTEND.md → Continue
┌─────────────────────────────────────────────────────────────────────────────────────┘
Analyze → [Smart Confirm] ─┬─ Quick: confirm recommended → outline.md → Generate → Complete
├─ Customize: adjust options → outline.md → Generate → Complete
└─ Detailed: 3 outlines → [Confirm 2] → outline.md → Generate → Complete
```
### Step 0: Load Preferences (EXTEND.md) ⛔ BLOCKING
**Purpose**: Load user preferences or run first-time setup.
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about style, do NOT ask about layout — ONLY complete the preferences setup first.
**`--yes` mode**: If EXTEND.md found → load it. If not found → use built-in defaults (no watermark, style/layout auto-select, language from content). Do NOT run first-time setup, do NOT create EXTEND.md, do NOT ask any questions. Proceed directly to Step 1.
**CRITICAL** (interactive mode only): If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about style, do NOT ask about layout — ONLY complete the preferences setup first.
Check EXTEND.md existence (priority order):
@@ -340,7 +349,11 @@ Read source content, save it if needed, and perform deep analysis.
### Step 2: Smart Confirm ⚠️
**Purpose**: Present auto-recommended plan, let user confirm or adjust. **Do NOT skip.**
**Purpose**: Present auto-recommended plan, let user confirm or adjust.
**`--yes` mode**: Skip this entire step. Use auto-recommended strategy + style + layout from Step 1 analysis (or `--style`/`--layout`/`--preset` if provided). Generate outline directly using Path A logic → save to `outline.md` → proceed to Step 3. No AskUserQuestion calls.
**Interactive mode**: Do NOT skip.
**Auto-Recommendation Logic**:
1. Use Auto Selection table to match content signals → best strategy + style + layout
@@ -491,7 +504,7 @@ Reference: `references/config/watermark-guide.md`
**Image Generation Skill Selection**:
- Check available image generation skills
- If multiple skills available, ask user preference
- If multiple skills available: ask user preference (interactive) or use first available skill (`--yes` mode)
**Session Management**:
If image generation skill supports `--sessionId`:
@@ -2,7 +2,7 @@ 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";
import { buildTranscriptListFromYtDlp, fetchTranscriptWithFallback, resolveVideoSource, selectYtDlpTrack } from "./youtube.ts";
test("selectYtDlpTrack prefers json3 over xml and vtt", () => {
const track = selectYtDlpTrack([
@@ -123,3 +123,61 @@ test("resolveVideoSource falls back to yt-dlp only after fallback-eligible error
assert.equal(fallbackCalled, true);
assert.equal(source.transcripts[0].languageCode, "en");
});
test("fetchTranscriptWithFallback retries with yt-dlp when InnerTube transcript payload is empty", async () => {
const warnings: string[] = [];
let fallbackCalled = false;
const result = await fetchTranscriptWithFallback(
"video12345ab",
{
kind: "innertube",
data: { videoDetails: { title: "Primary" } },
transcripts: [{
language: "English",
languageCode: "en",
isGenerated: false,
isTranslatable: false,
baseUrl: "https://www.youtube.com/api/timedtext?v=video12345ab&lang=en&fmt=json3",
translationLanguages: [],
}],
},
{
languages: ["en"],
translate: "",
excludeGenerated: false,
excludeManual: false,
},
async (info) => {
if (info.baseUrl.includes("youtube.com/api/timedtext")) {
return { snippets: [], language: info.language, languageCode: info.languageCode };
}
return {
snippets: [{ text: "Recovered subtitle", start: 0, duration: 2 }],
language: info.language,
languageCode: info.languageCode,
};
},
async () => {
fallbackCalled = true;
return {
kind: "yt-dlp",
info: { title: "Fallback" },
transcripts: [{
language: "English",
languageCode: "en",
isGenerated: false,
isTranslatable: false,
baseUrl: "https://example.com/subtitles.en.json3",
translationLanguages: [],
}],
};
},
(message) => warnings.push(message)
);
assert.equal(fallbackCalled, true);
assert.equal(result.source.kind, "yt-dlp");
assert.equal(result.snippets.length, 1);
assert.equal(result.snippets[0].text, "Recovered subtitle");
assert.match(warnings[0] || "", /Retrying with yt-dlp fallback/);
});
+16 -14
View File
@@ -13,13 +13,13 @@ import {
registerVideoDir,
resolveBaseDir,
} from "./storage.ts";
import { findTranscript, formatListOutput, formatMarkdown, formatSrt, segmentIntoSentences } from "./transcript.ts";
import { formatListOutput, formatMarkdown, formatSrt, segmentIntoSentences } from "./transcript.ts";
import type { Options, Sentence, Snippet, VideoMeta, VideoResult } from "./types.ts";
import {
buildVideoMeta,
buildVideoMetaFromYtDlp,
downloadCoverImage,
fetchTranscriptSnippets,
fetchTranscriptWithFallback,
fetchVideoSource,
getThumbnailUrls,
getYtDlpThumbnailUrls,
@@ -31,10 +31,12 @@ async function fetchAndCache(
baseDir: string,
opts: Options
): Promise<{ meta: VideoMeta; snippets: Snippet[]; sentences: Sentence[]; videoDir: string }> {
const source = await fetchVideoSource(videoId);
const requestedLanguages = source.kind === "yt-dlp" && opts.translate ? [opts.translate] : opts.languages;
const transcript = findTranscript(source.transcripts, requestedLanguages, opts.excludeGenerated, opts.excludeManual);
const result = await fetchTranscriptSnippets(transcript, source.kind === "yt-dlp" ? undefined : opts.translate || undefined);
const initialSource = await fetchVideoSource(videoId);
const { source, transcript, snippets, language, languageCode } = await fetchTranscriptWithFallback(
videoId,
initialSource,
opts
);
const description = source.kind === "yt-dlp"
? source.info.description || ""
: source.data?.videoDetails?.shortDescription || "";
@@ -42,21 +44,21 @@ async function fetchAndCache(
? Number(source.info.duration || 0)
: parseInt(source.data?.videoDetails?.lengthSeconds || "0");
const chapters = parseChapters(description, duration);
const language = {
code: result.languageCode,
name: result.language,
const languageMeta = {
code: languageCode,
name: language,
isGenerated: transcript.isGenerated,
};
const meta = source.kind === "yt-dlp"
? buildVideoMetaFromYtDlp(source.info, videoId, language, chapters)
: buildVideoMeta(source.data, videoId, language, chapters);
? buildVideoMetaFromYtDlp(source.info, videoId, languageMeta, chapters)
: buildVideoMeta(source.data, videoId, languageMeta, chapters);
const videoDir = registerVideoDir(videoId, slugify(meta.channel), slugify(meta.title), baseDir);
ensureDir(join(videoDir, "meta.json"));
writeFileSync(join(videoDir, "transcript-raw.json"), JSON.stringify(result.snippets, null, 2));
writeFileSync(join(videoDir, "transcript-raw.json"), JSON.stringify(snippets, null, 2));
const sentences = segmentIntoSentences(result.snippets);
const sentences = segmentIntoSentences(snippets);
writeFileSync(join(videoDir, "transcript-sentences.json"), JSON.stringify(sentences, null, 2));
const imagePath = join(videoDir, "imgs", "cover.jpg");
@@ -69,7 +71,7 @@ async function fetchAndCache(
writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2));
return { meta, snippets: result.snippets, sentences, videoDir };
return { meta, snippets, sentences, videoDir };
}
async function processVideo(videoId: string, opts: Options): Promise<VideoResult> {
@@ -2,12 +2,13 @@ import { spawnSync } from "child_process";
import { writeFileSync } from "fs";
import { makeError, normalizeError, normalizePublishDate, shouldTryAlternateClient, shouldTryYtDlpFallback } from "./shared.ts";
import { parseTranscriptPayload } from "./transcript.ts";
import { findTranscript, parseTranscriptPayload } from "./transcript.ts";
import type {
Chapter,
InnerTubeClient,
InnerTubeSession,
LanguageMeta,
Options,
Snippet,
TranscriptInfo,
VideoMeta,
@@ -219,6 +220,68 @@ export async function fetchTranscriptSnippets(
};
}
function buildYtDlpVideoSource(videoId: string, info: YtDlpInfo): VideoSource {
const transcripts = buildTranscriptListFromYtDlp(info);
if (!transcripts.length) throw makeError(`Transcripts disabled for ${videoId}`, "TRANSCRIPTS_DISABLED");
return { kind: "yt-dlp", info, transcripts };
}
function getRequestedLanguages(
source: VideoSource,
opts: Pick<Options, "languages" | "translate">
): string[] {
return source.kind === "yt-dlp" && opts.translate ? [opts.translate] : opts.languages;
}
export async function fetchTranscriptWithFallback(
videoId: string,
source: VideoSource,
opts: Pick<Options, "languages" | "translate" | "excludeGenerated" | "excludeManual">,
fetchSnippets: (
info: TranscriptInfo,
translateTo?: string
) => Promise<{ snippets: Snippet[]; language: string; languageCode: string }> = fetchTranscriptSnippets,
fetchFallbackSource: (videoId: string) => Promise<VideoSource> | VideoSource = (requestedVideoId) =>
buildYtDlpVideoSource(requestedVideoId, fetchYtDlpInfo(requestedVideoId)),
logWarning: (message: string) => void = (message) => console.error(message)
): Promise<{
source: VideoSource;
transcript: TranscriptInfo;
snippets: Snippet[];
language: string;
languageCode: string;
}> {
const transcript = findTranscript(
source.transcripts,
getRequestedLanguages(source, opts),
opts.excludeGenerated,
opts.excludeManual
);
const result = await fetchSnippets(transcript, source.kind === "yt-dlp" ? undefined : opts.translate || undefined);
if (result.snippets.length > 0) return { source, transcript, ...result };
if (source.kind === "yt-dlp") {
throw makeError(`Transcript fetch returned empty snippets for ${videoId}`, "EMPTY_TRANSCRIPT");
}
logWarning(`Warning (${videoId}): Transcript fetch returned empty snippets. Retrying with yt-dlp fallback.`);
const fallbackSource = await fetchFallbackSource(videoId);
const fallbackTranscript = findTranscript(
fallbackSource.transcripts,
getRequestedLanguages(fallbackSource, opts),
opts.excludeGenerated,
opts.excludeManual
);
const fallbackResult = await fetchSnippets(
fallbackTranscript,
fallbackSource.kind === "yt-dlp" ? undefined : opts.translate || undefined
);
if (!fallbackResult.snippets.length) {
throw makeError(`Transcript fetch returned empty snippets for ${videoId} after yt-dlp fallback`, "EMPTY_TRANSCRIPT");
}
return { source: fallbackSource, transcript: fallbackTranscript, ...fallbackResult };
}
export function detectYtDlpCommand(): { command: string; args: string[]; label: string } | null {
if (cachedYtDlpCommand !== undefined) return cachedYtDlpCommand;
const candidates = [
@@ -366,10 +429,7 @@ export async function resolveVideoSource(
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 };
return buildYtDlpVideoSource(videoId, fetchFallback(videoId));
}
}