@stephane-klein/youtube-to-markdown 0.2.0 → 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 +80 -20
- package/package.json +3 -2
- package/src/chunk.js +68 -0
- package/src/cli.js +6 -1
- package/src/config.js +2 -0
- package/src/doctor.js +28 -1
- package/src/extract-video-metadata.js +48 -21
- package/src/generate-markdown.js +315 -39
- package/src/organize-files.js +3 -1
- package/src/pricing.js +185 -0
- package/src/vtt.js +3 -1
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ I use it to search through the content of popular-science videos and shows of
|
|
|
7
7
|
all kinds. I also use it to feed that corpus into a RAG.
|
|
8
8
|
|
|
9
9
|
Technically, it is fairly simple: the project relies on
|
|
10
|
-
[yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch a YouTube
|
|
11
|
-
and then to download its transcripts.
|
|
10
|
+
[yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch the metadata of a YouTube
|
|
11
|
+
channel or playlist and then to download its transcripts.
|
|
12
12
|
|
|
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.
|
|
@@ -46,8 +46,8 @@ $ youtube-to-markdown --help
|
|
|
46
46
|
youtube-to-markdown <command>
|
|
47
47
|
|
|
48
48
|
Commands:
|
|
49
|
-
extract-video-metadata Fetch the
|
|
50
|
-
configuration file
|
|
49
|
+
extract-video-metadata Fetch the videos of each feed source (channel or
|
|
50
|
+
playlist) from YouTube into the configuration file
|
|
51
51
|
download-vtt Download the VTT transcripts of the marked videos
|
|
52
52
|
generate-markdown Turn the VTT transcripts into Markdown prose with an
|
|
53
53
|
LLM
|
|
@@ -75,7 +75,7 @@ Use `~/.bashrc` on bash.
|
|
|
75
75
|
|
|
76
76
|
## Getting started
|
|
77
77
|
|
|
78
|
-
**1. Create `youtube_to_markdown.yaml`** with the LLM settings and your
|
|
78
|
+
**1. Create `youtube_to_markdown.yaml`** with the LLM settings and your sources.
|
|
79
79
|
The example below uses [OpenCode Go](https://opencode.ai/en/go), an OpenAI-compatible API:
|
|
80
80
|
|
|
81
81
|
```yaml
|
|
@@ -115,15 +115,16 @@ model mimo-v2.5 ok
|
|
|
115
115
|
endpoint https://opencode.ai/zen/go/v1/chat/completions ok
|
|
116
116
|
api key OPENAI_API_KEY ok
|
|
117
117
|
session set info
|
|
118
|
+
pricing models.dev ok ($0.14 in / $0.28 out / $0.0028 cached per 1M)
|
|
118
119
|
llm 1.0s ok (248 in / 8 out)
|
|
119
120
|
|
|
120
|
-
summary:
|
|
121
|
+
summary: 9 ok, 0 warning, 0 error
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
`doctor` exits with a non-zero status as soon as one line is an `error`, so it
|
|
124
125
|
can gate a CI job.
|
|
125
126
|
|
|
126
|
-
**4. Fetch the
|
|
127
|
+
**4. Fetch the source videos** into the file:
|
|
127
128
|
|
|
128
129
|
```sh
|
|
129
130
|
$ youtube-to-markdown extract-video-metadata
|
|
@@ -137,21 +138,22 @@ The command is idempotent: a new run only adds the missing videos.
|
|
|
137
138
|
other commands are `markdown-stats`, `reorganize` and `doctor`; run
|
|
138
139
|
`youtube-to-markdown <command> --help` for their options.
|
|
139
140
|
|
|
140
|
-
## Configure the settings and the
|
|
141
|
+
## Configure the settings and the sources
|
|
141
142
|
|
|
142
143
|
The commands read `youtube_to_markdown.yaml` from the current directory; override
|
|
143
|
-
it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the
|
|
144
|
+
it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the sources.
|
|
144
145
|
Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
|
|
145
146
|
variables, then the settings written in the YAML file, then the built-in
|
|
146
147
|
defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
|
|
147
148
|
settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
|
|
148
149
|
`concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
|
|
149
|
-
`retry_delays` and `
|
|
150
|
-
`extract-video-metadata` command fills the rest:
|
|
150
|
+
`retry_delays`, `max_output_tokens` and `chunk_target_tokens`). I only set the
|
|
151
|
+
source URLs, and the `extract-video-metadata` command fills the rest:
|
|
151
152
|
|
|
152
|
-
`x_opencode_session` is
|
|
153
|
-
and specific to OpenCode.
|
|
154
|
-
|
|
153
|
+
`x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
|
|
154
|
+
optional and specific to OpenCode. A stable per-video suffix is appended to it,
|
|
155
|
+
so each video is routed as its own conversation. `model_id` and
|
|
156
|
+
`openaiapi_endpoint` are not: point them at any OpenAI-compatible provider.
|
|
155
157
|
|
|
156
158
|
```yaml
|
|
157
159
|
x_opencode_session: "youtube-to-markdown/0.1"
|
|
@@ -181,13 +183,35 @@ other settings, it can only be set in the YAML file — there is no flag or
|
|
|
181
183
|
environment variable for it. `.gitignore` only ignores the default `contents/`,
|
|
182
184
|
so add your own path there if you move it.
|
|
183
185
|
|
|
184
|
-
`folder_slug` (optional, on a
|
|
186
|
+
`folder_slug` (optional, on a feed entry) stores that source's transcripts and
|
|
185
187
|
Markdown in a subdirectory of `contents_path`, for example `science-4-all`.
|
|
186
|
-
It is a relative path, so it stays inside `contents_path`; a
|
|
188
|
+
It is a relative path, so it stays inside `contents_path`; a source without
|
|
187
189
|
`folder_slug` keeps its files at the root of `contents_path`. The reserved name
|
|
188
190
|
`_orphans` is rejected. Change it whenever you like, then run
|
|
189
191
|
`youtube-to-markdown reorganize` to move the existing files.
|
|
190
192
|
|
|
193
|
+
## Playlists
|
|
194
|
+
|
|
195
|
+
A feed entry can point to a YouTube playlist instead of a channel:
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
feed:
|
|
199
|
+
- title: Contre-histoire de la philosophie, vol. 9
|
|
200
|
+
folder_slug: contre-histoire-philo-9
|
|
201
|
+
url: https://www.youtube.com/playlist?list=OLAK5uy_lEyjyS840R2OkHNVgOt1fGjPzeBTs1xKI
|
|
202
|
+
videos: []
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The commands detect a playlist from its URL (the `/playlist` path);
|
|
206
|
+
`extract-video-metadata` uses it as is instead of resolving the channel uploads
|
|
207
|
+
playlist. `title` is filled with the playlist title when it is empty, and the
|
|
208
|
+
videos keep the playlist order instead of being sorted by date. Each video gets
|
|
209
|
+
an `index` field (`1`, `2`, …) which becomes the filename prefix:
|
|
210
|
+
`contents/contre-histoire-philo-9/1-2019-01-04_quel-xixeme-siecle-i-un-temps-nouveau.fr.vtt`.
|
|
211
|
+
The `index` field is written by `extract-video-metadata`; do not edit it by
|
|
212
|
+
hand. A video removed from the playlist is kept as an extra, without `index`,
|
|
213
|
+
at the end of the list.
|
|
214
|
+
|
|
191
215
|
## Download the transcripts of the videos
|
|
192
216
|
|
|
193
217
|
I mark the videos I want as transcripts with a `download_vtt` field:
|
|
@@ -278,6 +302,25 @@ from the server is honored when longer than the schedule. Each retry is shown in
|
|
|
278
302
|
the phase output; after the last attempt the file is reported as an error and is
|
|
279
303
|
retried on the next run.
|
|
280
304
|
|
|
305
|
+
### Output budget and long transcripts
|
|
306
|
+
|
|
307
|
+
The model must rewrite the whole transcript, so the command asks it for roughly
|
|
308
|
+
twice the input size (`maxOutputTokens`). That budget is capped per call: the
|
|
309
|
+
command reads the model's advertised output limit from the pricing catalogs and
|
|
310
|
+
uses it as the ceiling; if the catalog has no entry it falls back to 32768
|
|
311
|
+
tokens. `--max-output-tokens` (or `YT_TO_MD_MAX_OUTPUT_TOKENS`) overrides the
|
|
312
|
+
ceiling by hand.
|
|
313
|
+
|
|
314
|
+
When the projected output would exceed the ceiling — a long video on a model
|
|
315
|
+
with a small output limit — the transcript is split into chunks that each fit
|
|
316
|
+
the budget, generated independently, then stitched together: immediately
|
|
317
|
+
repeated headings are dropped and each junction is smoothed by a small extra
|
|
318
|
+
LLM call that rewrites the beginning of the next section so it flows from the
|
|
319
|
+
previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
|
|
320
|
+
default 8000) sets the target input size of each chunk. The generation of a
|
|
321
|
+
chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
|
|
322
|
+
error and nothing is written. The frontmatter records how many chunks were used.
|
|
323
|
+
|
|
281
324
|
### Generated frontmatter
|
|
282
325
|
|
|
283
326
|
Each generated file starts with a YAML frontmatter describing the run:
|
|
@@ -289,20 +332,37 @@ video_title: Pourquoi π est-il si fou ? Relativité 1
|
|
|
289
332
|
generated_at: 2026-09-19T10:12:33.456Z
|
|
290
333
|
llm:
|
|
291
334
|
model: mimo-v2.5
|
|
335
|
+
chunks: 1
|
|
292
336
|
duration_seconds: 87.1
|
|
293
337
|
input_tokens: 4428
|
|
294
338
|
cached_input_tokens: 0
|
|
295
339
|
output_tokens: 3262
|
|
296
340
|
estimated_cost_usd: 0.000927
|
|
297
341
|
finish_reason: stop
|
|
342
|
+
pricing:
|
|
343
|
+
source: models.dev
|
|
344
|
+
peak: null
|
|
298
345
|
---
|
|
299
346
|
```
|
|
300
347
|
|
|
301
348
|
`video_title` is the original video title in the file's language and
|
|
302
|
-
`generated_at` is the UTC time of the generation. `
|
|
303
|
-
|
|
304
|
-
`
|
|
305
|
-
|
|
349
|
+
`generated_at` is the UTC time of the generation. `pricing.source` names the
|
|
350
|
+
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). Existing files
|
|
352
|
+
are left untouched: run `youtube-to-markdown generate-markdown --force` to
|
|
353
|
+
regenerate every file, which is also the way to add the frontmatter to files
|
|
354
|
+
generated before it existed.
|
|
355
|
+
|
|
356
|
+
### Pricing
|
|
357
|
+
|
|
358
|
+
The estimated cost uses rates fetched at run time from
|
|
359
|
+
[models.dev](https://models.dev) and
|
|
360
|
+
[LiteLLM](https://github.com/BerriAI/litellm). The provider is resolved from
|
|
361
|
+
`openaiapi_endpoint`; LiteLLM is preferred when it prices the model, because it
|
|
362
|
+
carries DeepSeek's peak/off-peak windows, otherwise models.dev is used. Both
|
|
363
|
+
catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
|
|
364
|
+
(`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
|
|
365
|
+
unavailable, `estimated_cost_usd` is `null` and the run continues.
|
|
306
366
|
|
|
307
367
|
## Organize the files into folders
|
|
308
368
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"youtube-to-markdown": "node src/cli.js"
|
|
25
|
+
"youtube-to-markdown": "node src/cli.js",
|
|
26
|
+
"test": "node --test"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"@ai-sdk/openai-compatible": "~3.0.51",
|
package/src/chunk.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const CHARS_PER_TOKEN = 3.5;
|
|
2
|
+
|
|
3
|
+
export function estimateTokens(text) {
|
|
4
|
+
return Math.ceil(`${text ?? ""}`.length / CHARS_PER_TOKEN);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function chunkTranscript(transcript, { targetTokens }) {
|
|
8
|
+
const target = Math.max(1, Math.floor(Number(targetTokens) || 1));
|
|
9
|
+
const lines = `${transcript ?? ""}`.split("\n");
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let current = [];
|
|
12
|
+
let currentTokens = 0;
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const lineTokens = estimateTokens(line) + 1;
|
|
16
|
+
if (current.length > 0 && currentTokens + lineTokens > target) {
|
|
17
|
+
chunks.push(current.join("\n"));
|
|
18
|
+
current = [];
|
|
19
|
+
currentTokens = 0;
|
|
20
|
+
}
|
|
21
|
+
current.push(line);
|
|
22
|
+
currentTokens += lineTokens;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (current.length > 0) chunks.push(current.join("\n"));
|
|
26
|
+
return chunks.length > 0 ? chunks : [""];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function splitBlocks(markdown) {
|
|
30
|
+
return `${markdown ?? ""}`.split(/\n{2,}/);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function firstBlock(markdown) {
|
|
34
|
+
return splitBlocks(markdown)[0]?.trim() ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lastBlock(markdown) {
|
|
38
|
+
const blocks = splitBlocks(markdown).filter((block) => block.trim());
|
|
39
|
+
return blocks[blocks.length - 1]?.trim() ?? "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function replaceFirstBlock(markdown, replacement) {
|
|
43
|
+
const blocks = splitBlocks(markdown);
|
|
44
|
+
if (blocks.length === 0) return replacement;
|
|
45
|
+
blocks[0] = replacement;
|
|
46
|
+
return blocks.join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function dedupeAdjacentHeadings(markdown) {
|
|
50
|
+
const lines = `${markdown ?? ""}`.split("\n");
|
|
51
|
+
const out = [];
|
|
52
|
+
let lastHeading = null;
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const heading = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
|
56
|
+
if (heading) {
|
|
57
|
+
const key = heading[1].trim().toLowerCase();
|
|
58
|
+
if (key === lastHeading) continue;
|
|
59
|
+
lastHeading = key;
|
|
60
|
+
out.push(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (line.trim() !== "") lastHeading = null;
|
|
64
|
+
out.push(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
68
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ const SETTING_KEYS = [
|
|
|
23
23
|
"ytdlpConcurrency",
|
|
24
24
|
"retryDelays",
|
|
25
25
|
"maxOutputTokens",
|
|
26
|
+
"chunkTargetTokens",
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
const handle = (run) => async (argv) => {
|
|
@@ -57,7 +58,7 @@ const cli = yargs(hideBin(process.argv))
|
|
|
57
58
|
.option("config", config)
|
|
58
59
|
.command(
|
|
59
60
|
"extract-video-metadata",
|
|
60
|
-
"Fetch the channel
|
|
61
|
+
"Fetch the videos of each feed source (channel or playlist) from YouTube into the configuration file",
|
|
61
62
|
(yargs) =>
|
|
62
63
|
yargs
|
|
63
64
|
.option("force-dates", {
|
|
@@ -116,6 +117,10 @@ const cli = yargs(hideBin(process.argv))
|
|
|
116
117
|
describe: "Override the computed output token budget",
|
|
117
118
|
type: "number",
|
|
118
119
|
})
|
|
120
|
+
.option("chunk-target-tokens", {
|
|
121
|
+
describe: "Target input tokens per chunk when a transcript is split (default: 8000)",
|
|
122
|
+
type: "number",
|
|
123
|
+
})
|
|
119
124
|
.option("force", {
|
|
120
125
|
describe: "Regenerate every Markdown file",
|
|
121
126
|
type: "boolean",
|
package/src/config.js
CHANGED
|
@@ -14,6 +14,7 @@ const YAML_KEYS = {
|
|
|
14
14
|
ytdlpConcurrency: "ytdlp_concurrency",
|
|
15
15
|
retryDelays: "retry_delays",
|
|
16
16
|
maxOutputTokens: "max_output_tokens",
|
|
17
|
+
chunkTargetTokens: "chunk_target_tokens",
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function settingsFromConfig(config) {
|
|
@@ -34,6 +35,7 @@ const ENV = {
|
|
|
34
35
|
ytdlpConcurrency: "YT_TO_MD_YTDLP_CONCURRENCY",
|
|
35
36
|
retryDelays: "YT_TO_MD_RETRY_DELAYS",
|
|
36
37
|
maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
|
|
38
|
+
chunkTargetTokens: "YT_TO_MD_CHUNK_TARGET_TOKENS",
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
export function loadEnv(env = process.env) {
|
package/src/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { generateText } from "ai";
|
|
4
4
|
import { loadConfig, settingsFromConfig } from "./config.js";
|
|
5
5
|
import { createModel, describeError } from "./generate-markdown.js";
|
|
6
|
+
import { loadPricing } from "./pricing.js";
|
|
6
7
|
import { exists } from "./vtt.js";
|
|
7
8
|
import { runYtDlp } from "./ytdlp.js";
|
|
8
9
|
|
|
@@ -86,7 +87,7 @@ async function loadConfigSafe(configPath) {
|
|
|
86
87
|
row: {
|
|
87
88
|
status: "ok",
|
|
88
89
|
value: configPath,
|
|
89
|
-
note: `${channels}
|
|
90
|
+
note: `${channels} source(s), ${videos} video(s)`,
|
|
90
91
|
},
|
|
91
92
|
};
|
|
92
93
|
} catch (error) {
|
|
@@ -141,6 +142,31 @@ async function checkLlm({ llm, settings, key, timeoutMs }) {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
async function checkPricing({ settings }) {
|
|
146
|
+
if (!settings.endpoint) {
|
|
147
|
+
return { status: "warn", value: "-", note: "endpoint not set" };
|
|
148
|
+
}
|
|
149
|
+
if (!settings.model) {
|
|
150
|
+
return { status: "warn", value: "-", note: "model not set" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const pricing = await loadPricing({ endpoint: settings.endpoint });
|
|
154
|
+
if (!pricing) return { status: "warn", value: "-", note: "no pricing source" };
|
|
155
|
+
|
|
156
|
+
const price = pricing.priceAt(settings.model);
|
|
157
|
+
if (!price) {
|
|
158
|
+
return { status: "warn", value: pricing.provider, note: "model not priced" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const peak = price.peak === null ? "" : price.peak ? " (peak)" : " (off-peak)";
|
|
162
|
+
const usd = (value) => (typeof value === "number" ? `$${value}` : "-");
|
|
163
|
+
return {
|
|
164
|
+
status: "ok",
|
|
165
|
+
value: `${price.source}${peak}`,
|
|
166
|
+
note: `${usd(price.input)} in / ${usd(price.output)} out / ${usd(price.cachedInput)} cached per 1M`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
144
170
|
function render(rows) {
|
|
145
171
|
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
146
172
|
const valueWidth = Math.max(...rows.map((row) => row.value.length));
|
|
@@ -192,6 +218,7 @@ export async function runDoctor({
|
|
|
192
218
|
if (settings.session) {
|
|
193
219
|
rows.push({ label: "session", value: "set", status: "info", note: null });
|
|
194
220
|
}
|
|
221
|
+
rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
|
|
195
222
|
const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
|
|
196
223
|
rows.push({ label: "llm", ...llmRow });
|
|
197
224
|
|
|
@@ -32,11 +32,19 @@ async function uploadsPlaylistUrl(channelUrl) {
|
|
|
32
32
|
return `https://www.youtube.com/playlist?list=UU${channelId.slice(2)}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const isPlaylistUrl = (url) => {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(url).pathname === "/playlist";
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
35
43
|
async function listEntries(playlistUrl, lang) {
|
|
36
44
|
const args = [
|
|
37
45
|
"--flat-playlist",
|
|
38
46
|
"-O",
|
|
39
|
-
'{"titre": %(title)j, "url": %(webpage_url)j}',
|
|
47
|
+
'{"titre": %(title)j, "url": %(webpage_url)j, "playlist": %(playlist_title)j, "index": %(playlist_index)j}',
|
|
40
48
|
"--extractor-args",
|
|
41
49
|
`youtube:lang=${lang}`,
|
|
42
50
|
];
|
|
@@ -81,8 +89,9 @@ async function fetchExactDates(urls) {
|
|
|
81
89
|
return dates;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
async function fetchVideos(
|
|
85
|
-
const
|
|
92
|
+
async function fetchVideos(sourceUrl, knownDates) {
|
|
93
|
+
const playlist = isPlaylistUrl(sourceUrl);
|
|
94
|
+
const playlistUrl = playlist ? sourceUrl : await uploadsPlaylistUrl(sourceUrl);
|
|
86
95
|
const en = await listEntries(playlistUrl, "en");
|
|
87
96
|
const fr = await listEntries(playlistUrl, "fr");
|
|
88
97
|
const frTitles = new Map(fr.map((entry) => [entry.url, entry.titre]));
|
|
@@ -92,11 +101,15 @@ async function fetchVideos(channelUrl, knownDates) {
|
|
|
92
101
|
.map((entry) => {
|
|
93
102
|
const enTitle = entry.titre.trim();
|
|
94
103
|
const frTitle = (frTitles.get(entry.url) ?? enTitle).trim();
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
const video = {
|
|
105
|
+
title:
|
|
106
|
+
frTitle && enTitle && frTitle !== enTitle
|
|
107
|
+
? { fr: frTitle, en: enTitle }
|
|
108
|
+
: { fr: frTitle || enTitle },
|
|
109
|
+
url: entry.url,
|
|
110
|
+
};
|
|
111
|
+
if (playlist && Number.isInteger(entry.index)) video.index = entry.index;
|
|
112
|
+
return video;
|
|
100
113
|
});
|
|
101
114
|
|
|
102
115
|
const missing = videos
|
|
@@ -109,9 +122,20 @@ async function fetchVideos(channelUrl, knownDates) {
|
|
|
109
122
|
video.date = knownDates.get(id) ?? fetched.get(id) ?? "";
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
.
|
|
114
|
-
.reverse();
|
|
125
|
+
const ordered = playlist
|
|
126
|
+
? [...videos].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
127
|
+
: [...videos].reverse();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
title: playlist ? (en[0]?.playlist ?? "") : "",
|
|
131
|
+
playlist,
|
|
132
|
+
videos: ordered.map((video) => {
|
|
133
|
+
const fields = { title: video.title, date: video.date, url: video.url };
|
|
134
|
+
return video.index !== undefined
|
|
135
|
+
? { index: video.index, ...fields }
|
|
136
|
+
: fields;
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
function normalizeEntry(entry) {
|
|
@@ -122,7 +146,7 @@ function normalizeEntry(entry) {
|
|
|
122
146
|
return entry;
|
|
123
147
|
}
|
|
124
148
|
|
|
125
|
-
const MANAGED_KEYS = new Set(["title", "date", "url", "titre"]);
|
|
149
|
+
const MANAGED_KEYS = new Set(["title", "date", "url", "titre", "index"]);
|
|
126
150
|
|
|
127
151
|
function userFields(entry) {
|
|
128
152
|
return Object.fromEntries(
|
|
@@ -130,7 +154,7 @@ function userFields(entry) {
|
|
|
130
154
|
);
|
|
131
155
|
}
|
|
132
156
|
|
|
133
|
-
function mergeVideos(existing, fetched) {
|
|
157
|
+
function mergeVideos(existing, fetched, { sort = true } = {}) {
|
|
134
158
|
const existingByUrl = new Map(
|
|
135
159
|
existing.filter((v) => v.url).map((v) => [v.url, v]),
|
|
136
160
|
);
|
|
@@ -143,10 +167,12 @@ function mergeVideos(existing, fetched) {
|
|
|
143
167
|
const fetchedUrls = new Set(fetched.map((v) => v.url));
|
|
144
168
|
const extras = existing
|
|
145
169
|
.filter((v) => v.url && !fetchedUrls.has(v.url))
|
|
146
|
-
.map(normalizeEntry)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
170
|
+
.map(normalizeEntry)
|
|
171
|
+
.map(({ index, ...video }) => video);
|
|
172
|
+
const all = [...merged, ...extras];
|
|
173
|
+
return sort
|
|
174
|
+
? all.sort((a, b) => (a.date ?? "").localeCompare(b.date ?? ""))
|
|
175
|
+
: all;
|
|
150
176
|
}
|
|
151
177
|
|
|
152
178
|
export async function runExtract({
|
|
@@ -166,10 +192,11 @@ export async function runExtract({
|
|
|
166
192
|
.map((video) => [videoId(video.url), video.date]),
|
|
167
193
|
);
|
|
168
194
|
console.error(`→ ${channel.url}`);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
const fetched = await fetchVideos(channel.url, knownDates);
|
|
196
|
+
if (fetched.title && !channel.title) channel.title = fetched.title;
|
|
197
|
+
channel.videos = mergeVideos(videos, fetched.videos, {
|
|
198
|
+
sort: !fetched.playlist,
|
|
199
|
+
});
|
|
173
200
|
}
|
|
174
201
|
|
|
175
202
|
await writeFile(path, stringify(config, { lineWidth: 0 }));
|
package/src/generate-markdown.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname, relative } from "node:path";
|
|
3
4
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
4
5
|
import { APICallError, generateText } from "ai";
|
|
5
6
|
import { Listr, ListrLogger, ProcessOutput } from "listr2";
|
|
6
7
|
import { stringify } from "yaml";
|
|
8
|
+
import {
|
|
9
|
+
chunkTranscript,
|
|
10
|
+
dedupeAdjacentHeadings,
|
|
11
|
+
estimateTokens,
|
|
12
|
+
firstBlock,
|
|
13
|
+
lastBlock,
|
|
14
|
+
replaceFirstBlock,
|
|
15
|
+
} from "./chunk.js";
|
|
16
|
+
import { loadPricing } from "./pricing.js";
|
|
7
17
|
import {
|
|
8
18
|
DEFAULT_DIR,
|
|
9
19
|
downloadVtt,
|
|
@@ -24,19 +34,23 @@ const SYSTEM = [
|
|
|
24
34
|
"- Output only the Markdown body.",
|
|
25
35
|
].join("\n");
|
|
26
36
|
|
|
27
|
-
const
|
|
37
|
+
const SMOOTH_SYSTEM = [
|
|
38
|
+
"You improve the transition between two consecutive Markdown sections that were written independently.",
|
|
39
|
+
"- You are given the end of the previous section and the beginning of the next one.",
|
|
40
|
+
"- Rewrite ONLY the beginning of the next section so it flows naturally from the previous one.",
|
|
41
|
+
"- Keep the same language, the same facts and roughly the same length.",
|
|
42
|
+
"- Keep the Markdown formatting of the beginning.",
|
|
43
|
+
"- Output only the rewritten beginning, without any preamble or code fences.",
|
|
44
|
+
].join("\n");
|
|
28
45
|
|
|
29
|
-
const
|
|
30
|
-
"mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
|
|
31
|
-
};
|
|
46
|
+
const WIDTH = 80;
|
|
32
47
|
|
|
33
48
|
function cachedInputTokens(usage) {
|
|
34
49
|
const details = usage?.inputTokenDetails ?? {};
|
|
35
50
|
return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
function estimateCost(
|
|
39
|
-
const price = PRICES[modelId];
|
|
53
|
+
function estimateCost(price, usage) {
|
|
40
54
|
if (!price || !usage) return null;
|
|
41
55
|
|
|
42
56
|
const details = usage.inputTokenDetails ?? {};
|
|
@@ -53,20 +67,26 @@ function estimateCost(modelId, usage) {
|
|
|
53
67
|
|
|
54
68
|
const OUTPUT_FACTOR = 2;
|
|
55
69
|
const OUTPUT_FLOOR = 2048;
|
|
56
|
-
const
|
|
70
|
+
const DEFAULT_CEILING = 32768;
|
|
71
|
+
const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
|
|
57
72
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
73
|
+
function isSet(value) {
|
|
74
|
+
return value !== undefined && value !== null && value !== "";
|
|
75
|
+
}
|
|
62
76
|
|
|
63
|
-
|
|
77
|
+
function plannedBudget(transcript, ceiling) {
|
|
78
|
+
const inputTokens = estimateTokens(transcript);
|
|
64
79
|
return Math.min(
|
|
65
|
-
|
|
80
|
+
ceiling,
|
|
66
81
|
Math.max(OUTPUT_FLOOR, Math.ceil(inputTokens * OUTPUT_FACTOR)),
|
|
67
82
|
);
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
function budgetForPart(text, { maxOutputTokens, ceiling }) {
|
|
86
|
+
if (isSet(maxOutputTokens)) return Number(maxOutputTokens);
|
|
87
|
+
return plannedBudget(text, ceiling);
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
function metricsLine(modelId, usage, elapsed, cost) {
|
|
71
91
|
return [
|
|
72
92
|
modelId,
|
|
@@ -80,19 +100,21 @@ function metricsLine(modelId, usage, elapsed, cost) {
|
|
|
80
100
|
.join(" ");
|
|
81
101
|
}
|
|
82
102
|
|
|
83
|
-
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
|
|
103
|
+
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price, chunks }) {
|
|
84
104
|
const data = {
|
|
85
105
|
source_url: job.url,
|
|
86
106
|
video_title: titleForLang(job.titles, job.lang),
|
|
87
107
|
generated_at: new Date().toISOString(),
|
|
88
108
|
llm: {
|
|
89
109
|
model: modelId,
|
|
110
|
+
chunks: chunks ?? 1,
|
|
90
111
|
duration_seconds: Number(elapsed.toFixed(1)),
|
|
91
112
|
input_tokens: usage?.inputTokens ?? null,
|
|
92
113
|
cached_input_tokens: usage ? cachedInputTokens(usage) : null,
|
|
93
114
|
output_tokens: usage?.outputTokens ?? null,
|
|
94
115
|
estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
|
|
95
116
|
finish_reason: finishReason,
|
|
117
|
+
pricing: price ? { source: price.source, peak: price.peak } : null,
|
|
96
118
|
},
|
|
97
119
|
};
|
|
98
120
|
|
|
@@ -201,44 +223,83 @@ function parseRetryDelays(value) {
|
|
|
201
223
|
.filter((item) => Number.isFinite(item) && item >= 0);
|
|
202
224
|
}
|
|
203
225
|
|
|
226
|
+
function convertUsage(usage) {
|
|
227
|
+
const prompt = usage?.prompt_tokens ?? 0;
|
|
228
|
+
const completion = usage?.completion_tokens ?? 0;
|
|
229
|
+
const cacheRead =
|
|
230
|
+
usage?.prompt_cache_hit_tokens ??
|
|
231
|
+
usage?.prompt_tokens_details?.cached_tokens ??
|
|
232
|
+
0;
|
|
233
|
+
const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0;
|
|
234
|
+
return {
|
|
235
|
+
inputTokens: {
|
|
236
|
+
total: prompt,
|
|
237
|
+
noCache: Math.max(prompt - cacheRead, 0),
|
|
238
|
+
cacheRead,
|
|
239
|
+
cacheWrite: undefined,
|
|
240
|
+
},
|
|
241
|
+
outputTokens: {
|
|
242
|
+
total: completion,
|
|
243
|
+
text: Math.max(completion - reasoning, 0),
|
|
244
|
+
reasoning,
|
|
245
|
+
},
|
|
246
|
+
raw: usage,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
204
250
|
export function createModel({ modelId, endpoint, apiKey, session }) {
|
|
205
251
|
const provider = createOpenAICompatible({
|
|
206
252
|
name: "opencode",
|
|
207
253
|
apiKey,
|
|
208
254
|
baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
|
|
209
255
|
headers: session ? { "X-OpenCode-Session": session } : {},
|
|
256
|
+
convertUsage,
|
|
210
257
|
});
|
|
211
258
|
return provider(modelId);
|
|
212
259
|
}
|
|
213
260
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
261
|
+
function videoSession(base, job) {
|
|
262
|
+
const id = createHash("sha256")
|
|
263
|
+
.update(`${job.url}#${job.lang}`)
|
|
264
|
+
.digest("hex")
|
|
265
|
+
.slice(0, 16);
|
|
266
|
+
return `${base || "youtube-to-markdown"}-${id}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function cleanBody(text) {
|
|
270
|
+
return text
|
|
271
|
+
.trim()
|
|
272
|
+
.replace(/^```[a-z]*\n?/, "")
|
|
273
|
+
.replace(/\n?```$/, "")
|
|
274
|
+
.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function generateOnce(
|
|
278
|
+
{ model, retryDelays, session, limiter },
|
|
279
|
+
{ system, prompt, maxOutputTokens, onRetry },
|
|
219
280
|
) {
|
|
220
281
|
const totalAttempts = retryDelays.length + 1;
|
|
221
282
|
|
|
222
283
|
for (let attempt = 1;; attempt++) {
|
|
223
284
|
const started = performance.now();
|
|
224
285
|
try {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
286
|
+
const call = () =>
|
|
287
|
+
generateText({
|
|
288
|
+
model,
|
|
289
|
+
system,
|
|
290
|
+
prompt,
|
|
291
|
+
temperature: 0.2,
|
|
292
|
+
reasoning: "low",
|
|
293
|
+
maxOutputTokens,
|
|
294
|
+
maxRetries: 0,
|
|
295
|
+
headers: { "X-OpenCode-Session": session },
|
|
296
|
+
});
|
|
297
|
+
const { text, usage, finishReason } = await (
|
|
298
|
+
limiter ? limiter(call) : call()
|
|
299
|
+
);
|
|
233
300
|
const elapsed = (performance.now() - started) / 1000;
|
|
234
301
|
|
|
235
|
-
|
|
236
|
-
.trim()
|
|
237
|
-
.replace(/^```[a-z]*\n?/, "")
|
|
238
|
-
.replace(/\n?```$/, "")
|
|
239
|
-
.trim();
|
|
240
|
-
|
|
241
|
-
return { body, usage, finishReason, elapsed };
|
|
302
|
+
return { text, usage, finishReason, elapsed };
|
|
242
303
|
} catch (error) {
|
|
243
304
|
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
244
305
|
throw new Error(describeError(error, attempt), { cause: error });
|
|
@@ -250,6 +311,182 @@ async function toMarkdown(
|
|
|
250
311
|
}
|
|
251
312
|
}
|
|
252
313
|
|
|
314
|
+
function chunkPrompt(title, transcript, index, total) {
|
|
315
|
+
if (total <= 1) return `Title: ${title}\n\nTranscript:\n${transcript}`;
|
|
316
|
+
return [
|
|
317
|
+
`Title: ${title}`,
|
|
318
|
+
"",
|
|
319
|
+
`This is part ${index + 1} of ${total} of the transcript.`,
|
|
320
|
+
"Output only the Markdown body for this part, without the main title.",
|
|
321
|
+
"",
|
|
322
|
+
`Transcript (part ${index + 1}/${total}):`,
|
|
323
|
+
transcript,
|
|
324
|
+
].join("\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function smoothPrompt(previousTail, nextHead) {
|
|
328
|
+
return [
|
|
329
|
+
"End of the previous section:",
|
|
330
|
+
"",
|
|
331
|
+
previousTail,
|
|
332
|
+
"",
|
|
333
|
+
"Beginning of the next section:",
|
|
334
|
+
"",
|
|
335
|
+
nextHead,
|
|
336
|
+
].join("\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sumUsage(usages) {
|
|
340
|
+
const present = usages.filter(Boolean);
|
|
341
|
+
if (present.length === 0) return null;
|
|
342
|
+
|
|
343
|
+
const total = {
|
|
344
|
+
inputTokens: 0,
|
|
345
|
+
outputTokens: 0,
|
|
346
|
+
totalTokens: 0,
|
|
347
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0 },
|
|
348
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
for (const usage of present) {
|
|
352
|
+
total.inputTokens += usage.inputTokens ?? 0;
|
|
353
|
+
total.outputTokens += usage.outputTokens ?? 0;
|
|
354
|
+
total.totalTokens += usage.totalTokens ?? 0;
|
|
355
|
+
total.inputTokenDetails.noCacheTokens +=
|
|
356
|
+
usage.inputTokenDetails?.noCacheTokens ?? 0;
|
|
357
|
+
total.inputTokenDetails.cacheReadTokens +=
|
|
358
|
+
usage.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
359
|
+
total.outputTokenDetails.textTokens +=
|
|
360
|
+
usage.outputTokenDetails?.textTokens ?? 0;
|
|
361
|
+
total.outputTokenDetails.reasoningTokens +=
|
|
362
|
+
usage.outputTokenDetails?.reasoningTokens ?? 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return total;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function createLimiter(limit) {
|
|
369
|
+
const max = Math.max(1, Number(limit) || 1);
|
|
370
|
+
let active = 0;
|
|
371
|
+
const queue = [];
|
|
372
|
+
|
|
373
|
+
const pump = () => {
|
|
374
|
+
while (active < max && queue.length > 0) {
|
|
375
|
+
active++;
|
|
376
|
+
const { task, resolve, reject } = queue.shift();
|
|
377
|
+
Promise.resolve()
|
|
378
|
+
.then(task)
|
|
379
|
+
.then(resolve, reject)
|
|
380
|
+
.finally(() => {
|
|
381
|
+
active--;
|
|
382
|
+
pump();
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return (task) =>
|
|
388
|
+
new Promise((resolve, reject) => {
|
|
389
|
+
queue.push({ task, resolve, reject });
|
|
390
|
+
pump();
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function toMarkdown(
|
|
395
|
+
{
|
|
396
|
+
model,
|
|
397
|
+
retryDelays,
|
|
398
|
+
maxOutputTokens,
|
|
399
|
+
ceiling,
|
|
400
|
+
chunkTargetTokens,
|
|
401
|
+
session,
|
|
402
|
+
limiter,
|
|
403
|
+
},
|
|
404
|
+
title,
|
|
405
|
+
transcript,
|
|
406
|
+
onRetry,
|
|
407
|
+
onProgress,
|
|
408
|
+
) {
|
|
409
|
+
const budgetCeiling = isSet(maxOutputTokens)
|
|
410
|
+
? Number(maxOutputTokens)
|
|
411
|
+
: ceiling;
|
|
412
|
+
const desired = Math.max(
|
|
413
|
+
OUTPUT_FLOOR,
|
|
414
|
+
Math.ceil(estimateTokens(transcript) * OUTPUT_FACTOR),
|
|
415
|
+
);
|
|
416
|
+
const parts =
|
|
417
|
+
desired > budgetCeiling
|
|
418
|
+
? chunkTranscript(transcript, { targetTokens: chunkTargetTokens })
|
|
419
|
+
: [transcript];
|
|
420
|
+
|
|
421
|
+
const total = parts.length;
|
|
422
|
+
const bodies = new Array(total);
|
|
423
|
+
const usages = [];
|
|
424
|
+
let elapsed = 0;
|
|
425
|
+
let finishReason = "stop";
|
|
426
|
+
|
|
427
|
+
await Promise.all(
|
|
428
|
+
parts.map(async (part, index) => {
|
|
429
|
+
onProgress?.(index + 1, total);
|
|
430
|
+
const result = await generateOnce(
|
|
431
|
+
{ model, retryDelays, session, limiter },
|
|
432
|
+
{
|
|
433
|
+
system: SYSTEM,
|
|
434
|
+
prompt: chunkPrompt(title, part, index, total),
|
|
435
|
+
maxOutputTokens: budgetForPart(part, { maxOutputTokens, ceiling }),
|
|
436
|
+
onRetry,
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
bodies[index] = cleanBody(result.text);
|
|
440
|
+
usages.push(result.usage);
|
|
441
|
+
elapsed += result.elapsed;
|
|
442
|
+
if (result.finishReason !== "stop" && finishReason === "stop") {
|
|
443
|
+
finishReason = result.finishReason;
|
|
444
|
+
}
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (finishReason === "stop" && total > 1) {
|
|
449
|
+
for (let index = 0; index + 1 < total; index++) {
|
|
450
|
+
const previousTail = lastBlock(bodies[index]);
|
|
451
|
+
const nextHead = firstBlock(bodies[index + 1]);
|
|
452
|
+
if (!previousTail || !nextHead) continue;
|
|
453
|
+
|
|
454
|
+
const smoothing = await generateOnce(
|
|
455
|
+
{ model, retryDelays, session, limiter },
|
|
456
|
+
{
|
|
457
|
+
system: SMOOTH_SYSTEM,
|
|
458
|
+
prompt: smoothPrompt(previousTail, nextHead),
|
|
459
|
+
maxOutputTokens: plannedBudget(nextHead, budgetCeiling),
|
|
460
|
+
onRetry,
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
usages.push(smoothing.usage);
|
|
464
|
+
elapsed += smoothing.elapsed;
|
|
465
|
+
if (smoothing.finishReason === "stop") {
|
|
466
|
+
const rewritten = cleanBody(smoothing.text);
|
|
467
|
+
if (rewritten) {
|
|
468
|
+
bodies[index + 1] = replaceFirstBlock(bodies[index + 1], rewritten);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const body = dedupeAdjacentHeadings(
|
|
475
|
+
bodies
|
|
476
|
+
.join("\n\n")
|
|
477
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
478
|
+
.trim(),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
body,
|
|
483
|
+
usage: sumUsage(usages),
|
|
484
|
+
finishReason,
|
|
485
|
+
elapsed,
|
|
486
|
+
chunks: total,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
253
490
|
async function forEachConcurrent(items, limit, worker) {
|
|
254
491
|
let next = 0;
|
|
255
492
|
await Promise.all(
|
|
@@ -267,11 +504,12 @@ export async function runGenerateMarkdown({
|
|
|
267
504
|
model: modelId,
|
|
268
505
|
endpoint,
|
|
269
506
|
apiKey,
|
|
270
|
-
|
|
507
|
+
sessionBase,
|
|
271
508
|
concurrency = 6,
|
|
272
509
|
ytdlpConcurrency = 2,
|
|
273
510
|
retryDelays = "10,30,60",
|
|
274
511
|
maxOutputTokens,
|
|
512
|
+
chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
|
|
275
513
|
force = false,
|
|
276
514
|
dir = DEFAULT_DIR,
|
|
277
515
|
} = {}) {
|
|
@@ -283,8 +521,9 @@ export async function runGenerateMarkdown({
|
|
|
283
521
|
const RETRY_DELAYS = parseRetryDelays(retryDelays);
|
|
284
522
|
const llmConcurrency = Number(concurrency);
|
|
285
523
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
524
|
+
const limiter = createLimiter(llmConcurrency);
|
|
286
525
|
|
|
287
|
-
const model = createModel({ modelId, endpoint, apiKey: key, session });
|
|
526
|
+
const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
|
|
288
527
|
|
|
289
528
|
await mkdir(dir, { recursive: true });
|
|
290
529
|
|
|
@@ -335,6 +574,17 @@ export async function runGenerateMarkdown({
|
|
|
335
574
|
else pending.push(job);
|
|
336
575
|
}
|
|
337
576
|
|
|
577
|
+
const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
|
|
578
|
+
const modelOutputLimit = prices?.outputLimitAt(modelId) ?? null;
|
|
579
|
+
const ceiling = isSet(maxOutputTokens)
|
|
580
|
+
? Number(maxOutputTokens)
|
|
581
|
+
: isSet(modelOutputLimit)
|
|
582
|
+
? Number(modelOutputLimit)
|
|
583
|
+
: DEFAULT_CEILING;
|
|
584
|
+
console.error(
|
|
585
|
+
`output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
|
|
586
|
+
);
|
|
587
|
+
|
|
338
588
|
const downloadTask = {
|
|
339
589
|
title: "Download transcripts",
|
|
340
590
|
rendererOptions: phaseOptions,
|
|
@@ -410,20 +660,34 @@ export async function runGenerateMarkdown({
|
|
|
410
660
|
const name = relative(dir, md);
|
|
411
661
|
try {
|
|
412
662
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
413
|
-
const
|
|
414
|
-
|
|
663
|
+
const price = prices ? prices.priceAt(modelId) : null;
|
|
664
|
+
const { body, usage, finishReason, elapsed, chunks } = await toMarkdown(
|
|
665
|
+
{
|
|
666
|
+
model,
|
|
667
|
+
retryDelays: RETRY_DELAYS,
|
|
668
|
+
maxOutputTokens,
|
|
669
|
+
ceiling,
|
|
670
|
+
chunkTargetTokens,
|
|
671
|
+
session: videoSession(sessionBase, job),
|
|
672
|
+
limiter,
|
|
673
|
+
},
|
|
415
674
|
job.title,
|
|
416
675
|
transcript,
|
|
417
676
|
(nextAttempt, totalAttempts, waitMs) => {
|
|
418
677
|
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
419
678
|
`(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
|
|
420
679
|
},
|
|
680
|
+
(part, totalParts) => {
|
|
681
|
+
if (totalParts > 1) {
|
|
682
|
+
task.output = `generating part ${part}/${totalParts} ${name}`;
|
|
683
|
+
}
|
|
684
|
+
},
|
|
421
685
|
);
|
|
422
686
|
|
|
423
687
|
stats.totalSeconds += elapsed;
|
|
424
688
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
425
689
|
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
426
|
-
const cost = estimateCost(
|
|
690
|
+
const cost = estimateCost(price, usage);
|
|
427
691
|
if (cost === null) stats.costKnown = false;
|
|
428
692
|
else stats.totalCost += cost;
|
|
429
693
|
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
@@ -440,6 +704,8 @@ export async function runGenerateMarkdown({
|
|
|
440
704
|
elapsed,
|
|
441
705
|
cost,
|
|
442
706
|
finishReason,
|
|
707
|
+
price,
|
|
708
|
+
chunks,
|
|
443
709
|
});
|
|
444
710
|
await mkdir(dirname(md), { recursive: true });
|
|
445
711
|
await writeFile(
|
|
@@ -476,3 +742,13 @@ export async function runGenerateMarkdown({
|
|
|
476
742
|
}`,
|
|
477
743
|
);
|
|
478
744
|
}
|
|
745
|
+
|
|
746
|
+
export {
|
|
747
|
+
budgetForPart,
|
|
748
|
+
chunkPrompt,
|
|
749
|
+
cleanBody,
|
|
750
|
+
createLimiter,
|
|
751
|
+
plannedBudget,
|
|
752
|
+
smoothPrompt,
|
|
753
|
+
sumUsage,
|
|
754
|
+
};
|
package/src/organize-files.js
CHANGED
|
@@ -43,7 +43,9 @@ function foldersByPrefix(feed) {
|
|
|
43
43
|
for (const video of channel.videos ?? []) {
|
|
44
44
|
const slug = slugify(primaryTitle(video));
|
|
45
45
|
if (!video.date || !slug) continue;
|
|
46
|
-
const prefix =
|
|
46
|
+
const prefix = Number.isInteger(video.index)
|
|
47
|
+
? `${video.index}-${video.date}_${slug}`
|
|
48
|
+
: `${video.date}_${slug}`;
|
|
47
49
|
const folders = targets.get(prefix) ?? new Set();
|
|
48
50
|
folders.add(folder);
|
|
49
51
|
targets.set(prefix, folders);
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
6
|
+
const LITELLM_URL =
|
|
7
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
11
|
+
|
|
12
|
+
const CACHE_DIR = join(
|
|
13
|
+
process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
|
|
14
|
+
"youtube-to-markdown",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const normalizeBase = (url) => url.replace(/\/+$/, "");
|
|
18
|
+
|
|
19
|
+
async function fetchText(url) {
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`);
|
|
24
|
+
return response.text();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadCachedText(url, name) {
|
|
28
|
+
const file = join(CACHE_DIR, name);
|
|
29
|
+
try {
|
|
30
|
+
const info = await stat(file);
|
|
31
|
+
if (Date.now() - info.mtimeMs < CACHE_TTL_MS) {
|
|
32
|
+
return await readFile(file, "utf8");
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// no fresh cache
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const text = await fetchText(url);
|
|
40
|
+
await mkdir(dirname(file), { recursive: true });
|
|
41
|
+
await writeFile(file, text);
|
|
42
|
+
return text;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return await readFile(file, "utf8").catch(() => {
|
|
45
|
+
throw error;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadJson(url, name) {
|
|
51
|
+
return JSON.parse(await loadCachedText(url, name));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findProvider(catalog, base) {
|
|
55
|
+
const candidates = base.endsWith("/v1")
|
|
56
|
+
? [base, base.slice(0, -"/v1".length)]
|
|
57
|
+
: [base];
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
for (const provider of Object.values(catalog)) {
|
|
60
|
+
if (provider?.api && normalizeBase(provider.api) === candidate) {
|
|
61
|
+
return provider;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function indexLitellm(catalog) {
|
|
69
|
+
const providers = new Map();
|
|
70
|
+
for (const [key, entry] of Object.entries(catalog)) {
|
|
71
|
+
const provider = entry?.litellm_provider;
|
|
72
|
+
if (!provider) continue;
|
|
73
|
+
let models = providers.get(provider);
|
|
74
|
+
if (!models) {
|
|
75
|
+
models = new Map();
|
|
76
|
+
providers.set(provider, models);
|
|
77
|
+
}
|
|
78
|
+
const name = key.includes("/") ? key.slice(key.lastIndexOf("/") + 1) : key;
|
|
79
|
+
if (!models.has(name)) models.set(name, entry);
|
|
80
|
+
if (!models.has(key)) models.set(key, entry);
|
|
81
|
+
}
|
|
82
|
+
return providers;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toMinutes = (value) => {
|
|
86
|
+
const [hours, minutes] = value.split(":").map(Number);
|
|
87
|
+
return hours * 60 + minutes;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isWithinRange = (minutes, start, end) =>
|
|
91
|
+
start === end
|
|
92
|
+
? true
|
|
93
|
+
: start < end
|
|
94
|
+
? minutes >= start && minutes < end
|
|
95
|
+
: minutes >= start || minutes < end;
|
|
96
|
+
|
|
97
|
+
function isOffPeak(windows, at) {
|
|
98
|
+
const day = at.getUTCDay();
|
|
99
|
+
const weekday = day === 0 ? 7 : day;
|
|
100
|
+
const minutes = at.getUTCHours() * 60 + at.getUTCMinutes();
|
|
101
|
+
for (const window of windows ?? []) {
|
|
102
|
+
if (!window?.weekdays?.includes(weekday)) continue;
|
|
103
|
+
const ranges = Array.isArray(window.hours_utc)
|
|
104
|
+
? window.hours_utc
|
|
105
|
+
: [window.hours_utc];
|
|
106
|
+
for (const range of ranges) {
|
|
107
|
+
if (typeof range !== "string" || !range.includes("-")) continue;
|
|
108
|
+
const [start, end] = range.split("-").map(toMinutes);
|
|
109
|
+
if (isWithinRange(minutes, start, end)) return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const perMillion = (perToken) =>
|
|
116
|
+
typeof perToken === "number" ? perToken * 1e6 : undefined;
|
|
117
|
+
|
|
118
|
+
function litellmPrice(entry, at) {
|
|
119
|
+
const offPeak =
|
|
120
|
+
entry.off_peak_pricing && isOffPeak(entry.off_peak_pricing.windows, at);
|
|
121
|
+
const rates = offPeak ? entry.off_peak_pricing : entry;
|
|
122
|
+
return {
|
|
123
|
+
input: perMillion(rates.input_cost_per_token),
|
|
124
|
+
output: perMillion(rates.output_cost_per_token),
|
|
125
|
+
cachedInput: perMillion(
|
|
126
|
+
rates.cache_read_input_token_cost ?? rates.input_cost_per_token_cache_hit,
|
|
127
|
+
),
|
|
128
|
+
peak: entry.off_peak_pricing ? !offPeak : null,
|
|
129
|
+
source: "litellm",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function modelsDevPrice(model) {
|
|
134
|
+
const cost = model?.cost;
|
|
135
|
+
if (!cost) return null;
|
|
136
|
+
return {
|
|
137
|
+
input: cost.input,
|
|
138
|
+
output: cost.output,
|
|
139
|
+
cachedInput: cost.cache_read ?? 0,
|
|
140
|
+
peak: null,
|
|
141
|
+
source: "models.dev",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function loadPricing({ endpoint } = {}) {
|
|
146
|
+
if (!endpoint) return null;
|
|
147
|
+
const base = normalizeBase(endpoint.replace(/\/chat\/completions\/?$/, ""));
|
|
148
|
+
|
|
149
|
+
let catalog;
|
|
150
|
+
try {
|
|
151
|
+
catalog = await loadJson(MODELS_DEV_URL, "models-dev.json");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error(`warning: pricing catalog unavailable (${error.message})`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const provider = findProvider(catalog, base);
|
|
158
|
+
if (!provider) return null;
|
|
159
|
+
|
|
160
|
+
let litellm = null;
|
|
161
|
+
try {
|
|
162
|
+
litellm = indexLitellm(await loadJson(LITELLM_URL, "litellm.json")).get(
|
|
163
|
+
provider.id,
|
|
164
|
+
) ?? null;
|
|
165
|
+
} catch {
|
|
166
|
+
litellm = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
provider: provider.id,
|
|
171
|
+
priceAt(modelId, at = new Date()) {
|
|
172
|
+
const entry = litellm?.get(modelId);
|
|
173
|
+
if (entry) return litellmPrice(entry, at);
|
|
174
|
+
return modelsDevPrice(provider.models?.[modelId]);
|
|
175
|
+
},
|
|
176
|
+
outputLimitAt(modelId) {
|
|
177
|
+
const entry = litellm?.get(modelId);
|
|
178
|
+
const limit =
|
|
179
|
+
entry?.max_output_tokens ??
|
|
180
|
+
provider.models?.[modelId]?.limit?.output ??
|
|
181
|
+
entry?.max_tokens;
|
|
182
|
+
return typeof limit === "number" && limit > 0 ? limit : null;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/vtt.js
CHANGED
|
@@ -121,8 +121,10 @@ export function videoJobs(video, dir = DEFAULT_DIR, folderSlug = null) {
|
|
|
121
121
|
const slug = slugify(primaryTitle(video));
|
|
122
122
|
if (!slug) return [];
|
|
123
123
|
|
|
124
|
+
const prefix = Number.isInteger(video.index) ? `${video.index}-` : "";
|
|
125
|
+
|
|
124
126
|
return langs.map((lang) => {
|
|
125
|
-
const name = `${video.date}_${slug}.${lang}`;
|
|
127
|
+
const name = `${prefix}${video.date}_${slug}.${lang}`;
|
|
126
128
|
const base = folder ? join(dir, folder, name) : join(dir, name);
|
|
127
129
|
return {
|
|
128
130
|
url: video.url,
|