@tacone/prosey 0.5.0 → 0.7.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/bin/prosey +124 -62
- package/package.json +1 -1
- package/src/config.ts +1 -42
- package/src/default-config.toml +18 -1
- package/src/format.ts +7 -0
- package/src/html.test.ts +1 -1
- package/src/html.ts +23 -6
- package/src/index.ts +105 -13
package/bin/prosey
CHANGED
|
@@ -101600,46 +101600,6 @@ var lt = (r) => {
|
|
|
101600
101600
|
};
|
|
101601
101601
|
|
|
101602
101602
|
// src/config.ts
|
|
101603
|
-
var FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
101604
|
-
# Created automatically on first run. Edit as needed.
|
|
101605
|
-
|
|
101606
|
-
# Pager command for transcript and summary output.
|
|
101607
|
-
# Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
|
|
101608
|
-
# Set to a custom command (e.g. "less -R") to override.
|
|
101609
|
-
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
101610
|
-
pager = "auto"
|
|
101611
|
-
|
|
101612
|
-
# Show hints for missing tools (e.g. markdown highlighter).
|
|
101613
|
-
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
101614
|
-
hints = true
|
|
101615
|
-
|
|
101616
|
-
[ai]
|
|
101617
|
-
# Default command for AI operations (summarize, transcribe).
|
|
101618
|
-
# Can be overridden per-section via the command key below.
|
|
101619
|
-
command = "opencode run"
|
|
101620
|
-
|
|
101621
|
-
[summarize]
|
|
101622
|
-
# Prompt sent to the command via stdin.
|
|
101623
|
-
# Customize this to change how transcripts are summarized.
|
|
101624
|
-
prompt = """
|
|
101625
|
-
Write a comprehensive summary of the following transcription.
|
|
101626
|
-
"""
|
|
101627
|
-
|
|
101628
|
-
# Command override for summarize. Uncomment to use a different command
|
|
101629
|
-
# than the one specified in [ai].
|
|
101630
|
-
# command = "opencode run"
|
|
101631
|
-
|
|
101632
|
-
[transcribe]
|
|
101633
|
-
# Prompt sent to the command via stdin.
|
|
101634
|
-
# Customize this to change how transcripts are formatted as markdown.
|
|
101635
|
-
prompt = """
|
|
101636
|
-
Convert this transcript to clean, readable markdown.
|
|
101637
|
-
"""
|
|
101638
|
-
|
|
101639
|
-
# Command override for transcribe. Uncomment to use a different command
|
|
101640
|
-
# than the one specified in [ai].
|
|
101641
|
-
# command = "opencode run"
|
|
101642
|
-
`;
|
|
101643
101603
|
async function readDefaultConfig() {
|
|
101644
101604
|
const paths = [
|
|
101645
101605
|
join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
|
|
@@ -101649,7 +101609,7 @@ async function readDefaultConfig() {
|
|
|
101649
101609
|
if (existsSync(p2))
|
|
101650
101610
|
return readFile(p2, "utf8");
|
|
101651
101611
|
}
|
|
101652
|
-
|
|
101612
|
+
throw new Error("default-config.toml not found");
|
|
101653
101613
|
}
|
|
101654
101614
|
function configDir() {
|
|
101655
101615
|
const env = process.env.XDG_CONFIG_HOME;
|
|
@@ -103188,14 +103148,23 @@ var LOGO_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_
|
|
|
103188
103148
|
function escapeHtml(text) {
|
|
103189
103149
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
103190
103150
|
}
|
|
103191
|
-
|
|
103151
|
+
function watchMetaHtml(duration, wordCount, videoId) {
|
|
103152
|
+
const h2 = Math.floor(duration / 3600);
|
|
103153
|
+
const m3 = Math.round(duration % 3600 / 60);
|
|
103154
|
+
const durStr = h2 > 0 ? `${h2} h ${m3} min` : `${m3} min`;
|
|
103155
|
+
const readTime = Math.ceil(wordCount / 200);
|
|
103156
|
+
const url = `https://youtube.com/watch?v=${videoId}`;
|
|
103157
|
+
const separator = '<span style="opacity: 0.4"> | </span>';
|
|
103158
|
+
return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
|
|
103159
|
+
}
|
|
103160
|
+
async function generateHtml(markdown, title, watchMeta) {
|
|
103192
103161
|
const [css, body] = await Promise.all([getPicoCss(), g2.parse(markdown)]);
|
|
103193
103162
|
return `<!DOCTYPE html>
|
|
103194
103163
|
<html lang="en">
|
|
103195
103164
|
<head>
|
|
103196
103165
|
<meta charset="UTF-8">
|
|
103197
103166
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
103198
|
-
<title>${escapeHtml(title
|
|
103167
|
+
<title>${title ? escapeHtml(title) + " - Prosey" : "Prosey"}</title>
|
|
103199
103168
|
<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>
|
|
103200
103169
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
103201
103170
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -103249,14 +103218,17 @@ blockquote:last-child { margin-bottom: 0; }
|
|
|
103249
103218
|
#theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
|
|
103250
103219
|
img[alt="Prosey"] { filter: grayscale(100%); }
|
|
103251
103220
|
img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
|
|
103221
|
+
.watch-link { color: inherit; text-decoration: none; }
|
|
103222
|
+
.watch-link:hover { text-decoration: underline; }
|
|
103252
103223
|
</style>
|
|
103253
103224
|
</head>
|
|
103254
103225
|
<body>
|
|
103255
|
-
<div style="display:flex;justify-content:space-between;
|
|
103256
|
-
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
|
|
103257
|
-
<
|
|
103226
|
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
|
|
103227
|
+
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
|
|
103228
|
+
<div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
|
|
103229
|
+
<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;flex-shrink:0">\uD83D\uDCA1</button>
|
|
103258
103230
|
</div>
|
|
103259
|
-
<main style="max-width:
|
|
103231
|
+
<main style="max-width:50em;margin:0 auto;padding:1rem">
|
|
103260
103232
|
${body}
|
|
103261
103233
|
</main>
|
|
103262
103234
|
<script>
|
|
@@ -103291,7 +103263,7 @@ function openInBrowser(htmlPath) {
|
|
|
103291
103263
|
// package.json
|
|
103292
103264
|
var package_default = {
|
|
103293
103265
|
name: "@tacone/prosey",
|
|
103294
|
-
version: "0.
|
|
103266
|
+
version: "0.7.0",
|
|
103295
103267
|
description: "Download YouTube video transcripts from the CLI",
|
|
103296
103268
|
module: "src/index.ts",
|
|
103297
103269
|
type: "module",
|
|
@@ -122284,10 +122256,12 @@ try {
|
|
|
122284
122256
|
const dir2 = cacheDir(videoId, cacheOpts2);
|
|
122285
122257
|
let segments2 = null;
|
|
122286
122258
|
let summary = null;
|
|
122259
|
+
let cachedInfo = null;
|
|
122287
122260
|
startTimer();
|
|
122288
122261
|
if (!noCache) {
|
|
122289
122262
|
const cachedSegments = await readCache(dir2, "transcript.json");
|
|
122290
122263
|
const cachedSummary = await readCache(dir2, "summary.md");
|
|
122264
|
+
cachedInfo = await readCache(dir2, "info.json");
|
|
122291
122265
|
if (cachedSegments && cachedSummary) {
|
|
122292
122266
|
info("Transcript cached");
|
|
122293
122267
|
debug("Cache hit:", dir2);
|
|
@@ -122299,23 +122273,92 @@ try {
|
|
|
122299
122273
|
} else {
|
|
122300
122274
|
debug("Cache skipped (--no-cache)");
|
|
122301
122275
|
}
|
|
122302
|
-
if (!segments2) {
|
|
122303
|
-
info("Fetching transcript...");
|
|
122304
|
-
segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
122305
|
-
info(`Transcript: ${segments2.length} segments`);
|
|
122306
|
-
await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
|
|
122307
|
-
debug("Cache written: transcript.json");
|
|
122308
|
-
}
|
|
122309
122276
|
const prompt2 = resolveSummarizePrompt(config) ?? "";
|
|
122310
122277
|
if (!prompt2) {
|
|
122311
122278
|
console.error("Error: no prompt configured. Set a prompt in the [summarize] section of your config.");
|
|
122312
122279
|
exitProcess(1);
|
|
122313
122280
|
}
|
|
122314
|
-
|
|
122281
|
+
let structuredContent;
|
|
122282
|
+
let videoTitle;
|
|
122283
|
+
let videoDuration = 0;
|
|
122284
|
+
if (!segments2) {
|
|
122285
|
+
info("Fetching transcript...");
|
|
122286
|
+
const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
122287
|
+
const result = await fetchTranscript(videoId, opts);
|
|
122288
|
+
segments2 = result.segments;
|
|
122289
|
+
videoDuration = result.videoDetails.lengthSeconds;
|
|
122290
|
+
const infoJson = JSON.stringify({
|
|
122291
|
+
title: result.videoDetails.title,
|
|
122292
|
+
channel: result.videoDetails.author,
|
|
122293
|
+
description: result.videoDetails.description,
|
|
122294
|
+
duration: videoDuration
|
|
122295
|
+
});
|
|
122296
|
+
cachedInfo = infoJson;
|
|
122297
|
+
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
122298
|
+
videoTitle = result.videoDetails.title;
|
|
122299
|
+
const truncatedInfo = JSON.stringify({
|
|
122300
|
+
title: result.videoDetails.title,
|
|
122301
|
+
channel: result.videoDetails.author,
|
|
122302
|
+
description: result.videoDetails.description.slice(0, 1000)
|
|
122303
|
+
});
|
|
122304
|
+
const transcriptText = toText(segments2, !noDecode);
|
|
122305
|
+
structuredContent = `INFO:
|
|
122306
|
+
${truncatedInfo}
|
|
122307
|
+
|
|
122308
|
+
TEXT:
|
|
122309
|
+
${transcriptText}`;
|
|
122310
|
+
if (dryRun) {
|
|
122311
|
+
await outputText(`${prompt2}
|
|
122312
|
+
|
|
122313
|
+
${structuredContent}
|
|
122314
|
+
`);
|
|
122315
|
+
exitProcess(0);
|
|
122316
|
+
}
|
|
122317
|
+
info(`Transcript: ${segments2.length} segments`);
|
|
122318
|
+
await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
|
|
122319
|
+
await writeCache(dir2, "info.json", infoJson);
|
|
122320
|
+
await writeCache(dir2, "chapters.json", chapterValue);
|
|
122321
|
+
debug("Cache written: transcript.json, info.json, chapters.json");
|
|
122322
|
+
} else {
|
|
122323
|
+
let chapterValue;
|
|
122324
|
+
if (!cachedInfo) {
|
|
122325
|
+
debug("Cache missing info.json, re-fetching video details");
|
|
122326
|
+
const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
122327
|
+
const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
|
|
122328
|
+
videoDuration = fallbackResult.videoDetails.lengthSeconds;
|
|
122329
|
+
cachedInfo = JSON.stringify({
|
|
122330
|
+
title: fallbackResult.videoDetails.title,
|
|
122331
|
+
channel: fallbackResult.videoDetails.author,
|
|
122332
|
+
description: fallbackResult.videoDetails.description,
|
|
122333
|
+
duration: videoDuration
|
|
122334
|
+
});
|
|
122335
|
+
await writeCache(dir2, "info.json", cachedInfo);
|
|
122336
|
+
chapterValue = formatChaptersAsJson(extractChapters(fallbackResult.videoDetails.description));
|
|
122337
|
+
await writeCache(dir2, "chapters.json", chapterValue);
|
|
122338
|
+
debug("Cache written: info.json, chapters.json");
|
|
122339
|
+
} else {
|
|
122340
|
+
const cachedChapters = await readCache(dir2, "chapters.json");
|
|
122341
|
+
chapterValue = cachedChapters ?? "not available";
|
|
122342
|
+
}
|
|
122343
|
+
const transcriptText = toText(segments2, !noDecode);
|
|
122344
|
+
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
122345
|
+
videoTitle = cachedInfoObj.title;
|
|
122346
|
+
videoDuration = cachedInfoObj.duration ?? 0;
|
|
122347
|
+
const truncatedInfo = JSON.stringify({
|
|
122348
|
+
title: cachedInfoObj.title,
|
|
122349
|
+
channel: cachedInfoObj.channel,
|
|
122350
|
+
description: cachedInfoObj.description.slice(0, 1000)
|
|
122351
|
+
});
|
|
122352
|
+
structuredContent = `INFO:
|
|
122353
|
+
${truncatedInfo}
|
|
122354
|
+
|
|
122355
|
+
TEXT:
|
|
122356
|
+
${transcriptText}`;
|
|
122357
|
+
}
|
|
122315
122358
|
if (dryRun) {
|
|
122316
122359
|
await outputText(`${prompt2}
|
|
122317
122360
|
|
|
122318
|
-
${
|
|
122361
|
+
${structuredContent}
|
|
122319
122362
|
`);
|
|
122320
122363
|
exitProcess(0);
|
|
122321
122364
|
}
|
|
@@ -122324,7 +122367,7 @@ ${transcriptText}
|
|
|
122324
122367
|
summary = await summarize({
|
|
122325
122368
|
prompt: prompt2,
|
|
122326
122369
|
command: sumCmd,
|
|
122327
|
-
transcript:
|
|
122370
|
+
transcript: structuredContent,
|
|
122328
122371
|
cwd: dir2
|
|
122329
122372
|
});
|
|
122330
122373
|
info("Summary ready");
|
|
@@ -122333,7 +122376,12 @@ ${transcriptText}
|
|
|
122333
122376
|
}
|
|
122334
122377
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
122335
122378
|
if (format3 === "html") {
|
|
122336
|
-
const
|
|
122379
|
+
const wordCount = summary.split(/\s+/).filter(Boolean).length;
|
|
122380
|
+
const htmlContent = await generateHtml(formatted, videoTitle, {
|
|
122381
|
+
videoId,
|
|
122382
|
+
duration: videoDuration,
|
|
122383
|
+
wordCount
|
|
122384
|
+
});
|
|
122337
122385
|
const htmlPath = join5(dir2, "summary.html");
|
|
122338
122386
|
await writeFile3(htmlPath, htmlContent, "utf8");
|
|
122339
122387
|
debug("HTML written:", htmlPath);
|
|
@@ -122364,6 +122412,8 @@ ${transcriptText}
|
|
|
122364
122412
|
const dir2 = cacheDir(videoId, cacheOpts2);
|
|
122365
122413
|
let segments2 = null;
|
|
122366
122414
|
let md = null;
|
|
122415
|
+
let videoTitle;
|
|
122416
|
+
let videoDuration = 0;
|
|
122367
122417
|
startTimer();
|
|
122368
122418
|
let cachedInfo = null;
|
|
122369
122419
|
if (!noCache) {
|
|
@@ -122391,12 +122441,15 @@ ${transcriptText}
|
|
|
122391
122441
|
const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
122392
122442
|
const result = await fetchTranscript(videoId, opts);
|
|
122393
122443
|
segments2 = result.segments;
|
|
122444
|
+
videoDuration = result.videoDetails.lengthSeconds;
|
|
122394
122445
|
const infoJson = JSON.stringify({
|
|
122395
122446
|
title: result.videoDetails.title,
|
|
122396
122447
|
channel: result.videoDetails.author,
|
|
122397
|
-
description: result.videoDetails.description
|
|
122448
|
+
description: result.videoDetails.description,
|
|
122449
|
+
duration: videoDuration
|
|
122398
122450
|
});
|
|
122399
122451
|
cachedInfo = infoJson;
|
|
122452
|
+
videoTitle = result.videoDetails.title;
|
|
122400
122453
|
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
122401
122454
|
const truncatedInfo = JSON.stringify({
|
|
122402
122455
|
title: result.videoDetails.title,
|
|
@@ -122442,10 +122495,12 @@ ${structuredContent}
|
|
|
122442
122495
|
debug("Cache missing info.json, re-fetching video details");
|
|
122443
122496
|
const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
122444
122497
|
const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
|
|
122498
|
+
videoDuration = fallbackResult.videoDetails.lengthSeconds;
|
|
122445
122499
|
cachedInfo = JSON.stringify({
|
|
122446
122500
|
title: fallbackResult.videoDetails.title,
|
|
122447
122501
|
channel: fallbackResult.videoDetails.author,
|
|
122448
|
-
description: fallbackResult.videoDetails.description
|
|
122502
|
+
description: fallbackResult.videoDetails.description,
|
|
122503
|
+
duration: videoDuration
|
|
122449
122504
|
});
|
|
122450
122505
|
await writeCache(dir2, "info.json", cachedInfo);
|
|
122451
122506
|
chapterValue = formatChaptersAsJson(extractChapters(fallbackResult.videoDetails.description));
|
|
@@ -122457,6 +122512,8 @@ ${structuredContent}
|
|
|
122457
122512
|
}
|
|
122458
122513
|
const transcriptText = toText(segments2, !noDecode);
|
|
122459
122514
|
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
122515
|
+
videoTitle = cachedInfoObj.title;
|
|
122516
|
+
videoDuration = cachedInfoObj.duration ?? 0;
|
|
122460
122517
|
const truncatedInfo = JSON.stringify({
|
|
122461
122518
|
title: cachedInfoObj.title,
|
|
122462
122519
|
channel: cachedInfoObj.channel,
|
|
@@ -122485,7 +122542,12 @@ ${transcriptText}`;
|
|
|
122485
122542
|
}
|
|
122486
122543
|
const formatted = noFormat ? md : await formatMd(md);
|
|
122487
122544
|
if (format3 === "html") {
|
|
122488
|
-
const
|
|
122545
|
+
const wordCount = md.split(/\s+/).filter(Boolean).length;
|
|
122546
|
+
const htmlContent = await generateHtml(formatted, videoTitle, {
|
|
122547
|
+
videoId,
|
|
122548
|
+
duration: videoDuration,
|
|
122549
|
+
wordCount
|
|
122550
|
+
});
|
|
122489
122551
|
const htmlPath = join5(dir2, "transcript.html");
|
|
122490
122552
|
await writeFile3(htmlPath, htmlContent, "utf8");
|
|
122491
122553
|
debug("HTML written:", htmlPath);
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -21,47 +21,6 @@ export interface ProseyConfig {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
25
|
-
# Created automatically on first run. Edit as needed.
|
|
26
|
-
|
|
27
|
-
# Pager command for transcript and summary output.
|
|
28
|
-
# Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
|
|
29
|
-
# Set to a custom command (e.g. "less -R") to override.
|
|
30
|
-
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
31
|
-
pager = "auto"
|
|
32
|
-
|
|
33
|
-
# Show hints for missing tools (e.g. markdown highlighter).
|
|
34
|
-
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
35
|
-
hints = true
|
|
36
|
-
|
|
37
|
-
[ai]
|
|
38
|
-
# Default command for AI operations (summarize, transcribe).
|
|
39
|
-
# Can be overridden per-section via the command key below.
|
|
40
|
-
command = "opencode run"
|
|
41
|
-
|
|
42
|
-
[summarize]
|
|
43
|
-
# Prompt sent to the command via stdin.
|
|
44
|
-
# Customize this to change how transcripts are summarized.
|
|
45
|
-
prompt = """
|
|
46
|
-
Write a comprehensive summary of the following transcription.
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
# Command override for summarize. Uncomment to use a different command
|
|
50
|
-
# than the one specified in [ai].
|
|
51
|
-
# command = "opencode run"
|
|
52
|
-
|
|
53
|
-
[transcribe]
|
|
54
|
-
# Prompt sent to the command via stdin.
|
|
55
|
-
# Customize this to change how transcripts are formatted as markdown.
|
|
56
|
-
prompt = """
|
|
57
|
-
Convert this transcript to clean, readable markdown.
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
# Command override for transcribe. Uncomment to use a different command
|
|
61
|
-
# than the one specified in [ai].
|
|
62
|
-
# command = "opencode run"
|
|
63
|
-
`;
|
|
64
|
-
|
|
65
24
|
async function readDefaultConfig(): Promise<string> {
|
|
66
25
|
const paths = [
|
|
67
26
|
join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
|
|
@@ -70,7 +29,7 @@ async function readDefaultConfig(): Promise<string> {
|
|
|
70
29
|
for (const p of paths) {
|
|
71
30
|
if (existsSync(p)) return readFile(p, "utf8");
|
|
72
31
|
}
|
|
73
|
-
|
|
32
|
+
throw new Error("default-config.toml not found");
|
|
74
33
|
}
|
|
75
34
|
|
|
76
35
|
function configDir(): string {
|
package/src/default-config.toml
CHANGED
|
@@ -29,7 +29,24 @@ command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":
|
|
|
29
29
|
# Customize this to change how transcripts are summarized.
|
|
30
30
|
|
|
31
31
|
prompt = """
|
|
32
|
-
Write a comprehensive summary of the following transcription
|
|
32
|
+
Write a comprehensive summary of the following video transcription:
|
|
33
|
+
|
|
34
|
+
Guidelines:
|
|
35
|
+
- use the INFO section data to correct spellings in the text (e.g. Pi vs Pie)
|
|
36
|
+
- use the INFO title value as the main title (h1 header)
|
|
37
|
+
- use the TIMESTAMPS only to understand the structure, don't output time markers
|
|
38
|
+
- content length: aim to be the same range as the original text or more.
|
|
39
|
+
- section titles should be h2 headers
|
|
40
|
+
- paragraph length soft-limit. 250 characters
|
|
41
|
+
|
|
42
|
+
Content considerations:
|
|
43
|
+
|
|
44
|
+
- findings should be presented in lists or table
|
|
45
|
+
- flows should be presented in ordered lists
|
|
46
|
+
- elaborations are better in prose
|
|
47
|
+
- when content mixes types within a section, lead with prose and break out lists only for discrete items
|
|
48
|
+
|
|
49
|
+
Only output the summary text without adding anything.
|
|
33
50
|
"""
|
|
34
51
|
|
|
35
52
|
# Command override for summarize. Uncomment to use a different command
|
package/src/format.ts
CHANGED
|
@@ -24,6 +24,13 @@ export function formatDuration(seconds: number): string {
|
|
|
24
24
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function formatReadableDuration(seconds: number): string {
|
|
28
|
+
const h = Math.floor(seconds / 3600);
|
|
29
|
+
const m = Math.round((seconds % 3600) / 60);
|
|
30
|
+
if (h > 0) return `${h}h ${m}min`;
|
|
31
|
+
return `${m}min`;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export function decodeEntities(text: string): string {
|
|
28
35
|
return text
|
|
29
36
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
package/src/html.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ describe("generateHtml", () => {
|
|
|
5
5
|
test("wraps markdown in HTML with PicoCSS", async () => {
|
|
6
6
|
const html = await generateHtml("# Hello\n\nWorld", "Test");
|
|
7
7
|
expect(html).toStartWith("<!DOCTYPE html>");
|
|
8
|
-
expect(html).toContain("<title>Test</title>");
|
|
8
|
+
expect(html).toContain("<title>Test - Prosey</title>");
|
|
9
9
|
expect(html).toContain("<style>");
|
|
10
10
|
expect(html).toContain("--pico-");
|
|
11
11
|
expect(html).toContain("<h1>Hello</h1>");
|
package/src/html.ts
CHANGED
|
@@ -34,7 +34,21 @@ function escapeHtml(text: string): string {
|
|
|
34
34
|
.replace(/"/g, """);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
function watchMetaHtml(duration: number, wordCount: number, videoId: string): string {
|
|
38
|
+
const h = Math.floor(duration / 3600);
|
|
39
|
+
const m = Math.round((duration % 3600) / 60);
|
|
40
|
+
const durStr = h > 0 ? `${h} h ${m} min` : `${m} min`;
|
|
41
|
+
const readTime = Math.ceil(wordCount / 200);
|
|
42
|
+
const url = `https://youtube.com/watch?v=${videoId}`;
|
|
43
|
+
const separator = '<span style="opacity: 0.4"> | </span>';
|
|
44
|
+
return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function generateHtml(
|
|
48
|
+
markdown: string,
|
|
49
|
+
title?: string,
|
|
50
|
+
watchMeta?: { videoId: string; duration: number; wordCount: number },
|
|
51
|
+
): Promise<string> {
|
|
38
52
|
const [css, body] = await Promise.all([getPicoCss(), marked.parse(markdown)]);
|
|
39
53
|
|
|
40
54
|
return `<!DOCTYPE html>
|
|
@@ -42,7 +56,7 @@ export async function generateHtml(markdown: string, title?: string): Promise<st
|
|
|
42
56
|
<head>
|
|
43
57
|
<meta charset="UTF-8">
|
|
44
58
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
45
|
-
<title>${escapeHtml(title
|
|
59
|
+
<title>${title ? escapeHtml(title) + " - Prosey" : "Prosey"}</title>
|
|
46
60
|
<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
61
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
48
62
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -96,14 +110,17 @@ blockquote:last-child { margin-bottom: 0; }
|
|
|
96
110
|
#theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
|
|
97
111
|
img[alt="Prosey"] { filter: grayscale(100%); }
|
|
98
112
|
img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
|
|
113
|
+
.watch-link { color: inherit; text-decoration: none; }
|
|
114
|
+
.watch-link:hover { text-decoration: underline; }
|
|
99
115
|
</style>
|
|
100
116
|
</head>
|
|
101
117
|
<body>
|
|
102
|
-
<div style="display:flex;justify-content:space-between;
|
|
103
|
-
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
|
|
104
|
-
<
|
|
118
|
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
|
|
119
|
+
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
|
|
120
|
+
<div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
|
|
121
|
+
<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;flex-shrink:0">💡</button>
|
|
105
122
|
</div>
|
|
106
|
-
<main style="max-width:
|
|
123
|
+
<main style="max-width:50em;margin:0 auto;padding:1rem">
|
|
107
124
|
${body}
|
|
108
125
|
</main>
|
|
109
126
|
<script>
|
package/src/index.ts
CHANGED
|
@@ -443,12 +443,14 @@ try {
|
|
|
443
443
|
const dir = cacheDir(videoId, cacheOpts);
|
|
444
444
|
let segments: TranscriptSegment[] | null = null;
|
|
445
445
|
let summary: string | null = null;
|
|
446
|
+
let cachedInfo: string | null = null;
|
|
446
447
|
|
|
447
448
|
startTimer();
|
|
448
449
|
|
|
449
450
|
if (!noCache) {
|
|
450
451
|
const cachedSegments = await readCache(dir, "transcript.json");
|
|
451
452
|
const cachedSummary = await readCache(dir, "summary.md");
|
|
453
|
+
cachedInfo = await readCache(dir, "info.json");
|
|
452
454
|
if (cachedSegments && cachedSummary) {
|
|
453
455
|
info("Transcript cached");
|
|
454
456
|
debug("Cache hit:", dir);
|
|
@@ -461,14 +463,6 @@ try {
|
|
|
461
463
|
debug("Cache skipped (--no-cache)");
|
|
462
464
|
}
|
|
463
465
|
|
|
464
|
-
if (!segments) {
|
|
465
|
-
info("Fetching transcript...");
|
|
466
|
-
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
467
|
-
info(`Transcript: ${segments.length} segments`);
|
|
468
|
-
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
469
|
-
debug("Cache written: transcript.json");
|
|
470
|
-
}
|
|
471
|
-
|
|
472
466
|
const prompt = resolveSummarizePrompt(config) ?? "";
|
|
473
467
|
if (!prompt) {
|
|
474
468
|
console.error(
|
|
@@ -476,10 +470,89 @@ try {
|
|
|
476
470
|
);
|
|
477
471
|
exitProcess(1);
|
|
478
472
|
}
|
|
479
|
-
|
|
473
|
+
|
|
474
|
+
let structuredContent: string;
|
|
475
|
+
let videoTitle: string | undefined;
|
|
476
|
+
let videoDuration = 0;
|
|
477
|
+
|
|
478
|
+
if (!segments) {
|
|
479
|
+
info("Fetching transcript...");
|
|
480
|
+
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
481
|
+
const result = (await fetchTranscript(videoId, opts)) as {
|
|
482
|
+
videoDetails: VideoDetails;
|
|
483
|
+
segments: TranscriptSegment[];
|
|
484
|
+
};
|
|
485
|
+
segments = result.segments;
|
|
486
|
+
videoDuration = result.videoDetails.lengthSeconds;
|
|
487
|
+
const infoJson = JSON.stringify({
|
|
488
|
+
title: result.videoDetails.title,
|
|
489
|
+
channel: result.videoDetails.author,
|
|
490
|
+
description: result.videoDetails.description,
|
|
491
|
+
duration: videoDuration,
|
|
492
|
+
});
|
|
493
|
+
cachedInfo = infoJson;
|
|
494
|
+
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
495
|
+
videoTitle = result.videoDetails.title;
|
|
496
|
+
const truncatedInfo = JSON.stringify({
|
|
497
|
+
title: result.videoDetails.title,
|
|
498
|
+
channel: result.videoDetails.author,
|
|
499
|
+
description: result.videoDetails.description.slice(0, 1000),
|
|
500
|
+
});
|
|
501
|
+
const transcriptText = toText(segments, !noDecode);
|
|
502
|
+
structuredContent = `INFO:\n${truncatedInfo}\n\nTEXT:\n${transcriptText}`;
|
|
503
|
+
|
|
504
|
+
if (dryRun) {
|
|
505
|
+
await outputText(`${prompt}\n\n${structuredContent}\n`);
|
|
506
|
+
exitProcess(0);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
info(`Transcript: ${segments.length} segments`);
|
|
510
|
+
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
511
|
+
await writeCache(dir, "info.json", infoJson);
|
|
512
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
513
|
+
debug("Cache written: transcript.json, info.json, chapters.json");
|
|
514
|
+
} else {
|
|
515
|
+
let chapterValue: string;
|
|
516
|
+
if (!cachedInfo) {
|
|
517
|
+
debug("Cache missing info.json, re-fetching video details");
|
|
518
|
+
const fallbackOpts = lang
|
|
519
|
+
? { lang, videoDetails: true as const }
|
|
520
|
+
: { videoDetails: true as const };
|
|
521
|
+
const fallbackResult = (await fetchTranscript(videoId, fallbackOpts)) as {
|
|
522
|
+
videoDetails: VideoDetails;
|
|
523
|
+
segments: TranscriptSegment[];
|
|
524
|
+
};
|
|
525
|
+
videoDuration = fallbackResult.videoDetails.lengthSeconds;
|
|
526
|
+
cachedInfo = JSON.stringify({
|
|
527
|
+
title: fallbackResult.videoDetails.title,
|
|
528
|
+
channel: fallbackResult.videoDetails.author,
|
|
529
|
+
description: fallbackResult.videoDetails.description,
|
|
530
|
+
duration: videoDuration,
|
|
531
|
+
});
|
|
532
|
+
await writeCache(dir, "info.json", cachedInfo);
|
|
533
|
+
chapterValue = formatChaptersAsJson(
|
|
534
|
+
extractChapters(fallbackResult.videoDetails.description),
|
|
535
|
+
);
|
|
536
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
537
|
+
debug("Cache written: info.json, chapters.json");
|
|
538
|
+
} else {
|
|
539
|
+
const cachedChapters = await readCache(dir, "chapters.json");
|
|
540
|
+
chapterValue = cachedChapters ?? "not available";
|
|
541
|
+
}
|
|
542
|
+
const transcriptText = toText(segments, !noDecode);
|
|
543
|
+
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
544
|
+
videoTitle = cachedInfoObj.title;
|
|
545
|
+
videoDuration = cachedInfoObj.duration ?? 0;
|
|
546
|
+
const truncatedInfo = JSON.stringify({
|
|
547
|
+
title: cachedInfoObj.title,
|
|
548
|
+
channel: cachedInfoObj.channel,
|
|
549
|
+
description: cachedInfoObj.description.slice(0, 1000),
|
|
550
|
+
});
|
|
551
|
+
structuredContent = `INFO:\n${truncatedInfo}\n\nTEXT:\n${transcriptText}`;
|
|
552
|
+
}
|
|
480
553
|
|
|
481
554
|
if (dryRun) {
|
|
482
|
-
await outputText(`${prompt}\n\n${
|
|
555
|
+
await outputText(`${prompt}\n\n${structuredContent}\n`);
|
|
483
556
|
exitProcess(0);
|
|
484
557
|
}
|
|
485
558
|
|
|
@@ -488,7 +561,7 @@ try {
|
|
|
488
561
|
summary = await summarize({
|
|
489
562
|
prompt,
|
|
490
563
|
command: sumCmd,
|
|
491
|
-
transcript:
|
|
564
|
+
transcript: structuredContent,
|
|
492
565
|
cwd: dir,
|
|
493
566
|
});
|
|
494
567
|
info("Summary ready");
|
|
@@ -498,7 +571,12 @@ try {
|
|
|
498
571
|
|
|
499
572
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
500
573
|
if (format === "html") {
|
|
501
|
-
const
|
|
574
|
+
const wordCount = summary!.split(/\s+/).filter(Boolean).length;
|
|
575
|
+
const htmlContent = await generateHtml(formatted, videoTitle, {
|
|
576
|
+
videoId,
|
|
577
|
+
duration: videoDuration,
|
|
578
|
+
wordCount,
|
|
579
|
+
});
|
|
502
580
|
const htmlPath = join(dir, "summary.html");
|
|
503
581
|
await writeFile(htmlPath, htmlContent, "utf8");
|
|
504
582
|
debug("HTML written:", htmlPath);
|
|
@@ -532,6 +610,8 @@ try {
|
|
|
532
610
|
const dir = cacheDir(videoId, cacheOpts);
|
|
533
611
|
let segments: TranscriptSegment[] | null = null;
|
|
534
612
|
let md: string | null = null;
|
|
613
|
+
let videoTitle: string | undefined;
|
|
614
|
+
let videoDuration = 0;
|
|
535
615
|
|
|
536
616
|
startTimer();
|
|
537
617
|
|
|
@@ -569,12 +649,15 @@ try {
|
|
|
569
649
|
segments: TranscriptSegment[];
|
|
570
650
|
};
|
|
571
651
|
segments = result.segments;
|
|
652
|
+
videoDuration = result.videoDetails.lengthSeconds;
|
|
572
653
|
const infoJson = JSON.stringify({
|
|
573
654
|
title: result.videoDetails.title,
|
|
574
655
|
channel: result.videoDetails.author,
|
|
575
656
|
description: result.videoDetails.description,
|
|
657
|
+
duration: videoDuration,
|
|
576
658
|
});
|
|
577
659
|
cachedInfo = infoJson;
|
|
660
|
+
videoTitle = result.videoDetails.title;
|
|
578
661
|
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
579
662
|
const truncatedInfo = JSON.stringify({
|
|
580
663
|
title: result.videoDetails.title,
|
|
@@ -618,10 +701,12 @@ try {
|
|
|
618
701
|
videoDetails: VideoDetails;
|
|
619
702
|
segments: TranscriptSegment[];
|
|
620
703
|
};
|
|
704
|
+
videoDuration = fallbackResult.videoDetails.lengthSeconds;
|
|
621
705
|
cachedInfo = JSON.stringify({
|
|
622
706
|
title: fallbackResult.videoDetails.title,
|
|
623
707
|
channel: fallbackResult.videoDetails.author,
|
|
624
708
|
description: fallbackResult.videoDetails.description,
|
|
709
|
+
duration: videoDuration,
|
|
625
710
|
});
|
|
626
711
|
await writeCache(dir, "info.json", cachedInfo);
|
|
627
712
|
chapterValue = formatChaptersAsJson(
|
|
@@ -635,6 +720,8 @@ try {
|
|
|
635
720
|
}
|
|
636
721
|
const transcriptText = toText(segments, !noDecode);
|
|
637
722
|
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
723
|
+
videoTitle = cachedInfoObj.title;
|
|
724
|
+
videoDuration = cachedInfoObj.duration ?? 0;
|
|
638
725
|
const truncatedInfo = JSON.stringify({
|
|
639
726
|
title: cachedInfoObj.title,
|
|
640
727
|
channel: cachedInfoObj.channel,
|
|
@@ -658,7 +745,12 @@ try {
|
|
|
658
745
|
|
|
659
746
|
const formatted = noFormat ? md : await formatMd(md);
|
|
660
747
|
if (format === "html") {
|
|
661
|
-
const
|
|
748
|
+
const wordCount = md!.split(/\s+/).filter(Boolean).length;
|
|
749
|
+
const htmlContent = await generateHtml(formatted, videoTitle, {
|
|
750
|
+
videoId,
|
|
751
|
+
duration: videoDuration,
|
|
752
|
+
wordCount,
|
|
753
|
+
});
|
|
662
754
|
const htmlPath = join(dir, "transcript.html");
|
|
663
755
|
await writeFile(htmlPath, htmlContent, "utf8");
|
|
664
756
|
debug("HTML written:", htmlPath);
|