mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c44a524fa6 | |||
| 826535abe4 | |||
| fc50f31694 | |||
| 204765a137 | |||
| 4874cd2dae | |||
| b791ee5dc7 | |||
| 450c76d955 | |||
| db33da26e7 | |||
| c7c98ba034 | |||
| 60ab574559 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.89.0"
|
||||
"version": "1.90.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 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,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.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
-1
@@ -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;
|
||||
|
||||
+32
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user