@stephane-klein/youtube-to-markdown 0.4.0 → 0.5.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 +45 -4
- package/package.json +1 -1
- package/src/extract-video-metadata.js +28 -3
- package/src/generate-markdown.js +49 -30
- package/src/progress.js +32 -0
package/README.md
CHANGED
|
@@ -13,6 +13,37 @@ channel or playlist and then to download its transcripts.
|
|
|
13
13
|
In the final step, I use an LLM through a standard OpenAI-compatible API to
|
|
14
14
|
reconstruct a complete text from the video transcripts.
|
|
15
15
|
|
|
16
|
+
## AI usage in this project
|
|
17
|
+
|
|
18
|
+
This project was developed using:
|
|
19
|
+
|
|
20
|
+
- Harness: [OpenCode](https://opencode.ai)
|
|
21
|
+
- Models: [DeepSeek-V4.1-Flash](https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash) via [OpenCode Go](https://opencode.ai/go)
|
|
22
|
+
- I mostly vibe coded this project: I made the implementation, library and architecture choices, but I reviewed the code very little.
|
|
23
|
+
|
|
24
|
+
The figures below cover the period from the start of the project to version 0.4.0
|
|
25
|
+
([de39801](https://github.com/stephane-klein/youtube-to-markdown/commit/de39801f123fa1cfaeaa3b9cfed5071968977474)).
|
|
26
|
+
|
|
27
|
+
Metrics:
|
|
28
|
+
|
|
29
|
+
- 16 OpenCode sessions, 1426 interactions;
|
|
30
|
+
- 11 h of human time on this project, against 4 h of AI generation time over the same period, so a human/AI time ratio of 2.75 (11 h / 4 h);
|
|
31
|
+
- Cost: $2.4958;
|
|
32
|
+
- Tokens:
|
|
33
|
+
- input: 7,047,916
|
|
34
|
+
- output: 421,770
|
|
35
|
+
- cache read: 228,841,600
|
|
36
|
+
- total: 236,311,286
|
|
37
|
+
|
|
38
|
+
Per agent:
|
|
39
|
+
|
|
40
|
+
| agent | interactions | share | output tokens | active hours | active share | generation hours | cost |
|
|
41
|
+
|:--------|-------------:|------:|--------------:|-------------:|-------------:|-----------------:|-------:|
|
|
42
|
+
| build | 962 | 68% | 259,706 | 3.71 | 38% | 1.93 | $1.385 |
|
|
43
|
+
| plan | 450 | 32% | 155,136 | 5.91 | 61% | 2.10 | $1.098 |
|
|
44
|
+
| explore | 14 | 1% | 6,928 | 0.02 | 0% | 0.02 | $0.013 |
|
|
45
|
+
|
|
46
|
+
|
|
16
47
|
## Requirements
|
|
17
48
|
|
|
18
49
|
- Node.js 22 or later;
|
|
@@ -129,7 +160,13 @@ can gate a CI job.
|
|
|
129
160
|
```sh
|
|
130
161
|
$ youtube-to-markdown extract-video-metadata
|
|
131
162
|
→ https://www.youtube.com/@le_science4all
|
|
163
|
+
exact dates 50/2217 2.4s elapsed ~1m 44s left
|
|
164
|
+
exact dates 100/2217 4.8s elapsed ~1m 42s left
|
|
165
|
+
exact dates 2217/2217 1m 46s elapsed
|
|
166
|
+
done in 1m 46s
|
|
132
167
|
→ https://www.youtube.com/@MonsieurPhi
|
|
168
|
+
done in 12.3s
|
|
169
|
+
summary: 2 sources, 2678 videos, 2m 05s elapsed
|
|
133
170
|
```
|
|
134
171
|
|
|
135
172
|
The command is idempotent: a new run only adds the missing videos.
|
|
@@ -348,10 +385,10 @@ llm:
|
|
|
348
385
|
`video_title` is the original video title in the file's language and
|
|
349
386
|
`generated_at` is the UTC time of the generation. `pricing.source` names the
|
|
350
387
|
catalog the rates came from and `pricing.peak` tells whether the peak rate
|
|
351
|
-
applied (`null` when the source has no peak/off-peak pricing).
|
|
352
|
-
are left untouched: run `youtube-to-markdown generate-markdown
|
|
353
|
-
regenerate every file, which is also the way to add the frontmatter
|
|
354
|
-
generated before it existed.
|
|
388
|
+
applied to the first call (`null` when the source has no peak/off-peak pricing).
|
|
389
|
+
Existing files are left untouched: run `youtube-to-markdown generate-markdown
|
|
390
|
+
--force` to regenerate every file, which is also the way to add the frontmatter
|
|
391
|
+
to files generated before it existed.
|
|
355
392
|
|
|
356
393
|
### Pricing
|
|
357
394
|
|
|
@@ -364,6 +401,10 @@ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
|
|
|
364
401
|
(`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
|
|
365
402
|
unavailable, `estimated_cost_usd` is `null` and the run continues.
|
|
366
403
|
|
|
404
|
+
The rate is sampled at the start of every LLM call, so a run that straddles a
|
|
405
|
+
peak/off-peak boundary is priced with the right rate on each side. The
|
|
406
|
+
`pricing` block in the frontmatter describes the first call of the file.
|
|
407
|
+
|
|
367
408
|
## Organize the files into folders
|
|
368
409
|
|
|
369
410
|
A channel may set `folder_slug` to keep its files in their own subdirectory of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Collect YouTube channel videos into a YAML config with yt-dlp, download transcripts and turn them into Markdown prose with an LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
2
|
import { stringify } from "yaml";
|
|
3
|
+
import { formatDuration, progressLine } from "./progress.js";
|
|
3
4
|
import { runYtDlp } from "./ytdlp.js";
|
|
4
5
|
|
|
5
6
|
const DATE_CHUNK = 50;
|
|
6
7
|
|
|
8
|
+
const count = (value, word) => `${value} ${word}${value === 1 ? "" : "s"}`;
|
|
9
|
+
|
|
7
10
|
const toIso = (raw) =>
|
|
8
11
|
/^\d{8}$/.test(raw)
|
|
9
12
|
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
|
@@ -61,12 +64,10 @@ const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
|
|
|
61
64
|
|
|
62
65
|
async function fetchExactDates(urls) {
|
|
63
66
|
const dates = new Map();
|
|
67
|
+
const startedAt = performance.now();
|
|
64
68
|
|
|
65
69
|
for (let i = 0; i < urls.length; i += DATE_CHUNK) {
|
|
66
70
|
const chunk = urls.slice(i, i + DATE_CHUNK);
|
|
67
|
-
console.error(
|
|
68
|
-
` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
|
|
69
|
-
);
|
|
70
71
|
const out = await ytDlp(
|
|
71
72
|
[
|
|
72
73
|
"--skip-download",
|
|
@@ -84,6 +85,15 @@ async function fetchExactDates(urls) {
|
|
|
84
85
|
const iso = toIso(raw ?? "");
|
|
85
86
|
if (id && iso) dates.set(id, iso);
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
console.error(
|
|
90
|
+
progressLine({
|
|
91
|
+
label: "exact dates",
|
|
92
|
+
done: Math.min(i + DATE_CHUNK, urls.length),
|
|
93
|
+
total: urls.length,
|
|
94
|
+
startedAt,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
return dates;
|
|
@@ -180,9 +190,14 @@ export async function runExtract({
|
|
|
180
190
|
config = {},
|
|
181
191
|
forceDates = false,
|
|
182
192
|
} = {}) {
|
|
193
|
+
const startedAt = performance.now();
|
|
194
|
+
let sourceCount = 0;
|
|
195
|
+
let videoCount = 0;
|
|
196
|
+
|
|
183
197
|
for (const channel of config.feed ?? []) {
|
|
184
198
|
if (typeof channel.url !== "string" || !channel.url) continue;
|
|
185
199
|
|
|
200
|
+
const channelStartedAt = performance.now();
|
|
186
201
|
const videos = channel.videos ?? [];
|
|
187
202
|
const knownDates = forceDates
|
|
188
203
|
? new Map()
|
|
@@ -197,7 +212,17 @@ export async function runExtract({
|
|
|
197
212
|
channel.videos = mergeVideos(videos, fetched.videos, {
|
|
198
213
|
sort: !fetched.playlist,
|
|
199
214
|
});
|
|
215
|
+
|
|
216
|
+
sourceCount += 1;
|
|
217
|
+
videoCount += channel.videos.length;
|
|
218
|
+
console.error(
|
|
219
|
+
` done in ${formatDuration(performance.now() - channelStartedAt)}`,
|
|
220
|
+
);
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
await writeFile(path, stringify(config, { lineWidth: 0 }));
|
|
224
|
+
|
|
225
|
+
console.error(
|
|
226
|
+
`summary: ${count(sourceCount, "source")}, ${count(videoCount, "video")}, ${formatDuration(performance.now() - startedAt)} elapsed`,
|
|
227
|
+
);
|
|
203
228
|
}
|
package/src/generate-markdown.js
CHANGED
|
@@ -275,7 +275,7 @@ function cleanBody(text) {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
async function generateOnce(
|
|
278
|
-
{ model, retryDelays, session, limiter },
|
|
278
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
279
279
|
{ system, prompt, maxOutputTokens, onRetry },
|
|
280
280
|
) {
|
|
281
281
|
const totalAttempts = retryDelays.length + 1;
|
|
@@ -283,8 +283,10 @@ async function generateOnce(
|
|
|
283
283
|
for (let attempt = 1;; attempt++) {
|
|
284
284
|
const started = performance.now();
|
|
285
285
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
let at;
|
|
287
|
+
const call = () => {
|
|
288
|
+
at = new Date();
|
|
289
|
+
return generateText({
|
|
288
290
|
model,
|
|
289
291
|
system,
|
|
290
292
|
prompt,
|
|
@@ -294,12 +296,15 @@ async function generateOnce(
|
|
|
294
296
|
maxRetries: 0,
|
|
295
297
|
headers: { "X-OpenCode-Session": session },
|
|
296
298
|
});
|
|
299
|
+
};
|
|
297
300
|
const { text, usage, finishReason } = await (
|
|
298
301
|
limiter ? limiter(call) : call()
|
|
299
302
|
);
|
|
300
303
|
const elapsed = (performance.now() - started) / 1000;
|
|
304
|
+
const price = priceAt ? priceAt(at) : null;
|
|
305
|
+
const cost = estimateCost(price, usage);
|
|
301
306
|
|
|
302
|
-
return { text, usage, finishReason, elapsed };
|
|
307
|
+
return { text, usage, finishReason, elapsed, price, cost };
|
|
303
308
|
} catch (error) {
|
|
304
309
|
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
305
310
|
throw new Error(describeError(error, attempt), { cause: error });
|
|
@@ -400,6 +405,7 @@ async function toMarkdown(
|
|
|
400
405
|
chunkTargetTokens,
|
|
401
406
|
session,
|
|
402
407
|
limiter,
|
|
408
|
+
priceAt,
|
|
403
409
|
},
|
|
404
410
|
title,
|
|
405
411
|
transcript,
|
|
@@ -423,12 +429,15 @@ async function toMarkdown(
|
|
|
423
429
|
const usages = [];
|
|
424
430
|
let elapsed = 0;
|
|
425
431
|
let finishReason = "stop";
|
|
432
|
+
let cost = 0;
|
|
433
|
+
let costKnown = true;
|
|
434
|
+
let price = null;
|
|
426
435
|
|
|
427
436
|
await Promise.all(
|
|
428
437
|
parts.map(async (part, index) => {
|
|
429
438
|
onProgress?.(index + 1, total);
|
|
430
439
|
const result = await generateOnce(
|
|
431
|
-
{ model, retryDelays, session, limiter },
|
|
440
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
432
441
|
{
|
|
433
442
|
system: SYSTEM,
|
|
434
443
|
prompt: chunkPrompt(title, part, index, total),
|
|
@@ -439,6 +448,9 @@ async function toMarkdown(
|
|
|
439
448
|
bodies[index] = cleanBody(result.text);
|
|
440
449
|
usages.push(result.usage);
|
|
441
450
|
elapsed += result.elapsed;
|
|
451
|
+
if (result.cost === null) costKnown = false;
|
|
452
|
+
else cost += result.cost;
|
|
453
|
+
if (price === null && result.price) price = result.price;
|
|
442
454
|
if (result.finishReason !== "stop" && finishReason === "stop") {
|
|
443
455
|
finishReason = result.finishReason;
|
|
444
456
|
}
|
|
@@ -452,7 +464,7 @@ async function toMarkdown(
|
|
|
452
464
|
if (!previousTail || !nextHead) continue;
|
|
453
465
|
|
|
454
466
|
const smoothing = await generateOnce(
|
|
455
|
-
{ model, retryDelays, session, limiter },
|
|
467
|
+
{ model, retryDelays, session, limiter, priceAt },
|
|
456
468
|
{
|
|
457
469
|
system: SMOOTH_SYSTEM,
|
|
458
470
|
prompt: smoothPrompt(previousTail, nextHead),
|
|
@@ -462,6 +474,9 @@ async function toMarkdown(
|
|
|
462
474
|
);
|
|
463
475
|
usages.push(smoothing.usage);
|
|
464
476
|
elapsed += smoothing.elapsed;
|
|
477
|
+
if (smoothing.cost === null) costKnown = false;
|
|
478
|
+
else cost += smoothing.cost;
|
|
479
|
+
if (price === null && smoothing.price) price = smoothing.price;
|
|
465
480
|
if (smoothing.finishReason === "stop") {
|
|
466
481
|
const rewritten = cleanBody(smoothing.text);
|
|
467
482
|
if (rewritten) {
|
|
@@ -484,6 +499,8 @@ async function toMarkdown(
|
|
|
484
499
|
finishReason,
|
|
485
500
|
elapsed,
|
|
486
501
|
chunks: total,
|
|
502
|
+
cost: costKnown ? cost : null,
|
|
503
|
+
price,
|
|
487
504
|
};
|
|
488
505
|
}
|
|
489
506
|
|
|
@@ -581,6 +598,7 @@ export async function runGenerateMarkdown({
|
|
|
581
598
|
: isSet(modelOutputLimit)
|
|
582
599
|
? Number(modelOutputLimit)
|
|
583
600
|
: DEFAULT_CEILING;
|
|
601
|
+
const priceAt = prices ? (at) => prices.priceAt(modelId, at) : null;
|
|
584
602
|
console.error(
|
|
585
603
|
`output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
|
|
586
604
|
);
|
|
@@ -660,34 +678,34 @@ export async function runGenerateMarkdown({
|
|
|
660
678
|
const name = relative(dir, md);
|
|
661
679
|
try {
|
|
662
680
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
`
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
681
|
+
const { body, usage, finishReason, elapsed, chunks, cost, price } =
|
|
682
|
+
await toMarkdown(
|
|
683
|
+
{
|
|
684
|
+
model,
|
|
685
|
+
retryDelays: RETRY_DELAYS,
|
|
686
|
+
maxOutputTokens,
|
|
687
|
+
ceiling,
|
|
688
|
+
chunkTargetTokens,
|
|
689
|
+
session: videoSession(sessionBase, job),
|
|
690
|
+
limiter,
|
|
691
|
+
priceAt,
|
|
692
|
+
},
|
|
693
|
+
job.title,
|
|
694
|
+
transcript,
|
|
695
|
+
(nextAttempt, totalAttempts, waitMs) => {
|
|
696
|
+
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
697
|
+
`(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
|
|
698
|
+
},
|
|
699
|
+
(part, totalParts) => {
|
|
700
|
+
if (totalParts > 1) {
|
|
701
|
+
task.output = `generating part ${part}/${totalParts} ${name}`;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
);
|
|
686
705
|
|
|
687
706
|
stats.totalSeconds += elapsed;
|
|
688
707
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
689
708
|
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
690
|
-
const cost = estimateCost(price, usage);
|
|
691
709
|
if (cost === null) stats.costKnown = false;
|
|
692
710
|
else stats.totalCost += cost;
|
|
693
711
|
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
@@ -748,6 +766,7 @@ export {
|
|
|
748
766
|
chunkPrompt,
|
|
749
767
|
cleanBody,
|
|
750
768
|
createLimiter,
|
|
769
|
+
estimateCost,
|
|
751
770
|
plannedBudget,
|
|
752
771
|
smoothPrompt,
|
|
753
772
|
sumUsage,
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function formatDuration(ms) {
|
|
2
|
+
const value = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
3
|
+
if (value < 1000) return `${Math.round(value)}ms`;
|
|
4
|
+
|
|
5
|
+
const seconds = value / 1000;
|
|
6
|
+
if (seconds < 59.95) return `${seconds.toFixed(1)}s`;
|
|
7
|
+
|
|
8
|
+
const rounded = Math.round(seconds);
|
|
9
|
+
const minutes = Math.floor(rounded / 60);
|
|
10
|
+
if (minutes < 60) return `${minutes}m ${String(rounded % 60).padStart(2, "0")}s`;
|
|
11
|
+
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function progressStats({ done, total, startedAt, now = performance.now() }) {
|
|
17
|
+
const elapsed = Math.max(0, now - startedAt);
|
|
18
|
+
const completed = Math.min(Math.max(done, 0), Math.max(total, 0));
|
|
19
|
+
|
|
20
|
+
if (completed === 0 || total <= 0) return { elapsed, eta: null };
|
|
21
|
+
|
|
22
|
+
return { elapsed, eta: (elapsed * (total - completed)) / completed };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function progressLine({ label, done, total, startedAt, now }) {
|
|
26
|
+
const { elapsed, eta } = progressStats({ done, total, startedAt, now });
|
|
27
|
+
const parts = [` ${label} ${done}/${total}`, `${formatDuration(elapsed)} elapsed`];
|
|
28
|
+
|
|
29
|
+
if (eta !== null && eta > 0) parts.push(`~${formatDuration(eta)} left`);
|
|
30
|
+
|
|
31
|
+
return parts.join(" ");
|
|
32
|
+
}
|