@tacone/prosey 0.2.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -39
- package/bin/prosey +17196 -15426
- package/package.json +2 -1
- package/src/config-resolve.test.ts +104 -0
- package/src/config-resolve.ts +17 -0
- package/src/config.test.ts +3 -1
- package/src/config.ts +26 -17
- package/src/default-config.toml +39 -11
- package/src/extract-chapters.test.ts +229 -0
- package/src/extract-chapters.ts +66 -0
- package/src/html.test.ts +25 -0
- package/src/html.ts +141 -0
- package/src/index.test.ts +12 -0
- package/src/index.ts +277 -11
- package/src/summarize.test.ts +74 -3
- package/src/summarize.ts +18 -4
package/src/html.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { ChildProcess } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const PICO_CSS_URL = "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css";
|
|
6
|
+
|
|
7
|
+
let cachedCss: string | null = null;
|
|
8
|
+
|
|
9
|
+
async function getPicoCss(): Promise<string> {
|
|
10
|
+
if (cachedCss) return cachedCss;
|
|
11
|
+
const res = await fetch(PICO_CSS_URL);
|
|
12
|
+
cachedCss = await res.text();
|
|
13
|
+
return cachedCss;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LOGO_SVG = `<svg width="100" viewBox="92 120 264 68" role="img" title="Prosey" xmlns="http://www.w3.org/2000/svg">
|
|
17
|
+
<title>Prosey</title>
|
|
18
|
+
<path d="M100,545 a4,4 0 0 0-4,4 v50 a4,4 0 0 0 6,3.46 l43,-25 a4,4 0 0 0 0,-6.93 l-43,-25 a4,4 0 0 0-2,-0.53 Z" transform="translate(0,-405)" fill="#9B8BF4" opacity="0.25"/>
|
|
19
|
+
<path d="M96,130 C96,126 99,124 102,126 L145,149 C148,151 148,155 145,157 L102,180 C99,182 96,180 96,176 Z" fill="none" stroke="#9B8BF4" stroke-width="2.5" stroke-linejoin="round"/>
|
|
20
|
+
<path d="M96,130 C96,126 99,124 102,126 L145,149 C148,151 148,155 145,157 L102,180 C99,182 96,180 96,176 Z" fill="#9B8BF4" opacity="0.18"/>
|
|
21
|
+
<path d="M162,142 L178,154 L162,166" fill="none" stroke="#9B8BF4" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
22
|
+
<path d="M176,142 L192,154 L176,166" fill="none" stroke="#9B8BF4" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
23
|
+
<rect x="212" y="138" width="140" height="12" rx="6" fill="#9B8BF4" opacity="0.85"/>
|
|
24
|
+
<rect x="212" y="155" width="116" height="12" rx="6" fill="#9B8BF4" opacity="0.55"/>
|
|
25
|
+
<rect x="212" y="172" width="130" height="12" rx="6" fill="#9B8BF4" opacity="0.3"/>
|
|
26
|
+
</svg>`;
|
|
27
|
+
const LOGO_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_SVG)}`;
|
|
28
|
+
|
|
29
|
+
function escapeHtml(text: string): string {
|
|
30
|
+
return text
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, """);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function generateHtml(markdown: string, title?: string): Promise<string> {
|
|
38
|
+
const [css, body] = await Promise.all([getPicoCss(), marked.parse(markdown)]);
|
|
39
|
+
|
|
40
|
+
return `<!DOCTYPE html>
|
|
41
|
+
<html lang="en">
|
|
42
|
+
<head>
|
|
43
|
+
<meta charset="UTF-8">
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
45
|
+
<title>${escapeHtml(title ?? "Prosey")}</title>
|
|
46
|
+
<script>(function(){var m=localStorage.getItem('prosey-theme'),t=m||'auto';if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);if(!m)localStorage.setItem('prosey-theme','auto')})();</script>
|
|
47
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
48
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
49
|
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet">
|
|
50
|
+
<style>
|
|
51
|
+
${css}
|
|
52
|
+
:root {
|
|
53
|
+
--pico-font-family: 'Plus Jakarta Sans', sans-serif;
|
|
54
|
+
--pico-font-size: 120%;
|
|
55
|
+
--pico-line-height: 1.78;
|
|
56
|
+
--pico-font-weight: 300;
|
|
57
|
+
--pico-blockquote-border-color: #9B8BF4;
|
|
58
|
+
}
|
|
59
|
+
@media (max-width: 767px) {
|
|
60
|
+
:root {
|
|
61
|
+
--pico-font-size: 110%;
|
|
62
|
+
--pico-line-height: 1.50;
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
h1 { --pico-font-size: 1.9rem; }
|
|
67
|
+
h2 { --pico-font-size: 1.5rem; }
|
|
68
|
+
h3 { --pico-font-size: 1.25rem; }
|
|
69
|
+
h4 { --pico-font-size: 1.1rem; }
|
|
70
|
+
h5 { --pico-font-size: 1rem; }
|
|
71
|
+
h6 { --pico-font-size: 0.85rem; }
|
|
72
|
+
h1, h2, h3, h4, h5, h6 {
|
|
73
|
+
--pico-font-weight: 500;
|
|
74
|
+
--pico-line-height: 1.25;
|
|
75
|
+
margin-top: 0;
|
|
76
|
+
}
|
|
77
|
+
h1 {
|
|
78
|
+
letter-spacing: -0.03em;
|
|
79
|
+
margin-bottom: calc(var(--pico-typography-spacing-vertical) * 2);
|
|
80
|
+
}
|
|
81
|
+
li:last-child {
|
|
82
|
+
margin-bottom: 0px;
|
|
83
|
+
}
|
|
84
|
+
blockquote {
|
|
85
|
+
font-style: italic;
|
|
86
|
+
font-size: 1.05rem;
|
|
87
|
+
color: var(--pico-muted-color);
|
|
88
|
+
padding-top: 0;
|
|
89
|
+
padding-bottom: 0;
|
|
90
|
+
}
|
|
91
|
+
blockquote:first-child { margin-top: 0; }
|
|
92
|
+
blockquote:last-child { margin-bottom: 0; }
|
|
93
|
+
* { transition: all 0.3s; }
|
|
94
|
+
#theme-btn:focus, #theme-btn:active { outline: none !important; }
|
|
95
|
+
#theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
|
|
96
|
+
img[alt="Prosey"] { filter: grayscale(100%); }
|
|
97
|
+
img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;padding:1rem 1rem 0">
|
|
102
|
+
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
|
|
103
|
+
<button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s">💡</button>
|
|
104
|
+
</div>
|
|
105
|
+
<main style="max-width:720px;margin:0 auto;padding:1rem">
|
|
106
|
+
${body}
|
|
107
|
+
</main>
|
|
108
|
+
<script>
|
|
109
|
+
(function(){var b=document.getElementById('theme-btn'),modes=['auto','light','dark'],icons={auto:'\u{1F4A1}',light:'\u2600\uFE0F',dark:'\u{1F319}'};function apply(m){var t=m;if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);localStorage.setItem('prosey-theme',m);b.textContent=icons[m]}b.addEventListener('click',function(){var m=localStorage.getItem('prosey-theme')||'auto',i=modes.indexOf(m);apply(modes[(i+1)%modes.length])})})();
|
|
110
|
+
</script>
|
|
111
|
+
</body>
|
|
112
|
+
</html>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function openInBrowser(htmlPath: string): Promise<void> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let proc: ChildProcess;
|
|
118
|
+
const { platform } = process;
|
|
119
|
+
|
|
120
|
+
if (platform === "darwin") {
|
|
121
|
+
proc = spawn("open", [htmlPath], { stdio: "ignore" });
|
|
122
|
+
} else if (platform === "win32") {
|
|
123
|
+
proc = spawn("cmd", ["/c", "start", "", htmlPath], {
|
|
124
|
+
stdio: "ignore",
|
|
125
|
+
shell: true,
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
proc = spawn("xdg-open", [htmlPath], { stdio: "ignore" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
proc.on("error", () => {
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
proc.on("exit", () => {
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
setTimeout(() => resolve(), 5000);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const BIN = join(import.meta.dir, "..", "bin", "prosey");
|
|
6
|
+
|
|
7
|
+
describe("dry-run", () => {
|
|
8
|
+
test("is listed in help text", async () => {
|
|
9
|
+
const { stdout } = await $`${BIN} --help`.quiet();
|
|
10
|
+
expect(stdout.toString()).toContain("--dry-run");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { setLevel, info, debug, startTimer, resetTimer, hint } from "./debug";
|
|
|
4
4
|
import type { LogLevel } from "./debug";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
7
8
|
import { detectPager } from "./pager";
|
|
8
9
|
import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
|
|
9
10
|
import type { CaptionTrackInfo, VideoDetails, TranscriptSegment } from "youtube-transcript-plus";
|
|
@@ -11,11 +12,23 @@ import { formatWithTimestamps, toText, toJSON, formatDuration, decodeEntities }
|
|
|
11
12
|
import { loadConfig, resetConfig, configPath } from "./config";
|
|
12
13
|
import type { ProseyConfig } from "./config";
|
|
13
14
|
import { summarize } from "./summarize";
|
|
15
|
+
import {
|
|
16
|
+
resolveSummarizeCmd,
|
|
17
|
+
resolveSummarizePrompt,
|
|
18
|
+
resolveTranscribeCmd,
|
|
19
|
+
resolveTranscribePrompt,
|
|
20
|
+
} from "./config-resolve";
|
|
14
21
|
import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
22
|
+
import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
|
|
23
|
+
import { generateHtml, openInBrowser } from "./html";
|
|
15
24
|
import { checkVersion } from "./version-check";
|
|
16
25
|
import pkg from "../package.json";
|
|
17
26
|
import prettier from "prettier";
|
|
18
27
|
|
|
28
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
29
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
19
32
|
const NAME = "prosey";
|
|
20
33
|
const VERSION = pkg.version;
|
|
21
34
|
|
|
@@ -37,16 +50,20 @@ function help(): string {
|
|
|
37
50
|
return `${NAME} v${VERSION}
|
|
38
51
|
|
|
39
52
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
53
|
+
${NAME} read [options] <video-url-or-id>
|
|
40
54
|
${NAME} info [options] <video-url-or-id>
|
|
41
55
|
${NAME} summarize [options] <video-url-or-id>
|
|
42
56
|
${NAME} config
|
|
57
|
+
${NAME} help
|
|
43
58
|
|
|
44
59
|
Download a YouTube video transcript or show video details.
|
|
45
60
|
|
|
46
61
|
Commands:
|
|
62
|
+
summarize Pipe transcript to the AI command (default command)
|
|
63
|
+
read Download and print a richly formatted transcript
|
|
47
64
|
info Show video metadata (title, channel, duration, etc.)
|
|
48
|
-
summarize Pipe transcript to the command configured in [summarize]
|
|
49
65
|
config Open config file in \$EDITOR
|
|
66
|
+
help Show this help message
|
|
50
67
|
|
|
51
68
|
Arguments:
|
|
52
69
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -56,14 +73,19 @@ Options:
|
|
|
56
73
|
-t, --timestamps Include timestamps [MM:SS] in output.
|
|
57
74
|
--list List available transcript languages and exit.
|
|
58
75
|
-o, --output <path> Write output to file instead of stdout.
|
|
59
|
-
--
|
|
60
|
-
--
|
|
76
|
+
--format <type> Output format: markdown (default), text, json, or html.
|
|
77
|
+
--json Shortcut for --format json.
|
|
78
|
+
--text Shortcut for --format text.
|
|
79
|
+
--markdown Shortcut for --format markdown.
|
|
80
|
+
--html Shortcut for --format html (opens in browser).
|
|
61
81
|
--details Prepend video details to transcript (default, text only).
|
|
62
82
|
--no-details Suppress video details, transcript only.
|
|
63
83
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
64
84
|
--reset-config Reset config file to defaults and exit.
|
|
65
85
|
--no-cache Skip cache and overwrite cache files.
|
|
66
86
|
--no-format Skip prettier formatting.
|
|
87
|
+
--dry-run Print what would be sent to the AI command and exit.
|
|
88
|
+
--extract-timestamps Extract chapter timestamps from video description.
|
|
67
89
|
--no-pager Disable pager for stdout output.
|
|
68
90
|
--pager Use pager for stdout output (default).
|
|
69
91
|
--no-hints Disable hints.
|
|
@@ -193,7 +215,7 @@ let pagerCmd: string | null = null;
|
|
|
193
215
|
|
|
194
216
|
const args = process.argv.slice(2);
|
|
195
217
|
|
|
196
|
-
if (args.length === 0 || args.includes("--help")) {
|
|
218
|
+
if (args.length === 0 || args.includes("--help") || args.includes("help")) {
|
|
197
219
|
console.log(help());
|
|
198
220
|
exitProcess(0);
|
|
199
221
|
}
|
|
@@ -211,8 +233,10 @@ if (args.includes("--reset-config")) {
|
|
|
211
233
|
|
|
212
234
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
213
235
|
|
|
214
|
-
let mode = "
|
|
215
|
-
const subcmdIndex = args.findIndex(
|
|
236
|
+
let mode = "summarize";
|
|
237
|
+
const subcmdIndex = args.findIndex(
|
|
238
|
+
(a) => a === "info" || a === "summarize" || a === "config" || a === "read",
|
|
239
|
+
);
|
|
216
240
|
if (subcmdIndex !== -1) {
|
|
217
241
|
mode = args[subcmdIndex]!;
|
|
218
242
|
args.splice(subcmdIndex, 1);
|
|
@@ -224,12 +248,15 @@ let timestamps = false;
|
|
|
224
248
|
let listOnly = false;
|
|
225
249
|
let outputPath: string | undefined;
|
|
226
250
|
let outputJson = false;
|
|
251
|
+
let format: "text" | "json" | "markdown" | "html" = "markdown";
|
|
227
252
|
let noDecode = false;
|
|
228
253
|
let showDetails = true;
|
|
229
254
|
let noCache = false;
|
|
230
255
|
let noFormat = false;
|
|
231
256
|
let usePager = true;
|
|
232
257
|
let useHints = true;
|
|
258
|
+
let dryRun = false;
|
|
259
|
+
let extractTimestamps = false;
|
|
233
260
|
let logLevel: LogLevel = "normal";
|
|
234
261
|
|
|
235
262
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -253,8 +280,32 @@ for (let i = 0; i < args.length; i++) {
|
|
|
253
280
|
}
|
|
254
281
|
} else if (arg === "--json") {
|
|
255
282
|
outputJson = true;
|
|
283
|
+
format = "json";
|
|
256
284
|
} else if (arg === "--text") {
|
|
257
285
|
outputJson = false;
|
|
286
|
+
format = "text";
|
|
287
|
+
} else if (arg === "--markdown") {
|
|
288
|
+
format = "markdown";
|
|
289
|
+
} else if (arg === "--html") {
|
|
290
|
+
format = "html";
|
|
291
|
+
} else if (arg === "--format") {
|
|
292
|
+
const val = args[++i];
|
|
293
|
+
if (val === "json") {
|
|
294
|
+
format = "json";
|
|
295
|
+
outputJson = true;
|
|
296
|
+
} else if (val === "text") {
|
|
297
|
+
format = "text";
|
|
298
|
+
outputJson = false;
|
|
299
|
+
} else if (val === "markdown") {
|
|
300
|
+
format = "markdown";
|
|
301
|
+
outputJson = false;
|
|
302
|
+
} else if (val === "html") {
|
|
303
|
+
format = "html";
|
|
304
|
+
outputJson = false;
|
|
305
|
+
} else {
|
|
306
|
+
console.error("Error: --format must be text, json, markdown, or html");
|
|
307
|
+
exitProcess(1);
|
|
308
|
+
}
|
|
258
309
|
} else if (arg === "--details") {
|
|
259
310
|
showDetails = true;
|
|
260
311
|
} else if (arg === "--no-details") {
|
|
@@ -277,6 +328,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
277
328
|
logLevel = "verbose";
|
|
278
329
|
} else if (arg === "--no-decode-entities") {
|
|
279
330
|
noDecode = true;
|
|
331
|
+
} else if (arg === "--dry-run") {
|
|
332
|
+
dryRun = true;
|
|
333
|
+
} else if (arg === "--extract-timestamps") {
|
|
334
|
+
extractTimestamps = true;
|
|
280
335
|
} else if (arg.startsWith("-")) {
|
|
281
336
|
console.error(`Unknown option: ${arg}`);
|
|
282
337
|
exitProcess(1);
|
|
@@ -345,6 +400,25 @@ if (lang) debug("Language:", lang);
|
|
|
345
400
|
// Give the version check a moment to complete
|
|
346
401
|
await Promise.race([versionCheck, new Promise((r) => setTimeout(r, 1000))]);
|
|
347
402
|
|
|
403
|
+
if (extractTimestamps) {
|
|
404
|
+
startTimer();
|
|
405
|
+
info("Fetching transcript...");
|
|
406
|
+
const result = (await fetchTranscript(videoId, {
|
|
407
|
+
videoDetails: true,
|
|
408
|
+
lang,
|
|
409
|
+
} as any)) as {
|
|
410
|
+
videoDetails: VideoDetails;
|
|
411
|
+
segments: TranscriptSegment[];
|
|
412
|
+
};
|
|
413
|
+
info("Transcript fetched");
|
|
414
|
+
const chapters = extractChapters(result.videoDetails.description);
|
|
415
|
+
const output = outputJson
|
|
416
|
+
? JSON.stringify(chapters, null, 2) + "\n"
|
|
417
|
+
: formatChaptersAsText(chapters) + "\n";
|
|
418
|
+
await outputText(output);
|
|
419
|
+
exitProcess(0);
|
|
420
|
+
}
|
|
421
|
+
|
|
348
422
|
try {
|
|
349
423
|
if (mode === "info") {
|
|
350
424
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
|
|
@@ -357,8 +431,11 @@ try {
|
|
|
357
431
|
}
|
|
358
432
|
|
|
359
433
|
if (mode === "summarize") {
|
|
360
|
-
|
|
361
|
-
|
|
434
|
+
const sumCmd = resolveSummarizeCmd(config);
|
|
435
|
+
if (!sumCmd) {
|
|
436
|
+
console.error(
|
|
437
|
+
"Error: no command configured for summarize. Set [ai].command or [summarize].command in config.",
|
|
438
|
+
);
|
|
362
439
|
exitProcess(1);
|
|
363
440
|
}
|
|
364
441
|
|
|
@@ -392,14 +469,25 @@ try {
|
|
|
392
469
|
debug("Cache written: transcript.json");
|
|
393
470
|
}
|
|
394
471
|
|
|
395
|
-
const prompt = config
|
|
472
|
+
const prompt = resolveSummarizePrompt(config) ?? "";
|
|
473
|
+
if (!prompt) {
|
|
474
|
+
console.error(
|
|
475
|
+
"Error: no prompt configured. Set a prompt in the [summarize] section of your config.",
|
|
476
|
+
);
|
|
477
|
+
exitProcess(1);
|
|
478
|
+
}
|
|
396
479
|
const transcriptText = toText(segments, !noDecode);
|
|
397
480
|
|
|
481
|
+
if (dryRun) {
|
|
482
|
+
await outputText(`${prompt}\n\n${transcriptText}\n`);
|
|
483
|
+
exitProcess(0);
|
|
484
|
+
}
|
|
485
|
+
|
|
398
486
|
if (!summary) {
|
|
399
487
|
info(`Summarizing...`);
|
|
400
488
|
summary = await summarize({
|
|
401
489
|
prompt,
|
|
402
|
-
command:
|
|
490
|
+
command: sumCmd,
|
|
403
491
|
transcript: transcriptText,
|
|
404
492
|
cwd: dir,
|
|
405
493
|
});
|
|
@@ -409,7 +497,21 @@ try {
|
|
|
409
497
|
}
|
|
410
498
|
|
|
411
499
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
412
|
-
|
|
500
|
+
if (format === "html") {
|
|
501
|
+
const htmlContent = await generateHtml(formatted);
|
|
502
|
+
const htmlPath = join(dir, "summary.html");
|
|
503
|
+
await writeFile(htmlPath, htmlContent, "utf8");
|
|
504
|
+
debug("HTML written:", htmlPath);
|
|
505
|
+
if (outputPath) {
|
|
506
|
+
await writeFile(outputPath, htmlContent, "utf8");
|
|
507
|
+
} else if (!process.stdout.isTTY) {
|
|
508
|
+
process.stdout.write(htmlContent);
|
|
509
|
+
} else {
|
|
510
|
+
await openInBrowser(htmlPath);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
await outputText(formatted + "\n");
|
|
514
|
+
}
|
|
413
515
|
exitProcess(0);
|
|
414
516
|
} else if (listOnly) {
|
|
415
517
|
const languages = await listLanguages(videoId);
|
|
@@ -417,6 +519,162 @@ try {
|
|
|
417
519
|
exitProcess(0);
|
|
418
520
|
}
|
|
419
521
|
|
|
522
|
+
if (format === "markdown" || format === "html") {
|
|
523
|
+
const transcribeCmd = resolveTranscribeCmd(config);
|
|
524
|
+
if (!transcribeCmd) {
|
|
525
|
+
console.error(
|
|
526
|
+
"Error: no command configured for transcribe. Set [transcribe].command, [ai].command, or [summarize].command in config.",
|
|
527
|
+
);
|
|
528
|
+
exitProcess(1);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const cacheOpts = { lang, mode: "transcribe", noDecode };
|
|
532
|
+
const dir = cacheDir(videoId, cacheOpts);
|
|
533
|
+
let segments: TranscriptSegment[] | null = null;
|
|
534
|
+
let md: string | null = null;
|
|
535
|
+
|
|
536
|
+
startTimer();
|
|
537
|
+
|
|
538
|
+
let cachedInfo: string | null = null;
|
|
539
|
+
|
|
540
|
+
if (!noCache) {
|
|
541
|
+
const cachedSegments = await readCache(dir, "transcript.json");
|
|
542
|
+
const cachedMd = await readCache(dir, "transcript.md");
|
|
543
|
+
cachedInfo = await readCache(dir, "info.json");
|
|
544
|
+
if (cachedSegments && cachedMd) {
|
|
545
|
+
info("Transcript cached");
|
|
546
|
+
debug("Cache hit:", dir);
|
|
547
|
+
segments = JSON.parse(cachedSegments);
|
|
548
|
+
md = cachedMd;
|
|
549
|
+
} else {
|
|
550
|
+
debug("Cache miss:", dir);
|
|
551
|
+
}
|
|
552
|
+
} else {
|
|
553
|
+
debug("Cache skipped (--no-cache)");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const prompt = resolveTranscribePrompt(config) ?? "";
|
|
557
|
+
if (!prompt) {
|
|
558
|
+
console.error(
|
|
559
|
+
"Error: no prompt configured. Set a prompt in the [transcribe] or [summarize] section of your config.",
|
|
560
|
+
);
|
|
561
|
+
exitProcess(1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!segments) {
|
|
565
|
+
info("Fetching transcript...");
|
|
566
|
+
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
567
|
+
const result = (await fetchTranscript(videoId, opts)) as {
|
|
568
|
+
videoDetails: VideoDetails;
|
|
569
|
+
segments: TranscriptSegment[];
|
|
570
|
+
};
|
|
571
|
+
segments = result.segments;
|
|
572
|
+
const infoJson = JSON.stringify({
|
|
573
|
+
title: result.videoDetails.title,
|
|
574
|
+
channel: result.videoDetails.author,
|
|
575
|
+
description: result.videoDetails.description,
|
|
576
|
+
});
|
|
577
|
+
cachedInfo = infoJson;
|
|
578
|
+
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
579
|
+
const truncatedInfo = JSON.stringify({
|
|
580
|
+
title: result.videoDetails.title,
|
|
581
|
+
channel: result.videoDetails.author,
|
|
582
|
+
description: result.videoDetails.description.slice(0, 1000),
|
|
583
|
+
});
|
|
584
|
+
const transcriptText = toText(segments, !noDecode);
|
|
585
|
+
const structuredContent = `INFO:\n${truncatedInfo}\n\nTIMESTAMPS:\n${chapterValue}\n\nTEXT:\n${transcriptText}`;
|
|
586
|
+
|
|
587
|
+
if (dryRun) {
|
|
588
|
+
await outputText(`${prompt}\n\n${structuredContent}\n`);
|
|
589
|
+
exitProcess(0);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
info(`Transcript: ${segments.length} segments`);
|
|
593
|
+
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
594
|
+
await writeCache(dir, "info.json", infoJson);
|
|
595
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
596
|
+
debug("Cache written: transcript.json, info.json, chapters.json");
|
|
597
|
+
|
|
598
|
+
if (!md) {
|
|
599
|
+
info(`Transcribing...`);
|
|
600
|
+
md = await summarize({
|
|
601
|
+
prompt,
|
|
602
|
+
command: transcribeCmd,
|
|
603
|
+
transcript: structuredContent,
|
|
604
|
+
cwd: dir,
|
|
605
|
+
});
|
|
606
|
+
info("Transcription ready");
|
|
607
|
+
await writeCache(dir, "transcript.md", md);
|
|
608
|
+
debug("Cache written: transcript.md");
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
let chapterValue: string;
|
|
612
|
+
if (!cachedInfo) {
|
|
613
|
+
debug("Cache missing info.json, re-fetching video details");
|
|
614
|
+
const fallbackOpts = lang
|
|
615
|
+
? { lang, videoDetails: true as const }
|
|
616
|
+
: { videoDetails: true as const };
|
|
617
|
+
const fallbackResult = (await fetchTranscript(videoId, fallbackOpts)) as {
|
|
618
|
+
videoDetails: VideoDetails;
|
|
619
|
+
segments: TranscriptSegment[];
|
|
620
|
+
};
|
|
621
|
+
cachedInfo = JSON.stringify({
|
|
622
|
+
title: fallbackResult.videoDetails.title,
|
|
623
|
+
channel: fallbackResult.videoDetails.author,
|
|
624
|
+
description: fallbackResult.videoDetails.description,
|
|
625
|
+
});
|
|
626
|
+
await writeCache(dir, "info.json", cachedInfo);
|
|
627
|
+
chapterValue = formatChaptersAsJson(
|
|
628
|
+
extractChapters(fallbackResult.videoDetails.description),
|
|
629
|
+
);
|
|
630
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
631
|
+
debug("Cache written: info.json, chapters.json");
|
|
632
|
+
} else {
|
|
633
|
+
const cachedChapters = await readCache(dir, "chapters.json");
|
|
634
|
+
chapterValue = cachedChapters ?? "not available";
|
|
635
|
+
}
|
|
636
|
+
const transcriptText = toText(segments, !noDecode);
|
|
637
|
+
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
638
|
+
const truncatedInfo = JSON.stringify({
|
|
639
|
+
title: cachedInfoObj.title,
|
|
640
|
+
channel: cachedInfoObj.channel,
|
|
641
|
+
description: cachedInfoObj.description.slice(0, 1000),
|
|
642
|
+
});
|
|
643
|
+
const structuredContent = `INFO:\n${truncatedInfo}\n\nTIMESTAMPS:\n${chapterValue}\n\nTEXT:\n${transcriptText}`;
|
|
644
|
+
|
|
645
|
+
if (!md) {
|
|
646
|
+
info(`Transcribing...`);
|
|
647
|
+
md = await summarize({
|
|
648
|
+
prompt,
|
|
649
|
+
command: transcribeCmd,
|
|
650
|
+
transcript: structuredContent,
|
|
651
|
+
cwd: dir,
|
|
652
|
+
});
|
|
653
|
+
info("Transcription ready");
|
|
654
|
+
await writeCache(dir, "transcript.md", md);
|
|
655
|
+
debug("Cache written: transcript.md");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const formatted = noFormat ? md : await formatMd(md);
|
|
660
|
+
if (format === "html") {
|
|
661
|
+
const htmlContent = await generateHtml(formatted);
|
|
662
|
+
const htmlPath = join(dir, "transcript.html");
|
|
663
|
+
await writeFile(htmlPath, htmlContent, "utf8");
|
|
664
|
+
debug("HTML written:", htmlPath);
|
|
665
|
+
if (outputPath) {
|
|
666
|
+
await writeFile(outputPath, htmlContent, "utf8");
|
|
667
|
+
} else if (!process.stdout.isTTY) {
|
|
668
|
+
process.stdout.write(htmlContent);
|
|
669
|
+
} else {
|
|
670
|
+
await openInBrowser(htmlPath);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
await outputText(formatted + "\n");
|
|
674
|
+
}
|
|
675
|
+
exitProcess(0);
|
|
676
|
+
}
|
|
677
|
+
|
|
420
678
|
const decode = !noDecode;
|
|
421
679
|
const cacheOpts = { lang, timestamps, json: outputJson, noDecode };
|
|
422
680
|
const dir = cacheDir(videoId, cacheOpts);
|
|
@@ -438,6 +696,14 @@ try {
|
|
|
438
696
|
debug("Cache skipped (--no-cache)");
|
|
439
697
|
}
|
|
440
698
|
|
|
699
|
+
const prompt = resolveTranscribePrompt(config) ?? "";
|
|
700
|
+
if (!prompt) {
|
|
701
|
+
console.error(
|
|
702
|
+
"Error: no prompt configured. Set a prompt in the [transcribe] or [summarize] section of your config.",
|
|
703
|
+
);
|
|
704
|
+
exitProcess(1);
|
|
705
|
+
}
|
|
706
|
+
|
|
441
707
|
if (!segments) {
|
|
442
708
|
info("Fetching transcript...");
|
|
443
709
|
if (showDetails && !outputJson) {
|
package/src/summarize.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { summarize } from "./summarize";
|
|
3
|
+
import type { ExecuteCommand } from "./summarize";
|
|
3
4
|
|
|
4
5
|
describe("summarize", () => {
|
|
5
6
|
test("rejects when command only echoes input", async () => {
|
|
@@ -19,14 +20,13 @@ describe("summarize", () => {
|
|
|
19
20
|
command: "cat",
|
|
20
21
|
transcript: "Just the transcript.",
|
|
21
22
|
}),
|
|
22
|
-
).rejects.toThrow("
|
|
23
|
+
).rejects.toThrow("No prompt configured");
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
test("preserves response text beyond the input", async () => {
|
|
26
|
-
const cmd = "sh -c 'cat -; echo \"RESPONSE\"'";
|
|
27
27
|
const result = await summarize({
|
|
28
28
|
prompt: "Summarize:",
|
|
29
|
-
command:
|
|
29
|
+
command: "sh -c 'cat -; echo \"RESPONSE\"'",
|
|
30
30
|
transcript: "Transcript text.",
|
|
31
31
|
});
|
|
32
32
|
expect(result).toBe("RESPONSE");
|
|
@@ -41,4 +41,75 @@ describe("summarize", () => {
|
|
|
41
41
|
}),
|
|
42
42
|
).rejects.toThrow(/exited with code 1/);
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
test("prompt text is included in stdin sent to the command", async () => {
|
|
46
|
+
let capturedInput = "";
|
|
47
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
48
|
+
capturedInput = input;
|
|
49
|
+
return "done";
|
|
50
|
+
};
|
|
51
|
+
await summarize({ prompt: "TEST_PROMPT", command: "any", transcript: "SOME_TEXT" }, exec);
|
|
52
|
+
expect(capturedInput).toContain("TEST_PROMPT");
|
|
53
|
+
expect(capturedInput).toContain("\n\n");
|
|
54
|
+
expect(capturedInput).toContain("SOME_TEXT");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("transcript text is included in stdin along with the prompt", async () => {
|
|
58
|
+
let capturedInput = "";
|
|
59
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
60
|
+
capturedInput = input;
|
|
61
|
+
return "done";
|
|
62
|
+
};
|
|
63
|
+
await summarize({ prompt: "PROMPT:", command: "any", transcript: "SAMPLE_TRANSCRIPT" }, exec);
|
|
64
|
+
expect(capturedInput).toContain("SAMPLE_TRANSCRIPT");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("prompt appears before transcript text in stdin", async () => {
|
|
68
|
+
let capturedInput = "";
|
|
69
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
70
|
+
capturedInput = input;
|
|
71
|
+
return "done";
|
|
72
|
+
};
|
|
73
|
+
await summarize({ prompt: "FIRST_LINE", command: "any", transcript: "SECOND_LINE" }, exec);
|
|
74
|
+
const lines = capturedInput.split("\n");
|
|
75
|
+
expect(lines[0]).toBe("FIRST_LINE");
|
|
76
|
+
expect(lines[lines.length - 1]).toBe("SECOND_LINE");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects when prompt is empty string", async () => {
|
|
80
|
+
const exec: ExecuteCommand = async () => "some output";
|
|
81
|
+
await expect(
|
|
82
|
+
summarize({ prompt: "", command: "any", transcript: "text" }, exec),
|
|
83
|
+
).rejects.toThrow("No prompt configured");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("execCommand receives the correct content via stdin", async () => {
|
|
87
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
88
|
+
expect(input).toBe("My Prompt\n\nMy Transcript");
|
|
89
|
+
return "result";
|
|
90
|
+
};
|
|
91
|
+
const result = await summarize(
|
|
92
|
+
{ prompt: "My Prompt", command: "any", transcript: "My Transcript" },
|
|
93
|
+
exec,
|
|
94
|
+
);
|
|
95
|
+
expect(result).toBe("result");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("strips echoed input from command output", async () => {
|
|
99
|
+
// Simulate command that echoes the full input then adds response
|
|
100
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
101
|
+
return `${input}EXTRA_RESPONSE`;
|
|
102
|
+
};
|
|
103
|
+
const result = await summarize({ prompt: "P", command: "any", transcript: "T" }, exec);
|
|
104
|
+
expect(result).toBe("EXTRA_RESPONSE");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("throws when execCommand returns only the input", async () => {
|
|
108
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
109
|
+
return `${input}\n`;
|
|
110
|
+
};
|
|
111
|
+
await expect(summarize({ prompt: "P", command: "any", transcript: "T" }, exec)).rejects.toThrow(
|
|
112
|
+
"Summarization command returned no meaningful output",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
44
115
|
});
|