@stephane-klein/youtube-to-markdown 0.1.0 → 0.3.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 +177 -45
- package/package.json +1 -1
- package/src/cli.js +64 -6
- package/src/doctor.js +238 -0
- package/src/extract-video-metadata.js +48 -21
- package/src/generate-markdown.js +65 -20
- package/src/organize-files.js +3 -1
- package/src/pricing.js +177 -0
- package/src/vtt.js +3 -1
package/README.md
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# youtube-feed-to-markdown
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The `youtube-to-markdown` CLI generates Markdown files from YouTube video
|
|
4
|
+
transcripts.
|
|
5
|
+
|
|
6
|
+
I use it to search through the content of popular-science videos and shows of
|
|
7
|
+
all kinds. I also use it to feed that corpus into a RAG.
|
|
8
|
+
|
|
9
|
+
Technically, it is fairly simple: the project relies on
|
|
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
|
+
|
|
13
|
+
In the final step, I use an LLM through a standard OpenAI-compatible API to
|
|
14
|
+
reconstruct a complete text from the video transcripts.
|
|
6
15
|
|
|
7
16
|
## Requirements
|
|
8
17
|
|
|
@@ -28,11 +37,94 @@ Or run it without installing it, with `npx`:
|
|
|
28
37
|
$ npx @stephane-klein/youtube-to-markdown extract-video-metadata
|
|
29
38
|
```
|
|
30
39
|
|
|
31
|
-
Either way, `yt-dlp` must be on your `PATH` (see Requirements).
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
Either way, `yt-dlp` must be on your `PATH` (see Requirements).
|
|
41
|
+
|
|
42
|
+
Run `youtube-to-markdown --help` to list the available commands:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
$ youtube-to-markdown --help
|
|
46
|
+
youtube-to-markdown <command>
|
|
47
|
+
|
|
48
|
+
Commands:
|
|
49
|
+
extract-video-metadata Fetch the videos of each feed source (channel or
|
|
50
|
+
playlist) from YouTube into the configuration file
|
|
51
|
+
download-vtt Download the VTT transcripts of the marked videos
|
|
52
|
+
generate-markdown Turn the VTT transcripts into Markdown prose with an
|
|
53
|
+
LLM
|
|
54
|
+
markdown-stats Report global tokens, cost and time from the Markdown
|
|
55
|
+
frontmatter
|
|
56
|
+
reorganize Move the transcripts and Markdown into their
|
|
57
|
+
folder_slug folders
|
|
58
|
+
doctor Check the runtime dependencies and the LLM access
|
|
59
|
+
completion Generate a shell completion script for bash or zsh
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
--config Path to the YAML configuration file
|
|
63
|
+
[string] [default: "youtube_to_markdown.yaml"]
|
|
64
|
+
--version Show version number [boolean]
|
|
65
|
+
--help Show help [boolean]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Install the shell completion for bash or zsh (`$SHELL` selects the script):
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
$ youtube-to-markdown completion >> ~/.zshrc
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use `~/.bashrc` on bash.
|
|
75
|
+
|
|
76
|
+
## Getting started
|
|
77
|
+
|
|
78
|
+
**1. Create `youtube_to_markdown.yaml`** with the LLM settings and your sources.
|
|
79
|
+
The example below uses [OpenCode Go](https://opencode.ai/en/go), an OpenAI-compatible API:
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
x_opencode_session: "youtube-to-markdown/0.1"
|
|
83
|
+
model_id: "mimo-v2.5"
|
|
84
|
+
openaiapi_endpoint: "https://opencode.ai/zen/go/v1/chat/completions"
|
|
85
|
+
feed:
|
|
86
|
+
- title: Science4All
|
|
87
|
+
url: https://www.youtube.com/@le_science4all
|
|
88
|
+
videos: []
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Only the `url` is required: `extract-video-metadata` fills the titles, the dates
|
|
92
|
+
and the video lists. `model_id` and `openaiapi_endpoint` are not specific to
|
|
93
|
+
OpenCode Go — point them at any OpenAI-compatible provider. The other settings
|
|
94
|
+
are documented below.
|
|
95
|
+
|
|
96
|
+
**2. Store your API key** in `.secret.sh` and source it. Write it with your editor
|
|
97
|
+
so the key never lands in your shell history:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
$ $EDITOR .secret.sh
|
|
101
|
+
$ cat .secret.sh
|
|
102
|
+
export OPENAI_API_KEY="sk-…"
|
|
103
|
+
$ source .secret.sh
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**3. Check the setup** with `doctor` and fix every `error` (a `warn` is fine):
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
$ youtube-to-markdown doctor
|
|
110
|
+
node v24.21.0 ok
|
|
111
|
+
yt-dlp 2026.08.19 ok
|
|
112
|
+
config youtube_to_markdown.yaml ok (2 channel(s), 636 video(s))
|
|
113
|
+
contents contents ok
|
|
114
|
+
model mimo-v2.5 ok
|
|
115
|
+
endpoint https://opencode.ai/zen/go/v1/chat/completions ok
|
|
116
|
+
api key OPENAI_API_KEY ok
|
|
117
|
+
session set info
|
|
118
|
+
pricing models.dev ok ($0.14 in / $0.28 out / $0.0028 cached per 1M)
|
|
119
|
+
llm 1.0s ok (248 in / 8 out)
|
|
120
|
+
|
|
121
|
+
summary: 9 ok, 0 warning, 0 error
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`doctor` exits with a non-zero status as soon as one line is an `error`, so it
|
|
125
|
+
can gate a CI job.
|
|
34
126
|
|
|
35
|
-
|
|
127
|
+
**4. Fetch the source videos** into the file:
|
|
36
128
|
|
|
37
129
|
```sh
|
|
38
130
|
$ youtube-to-markdown extract-video-metadata
|
|
@@ -40,29 +132,29 @@ $ youtube-to-markdown extract-video-metadata
|
|
|
40
132
|
→ https://www.youtube.com/@MonsieurPhi
|
|
41
133
|
```
|
|
42
134
|
|
|
43
|
-
|
|
44
|
-
`download-vtt`, `generate-markdown`, `markdown-stats` and `reorganize`. Run
|
|
45
|
-
`youtube-to-markdown <command> --help` for the available options.
|
|
135
|
+
The command is idempotent: a new run only adds the missing videos.
|
|
46
136
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
YAML file, then the built-in defaults. The API key stays a secret: `--api-key` or
|
|
51
|
-
`OPENAI_API_KEY`.
|
|
137
|
+
**5. Generate the Markdown** with `download-vtt`, then `generate-markdown`. The
|
|
138
|
+
other commands are `markdown-stats`, `reorganize` and `doctor`; run
|
|
139
|
+
`youtube-to-markdown <command> --help` for their options.
|
|
52
140
|
|
|
53
|
-
|
|
54
|
-
configuration file, asks yt-dlp for each channel's videos, and writes the result
|
|
55
|
-
back into the file. I run it from time to time: it is idempotent, so a new run
|
|
56
|
-
only adds the videos that are missing.
|
|
141
|
+
## Configure the settings and the sources
|
|
57
142
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
143
|
+
The commands read `youtube_to_markdown.yaml` from the current directory; override
|
|
144
|
+
it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the sources.
|
|
145
|
+
Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
|
|
146
|
+
variables, then the settings written in the YAML file, then the built-in
|
|
147
|
+
defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
|
|
148
|
+
settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
|
|
149
|
+
`concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
|
|
150
|
+
`retry_delays` and `max_output_tokens`). I only set the source URLs, and the
|
|
64
151
|
`extract-video-metadata` command fills the rest:
|
|
65
152
|
|
|
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.
|
|
157
|
+
|
|
66
158
|
```yaml
|
|
67
159
|
x_opencode_session: "youtube-to-markdown/0.1"
|
|
68
160
|
model_id: "mimo-v2.5"
|
|
@@ -91,14 +183,36 @@ other settings, it can only be set in the YAML file — there is no flag or
|
|
|
91
183
|
environment variable for it. `.gitignore` only ignores the default `contents/`,
|
|
92
184
|
so add your own path there if you move it.
|
|
93
185
|
|
|
94
|
-
`folder_slug` (optional, on a
|
|
186
|
+
`folder_slug` (optional, on a feed entry) stores that source's transcripts and
|
|
95
187
|
Markdown in a subdirectory of `contents_path`, for example `science-4-all`.
|
|
96
|
-
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
|
|
97
189
|
`folder_slug` keeps its files at the root of `contents_path`. The reserved name
|
|
98
190
|
`_orphans` is rejected. Change it whenever you like, then run
|
|
99
191
|
`youtube-to-markdown reorganize` to move the existing files.
|
|
100
192
|
|
|
101
|
-
##
|
|
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
|
+
|
|
215
|
+
## Download the transcripts of the videos
|
|
102
216
|
|
|
103
217
|
I mark the videos I want as transcripts with a `download_vtt` field:
|
|
104
218
|
|
|
@@ -135,7 +249,7 @@ field.
|
|
|
135
249
|
When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
|
|
136
250
|
marker and stops trying. I delete that marker to force a new attempt.
|
|
137
251
|
|
|
138
|
-
## Markdown
|
|
252
|
+
## Generate Markdown text from the transcripts
|
|
139
253
|
|
|
140
254
|
I mark the videos I want as Markdown with `generate_markdown`. The transcript is
|
|
141
255
|
downloaded first if it is not there yet:
|
|
@@ -188,7 +302,7 @@ from the server is honored when longer than the schedule. Each retry is shown in
|
|
|
188
302
|
the phase output; after the last attempt the file is reported as an error and is
|
|
189
303
|
retried on the next run.
|
|
190
304
|
|
|
191
|
-
###
|
|
305
|
+
### Generated frontmatter
|
|
192
306
|
|
|
193
307
|
Each generated file starts with a YAML frontmatter describing the run:
|
|
194
308
|
|
|
@@ -205,16 +319,32 @@ llm:
|
|
|
205
319
|
output_tokens: 3262
|
|
206
320
|
estimated_cost_usd: 0.000927
|
|
207
321
|
finish_reason: stop
|
|
322
|
+
pricing:
|
|
323
|
+
source: models.dev
|
|
324
|
+
peak: null
|
|
208
325
|
---
|
|
209
326
|
```
|
|
210
327
|
|
|
211
328
|
`video_title` is the original video title in the file's language and
|
|
212
|
-
`generated_at` is the UTC time of the generation. `
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
329
|
+
`generated_at` is the UTC time of the generation. `pricing.source` names the
|
|
330
|
+
catalog the rates came from and `pricing.peak` tells whether the peak rate
|
|
331
|
+
applied (`null` when the source has no peak/off-peak pricing). Existing files
|
|
332
|
+
are left untouched: run `youtube-to-markdown generate-markdown --force` to
|
|
333
|
+
regenerate every file, which is also the way to add the frontmatter to files
|
|
334
|
+
generated before it existed.
|
|
335
|
+
|
|
336
|
+
### Pricing
|
|
337
|
+
|
|
338
|
+
The estimated cost uses rates fetched at run time from
|
|
339
|
+
[models.dev](https://models.dev) and
|
|
340
|
+
[LiteLLM](https://github.com/BerriAI/litellm). The provider is resolved from
|
|
341
|
+
`openaiapi_endpoint`; LiteLLM is preferred when it prices the model, because it
|
|
342
|
+
carries DeepSeek's peak/off-peak windows, otherwise models.dev is used. Both
|
|
343
|
+
catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
|
|
344
|
+
(`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
|
|
345
|
+
unavailable, `estimated_cost_usd` is `null` and the run continues.
|
|
346
|
+
|
|
347
|
+
## Organize the files into folders
|
|
218
348
|
|
|
219
349
|
A channel may set `folder_slug` to keep its files in their own subdirectory of
|
|
220
350
|
`contents_path`. When you add, change or remove that setting, `reorganize` moves
|
|
@@ -234,7 +364,7 @@ moves it under `contents_path/_orphans/`, keeping its relative path. A file whos
|
|
|
234
364
|
target already exists, or a filename shared by two channels with different
|
|
235
365
|
`folder_slug`, is reported as a conflict and left untouched.
|
|
236
366
|
|
|
237
|
-
##
|
|
367
|
+
## Report the tokens, cost and time
|
|
238
368
|
|
|
239
369
|
`markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
|
|
240
370
|
(recursively) and prints global totals — tokens, estimated cost, processing
|
|
@@ -280,21 +410,23 @@ folders
|
|
|
280
410
|
Files without frontmatter (generated before the frontmatter existed) are counted
|
|
281
411
|
but left out of the totals.
|
|
282
412
|
|
|
283
|
-
##
|
|
413
|
+
## Set up the development environment
|
|
284
414
|
|
|
285
415
|
To work on this project, [mise](https://mise.jdx.dev/) installs both yt-dlp and
|
|
286
|
-
Node.js
|
|
287
|
-
the JavaScript dependencies:
|
|
416
|
+
Node.js; `npm install` fetches the JavaScript dependencies:
|
|
288
417
|
|
|
289
418
|
```sh
|
|
290
419
|
$ mise install
|
|
291
420
|
$ npm install
|
|
292
421
|
```
|
|
293
422
|
|
|
294
|
-
The
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
423
|
+
The `enter` hook defines a `youtube-to-markdown` shell function that calls
|
|
424
|
+
`node src/cli.js`, and loads the shell completion for bash and zsh; the `leave`
|
|
425
|
+
hook removes both. Everything is set when you enter the project from an
|
|
426
|
+
interactive bash or zsh shell, so it is only available in interactive shells; in
|
|
427
|
+
scripts and CI, call `node src/cli.js <command>` instead. A function, rather than
|
|
428
|
+
a shell alias, is what lets `youtube-to-markdown <TAB>` complete the subcommands:
|
|
429
|
+
zsh expands aliases before dispatching completion, which would bypass `compdef`.
|
|
298
430
|
|
|
299
431
|
To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
|
|
300
432
|
`.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
|
|
@@ -305,7 +437,7 @@ $ cp .secret.sh.example .secret.sh
|
|
|
305
437
|
$ $EDITOR .secret.sh
|
|
306
438
|
```
|
|
307
439
|
|
|
308
|
-
## Publish
|
|
440
|
+
## Publish the package to npmjs
|
|
309
441
|
|
|
310
442
|
I publish the package to npmjs from this repository with mise:
|
|
311
443
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runDownloadVtt } from "./download-vtt.js";
|
|
|
10
10
|
import { runGenerateMarkdown } from "./generate-markdown.js";
|
|
11
11
|
import { runMarkdownStats } from "./markdown-stats.js";
|
|
12
12
|
import { runReorganize } from "./organize-files.js";
|
|
13
|
+
import { runDoctor } from "./doctor.js";
|
|
13
14
|
|
|
14
15
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
15
16
|
|
|
@@ -49,13 +50,14 @@ const project = (argv) => {
|
|
|
49
50
|
return { path: argv.config, config: yaml, feed: yaml.feed ?? [], settings, dir };
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
yargs(hideBin(process.argv))
|
|
53
|
-
.scriptName("
|
|
53
|
+
const cli = yargs(hideBin(process.argv))
|
|
54
|
+
.scriptName("")
|
|
55
|
+
.usage("youtube-to-markdown <command>")
|
|
54
56
|
.config(loadEnv())
|
|
55
57
|
.option("config", config)
|
|
56
58
|
.command(
|
|
57
59
|
"extract-video-metadata",
|
|
58
|
-
"Fetch the channel
|
|
60
|
+
"Fetch the videos of each feed source (channel or playlist) from YouTube into the configuration file",
|
|
59
61
|
(yargs) =>
|
|
60
62
|
yargs
|
|
61
63
|
.option("force-dates", {
|
|
@@ -147,9 +149,65 @@ yargs(hideBin(process.argv))
|
|
|
147
149
|
await runReorganize({ feed, dir, dryRun: argv.dryRun });
|
|
148
150
|
}),
|
|
149
151
|
)
|
|
150
|
-
.
|
|
152
|
+
.command(
|
|
153
|
+
"doctor",
|
|
154
|
+
"Check the runtime dependencies and the LLM access",
|
|
155
|
+
(yargs) =>
|
|
156
|
+
yargs
|
|
157
|
+
.option("model", {
|
|
158
|
+
describe: "LLM model id to test (default: model_id in the configuration)",
|
|
159
|
+
type: "string",
|
|
160
|
+
})
|
|
161
|
+
.option("endpoint", {
|
|
162
|
+
describe: "OpenAI-compatible chat completions endpoint to test",
|
|
163
|
+
type: "string",
|
|
164
|
+
})
|
|
165
|
+
.option("api-key", {
|
|
166
|
+
describe: "API key to test (defaults to OPENAI_API_KEY)",
|
|
167
|
+
type: "string",
|
|
168
|
+
})
|
|
169
|
+
.option("session", {
|
|
170
|
+
describe: "Value of the X-OpenCode-Session header",
|
|
171
|
+
type: "string",
|
|
172
|
+
})
|
|
173
|
+
.option("llm", {
|
|
174
|
+
describe: "Run a minimal LLM request to test the API access",
|
|
175
|
+
type: "boolean",
|
|
176
|
+
default: true,
|
|
177
|
+
})
|
|
178
|
+
.option("timeout", {
|
|
179
|
+
describe: "Timeout in seconds for the LLM request (default: 30)",
|
|
180
|
+
type: "number",
|
|
181
|
+
default: 30,
|
|
182
|
+
}),
|
|
183
|
+
handle(async (argv) => {
|
|
184
|
+
await runDoctor({
|
|
185
|
+
configPath: argv.config,
|
|
186
|
+
overrides: {
|
|
187
|
+
model: argv.model,
|
|
188
|
+
endpoint: argv.endpoint,
|
|
189
|
+
apiKey: argv.apiKey,
|
|
190
|
+
session: argv.session,
|
|
191
|
+
},
|
|
192
|
+
llm: argv.llm,
|
|
193
|
+
timeoutMs: argv.timeout * 1000,
|
|
194
|
+
});
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
.command(
|
|
198
|
+
"completion",
|
|
199
|
+
"Generate a shell completion script for bash or zsh",
|
|
200
|
+
() => {},
|
|
201
|
+
() => cli.showCompletionScript("youtube-to-markdown", "completion"),
|
|
202
|
+
)
|
|
203
|
+
.demandCommand(
|
|
204
|
+
1,
|
|
205
|
+
"Use one of the available commands, or run `youtube-to-markdown doctor` " +
|
|
206
|
+
"first.",
|
|
207
|
+
)
|
|
151
208
|
.strictCommands()
|
|
152
209
|
.recommendCommands()
|
|
153
210
|
.version(version)
|
|
154
|
-
.help()
|
|
155
|
-
|
|
211
|
+
.help();
|
|
212
|
+
|
|
213
|
+
cli.parse();
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { generateText } from "ai";
|
|
4
|
+
import { loadConfig, settingsFromConfig } from "./config.js";
|
|
5
|
+
import { createModel, describeError } from "./generate-markdown.js";
|
|
6
|
+
import { loadPricing } from "./pricing.js";
|
|
7
|
+
import { exists } from "./vtt.js";
|
|
8
|
+
import { runYtDlp } from "./ytdlp.js";
|
|
9
|
+
|
|
10
|
+
const MIN_NODE_MAJOR = 22;
|
|
11
|
+
|
|
12
|
+
function resolveApiKey(overrides, config, env = process.env) {
|
|
13
|
+
if (overrides.apiKey !== undefined) {
|
|
14
|
+
const envKey = env.YT_TO_MD_API_KEY;
|
|
15
|
+
const source = envKey !== undefined && envKey === overrides.apiKey
|
|
16
|
+
? "YT_TO_MD_API_KEY"
|
|
17
|
+
: "--api-key";
|
|
18
|
+
return { key: overrides.apiKey, source };
|
|
19
|
+
}
|
|
20
|
+
if (config?.api_key !== undefined) {
|
|
21
|
+
return { key: config.api_key, source: "api_key (config)" };
|
|
22
|
+
}
|
|
23
|
+
if (env.OPENAI_API_KEY) {
|
|
24
|
+
return { key: env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
|
|
25
|
+
}
|
|
26
|
+
return { key: undefined, source: null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function checkContents(dir) {
|
|
30
|
+
try {
|
|
31
|
+
await access(dir, constants.W_OK);
|
|
32
|
+
return { status: "ok", note: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT") {
|
|
35
|
+
return { status: "error", note: "not writable" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let parent = dirname(dir);
|
|
39
|
+
while (true) {
|
|
40
|
+
try {
|
|
41
|
+
await access(parent, constants.W_OK);
|
|
42
|
+
return { status: "warn", note: "not created yet" };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== "ENOENT") {
|
|
45
|
+
return { status: "error", note: "not writable" };
|
|
46
|
+
}
|
|
47
|
+
const next = dirname(parent);
|
|
48
|
+
if (next === parent) return { status: "error", note: "not writable" };
|
|
49
|
+
parent = next;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkNode() {
|
|
55
|
+
const version = process.versions.node;
|
|
56
|
+
const major = Number(version.split(".")[0]);
|
|
57
|
+
return major >= MIN_NODE_MAJOR
|
|
58
|
+
? { status: "ok", value: `v${version}`, note: null }
|
|
59
|
+
: { status: "error", value: `v${version}`, note: `requires >= ${MIN_NODE_MAJOR}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function checkYtDlp() {
|
|
63
|
+
try {
|
|
64
|
+
const { stdout } = await runYtDlp(["--version"]);
|
|
65
|
+
return { status: "ok", value: stdout.trim() || "found", note: null };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { status: "error", value: "-", note: error.message };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function loadConfigSafe(configPath) {
|
|
72
|
+
if (!(await exists(configPath))) {
|
|
73
|
+
return {
|
|
74
|
+
config: null,
|
|
75
|
+
row: { status: "warn", value: configPath, note: "not found" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const config = loadConfig(configPath);
|
|
80
|
+
const channels = config.feed?.length ?? 0;
|
|
81
|
+
const videos = (config.feed ?? []).reduce(
|
|
82
|
+
(total, channel) => total + (channel.videos?.length ?? 0),
|
|
83
|
+
0,
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
config,
|
|
87
|
+
row: {
|
|
88
|
+
status: "ok",
|
|
89
|
+
value: configPath,
|
|
90
|
+
note: `${channels} source(s), ${videos} video(s)`,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
config: null,
|
|
96
|
+
row: { status: "error", value: configPath, note: error.message },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checkEndpoint(endpoint) {
|
|
102
|
+
if (!endpoint) return { status: "error", value: "-", note: "not set" };
|
|
103
|
+
try {
|
|
104
|
+
new URL(endpoint);
|
|
105
|
+
return { status: "ok", value: endpoint, note: null };
|
|
106
|
+
} catch {
|
|
107
|
+
return { status: "error", value: endpoint, note: "not a valid URL" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function checkLlm({ llm, settings, key, timeoutMs }) {
|
|
112
|
+
if (!llm) {
|
|
113
|
+
return { status: "warn", value: "-", note: "skipped (--no-llm)" };
|
|
114
|
+
}
|
|
115
|
+
if (!settings.model) return { status: "error", value: "-", note: "model not set" };
|
|
116
|
+
if (!settings.endpoint) return { status: "error", value: "-", note: "endpoint not set" };
|
|
117
|
+
if (!key) return { status: "error", value: "-", note: "api key not set" };
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const model = createModel({
|
|
121
|
+
modelId: settings.model,
|
|
122
|
+
endpoint: settings.endpoint,
|
|
123
|
+
apiKey: key,
|
|
124
|
+
session: settings.session,
|
|
125
|
+
});
|
|
126
|
+
const started = performance.now();
|
|
127
|
+
const { usage } = await generateText({
|
|
128
|
+
model,
|
|
129
|
+
prompt: "ping",
|
|
130
|
+
maxOutputTokens: 8,
|
|
131
|
+
maxRetries: 0,
|
|
132
|
+
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
});
|
|
134
|
+
const elapsed = (performance.now() - started) / 1000;
|
|
135
|
+
return {
|
|
136
|
+
status: "ok",
|
|
137
|
+
value: `${elapsed.toFixed(1)}s`,
|
|
138
|
+
note: `${usage?.inputTokens ?? 0} in / ${usage?.outputTokens ?? 0} out`,
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { status: "error", value: "-", note: describeError(error, 1) };
|
|
142
|
+
}
|
|
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
|
+
|
|
170
|
+
function render(rows) {
|
|
171
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
172
|
+
const valueWidth = Math.max(...rows.map((row) => row.value.length));
|
|
173
|
+
return rows.map((row) => {
|
|
174
|
+
const note = row.note ? ` (${row.note})` : "";
|
|
175
|
+
return `${row.label.padEnd(labelWidth)} ${row.value.padEnd(valueWidth)} ${row.status}${note}`;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runDoctor({
|
|
180
|
+
configPath = "youtube_to_markdown.yaml",
|
|
181
|
+
overrides = {},
|
|
182
|
+
llm = true,
|
|
183
|
+
timeoutMs = 30000,
|
|
184
|
+
} = {}) {
|
|
185
|
+
const { config, row: configRow } = await loadConfigSafe(configPath);
|
|
186
|
+
|
|
187
|
+
const settings = { ...settingsFromConfig(config ?? {}) };
|
|
188
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
189
|
+
if (value !== undefined) settings[key] = value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { key, source } = resolveApiKey(overrides, config);
|
|
193
|
+
const dir = resolve(dirname(configPath), config?.contents_path ?? "contents");
|
|
194
|
+
|
|
195
|
+
const rows = [
|
|
196
|
+
{ label: "node", ...checkNode() },
|
|
197
|
+
{ label: "yt-dlp", ...(await checkYtDlp()) },
|
|
198
|
+
];
|
|
199
|
+
rows.push({ label: "config", ...configRow });
|
|
200
|
+
rows.push({
|
|
201
|
+
label: "contents",
|
|
202
|
+
value: config?.contents_path ?? "contents",
|
|
203
|
+
...(await checkContents(dir)),
|
|
204
|
+
});
|
|
205
|
+
rows.push({
|
|
206
|
+
label: "model",
|
|
207
|
+
value: settings.model ?? "-",
|
|
208
|
+
status: settings.model ? "ok" : "error",
|
|
209
|
+
note: settings.model ? null : "not set",
|
|
210
|
+
});
|
|
211
|
+
rows.push({ label: "endpoint", ...checkEndpoint(settings.endpoint) });
|
|
212
|
+
rows.push({
|
|
213
|
+
label: "api key",
|
|
214
|
+
value: source ?? "-",
|
|
215
|
+
status: key ? "ok" : "error",
|
|
216
|
+
note: key ? null : "not set",
|
|
217
|
+
});
|
|
218
|
+
if (settings.session) {
|
|
219
|
+
rows.push({ label: "session", value: "set", status: "info", note: null });
|
|
220
|
+
}
|
|
221
|
+
rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
|
|
222
|
+
const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
|
|
223
|
+
rows.push({ label: "llm", ...llmRow });
|
|
224
|
+
|
|
225
|
+
const counts = { ok: 0, warn: 0, error: 0 };
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
if (row.status in counts) counts[row.status]++;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(`youtube-to-markdown doctor`);
|
|
231
|
+
console.log(render(rows).join("\n"));
|
|
232
|
+
console.log();
|
|
233
|
+
console.log(
|
|
234
|
+
`summary: ${counts.ok} ok, ${counts.warn} warning, ${counts.error} error`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (counts.error > 0) process.exitCode = 1;
|
|
238
|
+
}
|
|
@@ -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,11 @@
|
|
|
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 { loadPricing } from "./pricing.js";
|
|
7
9
|
import {
|
|
8
10
|
DEFAULT_DIR,
|
|
9
11
|
downloadVtt,
|
|
@@ -26,17 +28,12 @@ const SYSTEM = [
|
|
|
26
28
|
|
|
27
29
|
const WIDTH = 80;
|
|
28
30
|
|
|
29
|
-
const PRICES = {
|
|
30
|
-
"mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
|
|
31
|
-
};
|
|
32
|
-
|
|
33
31
|
function cachedInputTokens(usage) {
|
|
34
32
|
const details = usage?.inputTokenDetails ?? {};
|
|
35
33
|
return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
function estimateCost(
|
|
39
|
-
const price = PRICES[modelId];
|
|
36
|
+
function estimateCost(price, usage) {
|
|
40
37
|
if (!price || !usage) return null;
|
|
41
38
|
|
|
42
39
|
const details = usage.inputTokenDetails ?? {};
|
|
@@ -80,7 +77,7 @@ function metricsLine(modelId, usage, elapsed, cost) {
|
|
|
80
77
|
.join(" ");
|
|
81
78
|
}
|
|
82
79
|
|
|
83
|
-
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
|
|
80
|
+
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price }) {
|
|
84
81
|
const data = {
|
|
85
82
|
source_url: job.url,
|
|
86
83
|
video_title: titleForLang(job.titles, job.lang),
|
|
@@ -93,6 +90,7 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
|
|
|
93
90
|
output_tokens: usage?.outputTokens ?? null,
|
|
94
91
|
estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
|
|
95
92
|
finish_reason: finishReason,
|
|
93
|
+
pricing: price ? { source: price.source, peak: price.peak } : null,
|
|
96
94
|
},
|
|
97
95
|
};
|
|
98
96
|
|
|
@@ -183,7 +181,7 @@ function retryDelayMs(error, fallbackMs) {
|
|
|
183
181
|
return Math.max(headerMs, fallbackMs);
|
|
184
182
|
}
|
|
185
183
|
|
|
186
|
-
function describeError(error, attempts) {
|
|
184
|
+
export function describeError(error, attempts) {
|
|
187
185
|
const suffix = ` after ${attempts} attempt(s)`;
|
|
188
186
|
if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
|
|
189
187
|
|
|
@@ -201,8 +199,51 @@ function parseRetryDelays(value) {
|
|
|
201
199
|
.filter((item) => Number.isFinite(item) && item >= 0);
|
|
202
200
|
}
|
|
203
201
|
|
|
202
|
+
function convertUsage(usage) {
|
|
203
|
+
const prompt = usage?.prompt_tokens ?? 0;
|
|
204
|
+
const completion = usage?.completion_tokens ?? 0;
|
|
205
|
+
const cacheRead =
|
|
206
|
+
usage?.prompt_cache_hit_tokens ??
|
|
207
|
+
usage?.prompt_tokens_details?.cached_tokens ??
|
|
208
|
+
0;
|
|
209
|
+
const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0;
|
|
210
|
+
return {
|
|
211
|
+
inputTokens: {
|
|
212
|
+
total: prompt,
|
|
213
|
+
noCache: Math.max(prompt - cacheRead, 0),
|
|
214
|
+
cacheRead,
|
|
215
|
+
cacheWrite: undefined,
|
|
216
|
+
},
|
|
217
|
+
outputTokens: {
|
|
218
|
+
total: completion,
|
|
219
|
+
text: Math.max(completion - reasoning, 0),
|
|
220
|
+
reasoning,
|
|
221
|
+
},
|
|
222
|
+
raw: usage,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createModel({ modelId, endpoint, apiKey, session }) {
|
|
227
|
+
const provider = createOpenAICompatible({
|
|
228
|
+
name: "opencode",
|
|
229
|
+
apiKey,
|
|
230
|
+
baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
|
|
231
|
+
headers: session ? { "X-OpenCode-Session": session } : {},
|
|
232
|
+
convertUsage,
|
|
233
|
+
});
|
|
234
|
+
return provider(modelId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function videoSession(base, job) {
|
|
238
|
+
const id = createHash("sha256")
|
|
239
|
+
.update(`${job.url}#${job.lang}`)
|
|
240
|
+
.digest("hex")
|
|
241
|
+
.slice(0, 16);
|
|
242
|
+
return `${base || "youtube-to-markdown"}-${id}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
async function toMarkdown(
|
|
205
|
-
{ model, retryDelays, maxOutputTokens },
|
|
246
|
+
{ model, retryDelays, maxOutputTokens, session },
|
|
206
247
|
title,
|
|
207
248
|
transcript,
|
|
208
249
|
onRetry,
|
|
@@ -217,8 +258,10 @@ async function toMarkdown(
|
|
|
217
258
|
system: SYSTEM,
|
|
218
259
|
prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
|
|
219
260
|
temperature: 0.2,
|
|
261
|
+
reasoning: "low",
|
|
220
262
|
maxOutputTokens: outputBudget(transcript, maxOutputTokens),
|
|
221
263
|
maxRetries: 0,
|
|
264
|
+
headers: { "X-OpenCode-Session": session },
|
|
222
265
|
});
|
|
223
266
|
const elapsed = (performance.now() - started) / 1000;
|
|
224
267
|
|
|
@@ -257,7 +300,7 @@ export async function runGenerateMarkdown({
|
|
|
257
300
|
model: modelId,
|
|
258
301
|
endpoint,
|
|
259
302
|
apiKey,
|
|
260
|
-
|
|
303
|
+
sessionBase,
|
|
261
304
|
concurrency = 6,
|
|
262
305
|
ytdlpConcurrency = 2,
|
|
263
306
|
retryDelays = "10,30,60",
|
|
@@ -274,14 +317,7 @@ export async function runGenerateMarkdown({
|
|
|
274
317
|
const llmConcurrency = Number(concurrency);
|
|
275
318
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
276
319
|
|
|
277
|
-
const
|
|
278
|
-
name: "opencode",
|
|
279
|
-
apiKey: key,
|
|
280
|
-
baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
|
|
281
|
-
headers: session ? { "X-OpenCode-Session": session } : {},
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
const model = provider(modelId);
|
|
320
|
+
const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
|
|
285
321
|
|
|
286
322
|
await mkdir(dir, { recursive: true });
|
|
287
323
|
|
|
@@ -332,6 +368,8 @@ export async function runGenerateMarkdown({
|
|
|
332
368
|
else pending.push(job);
|
|
333
369
|
}
|
|
334
370
|
|
|
371
|
+
const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
|
|
372
|
+
|
|
335
373
|
const downloadTask = {
|
|
336
374
|
title: "Download transcripts",
|
|
337
375
|
rendererOptions: phaseOptions,
|
|
@@ -407,8 +445,14 @@ export async function runGenerateMarkdown({
|
|
|
407
445
|
const name = relative(dir, md);
|
|
408
446
|
try {
|
|
409
447
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
448
|
+
const price = prices ? prices.priceAt(modelId) : null;
|
|
410
449
|
const { body, usage, finishReason, elapsed } = await toMarkdown(
|
|
411
|
-
{
|
|
450
|
+
{
|
|
451
|
+
model,
|
|
452
|
+
retryDelays: RETRY_DELAYS,
|
|
453
|
+
maxOutputTokens,
|
|
454
|
+
session: videoSession(sessionBase, job),
|
|
455
|
+
},
|
|
412
456
|
job.title,
|
|
413
457
|
transcript,
|
|
414
458
|
(nextAttempt, totalAttempts, waitMs) => {
|
|
@@ -420,7 +464,7 @@ export async function runGenerateMarkdown({
|
|
|
420
464
|
stats.totalSeconds += elapsed;
|
|
421
465
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
422
466
|
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
423
|
-
const cost = estimateCost(
|
|
467
|
+
const cost = estimateCost(price, usage);
|
|
424
468
|
if (cost === null) stats.costKnown = false;
|
|
425
469
|
else stats.totalCost += cost;
|
|
426
470
|
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
@@ -437,6 +481,7 @@ export async function runGenerateMarkdown({
|
|
|
437
481
|
elapsed,
|
|
438
482
|
cost,
|
|
439
483
|
finishReason,
|
|
484
|
+
price,
|
|
440
485
|
});
|
|
441
486
|
await mkdir(dirname(md), { recursive: true });
|
|
442
487
|
await writeFile(
|
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,177 @@
|
|
|
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
|
+
};
|
|
177
|
+
}
|
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,
|