@stephane-klein/youtube-to-markdown 0.1.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 +324 -0
- package/package.json +34 -0
- package/src/cli.js +155 -0
- package/src/config.js +45 -0
- package/src/download-vtt.js +59 -0
- package/src/extract-video-metadata.js +176 -0
- package/src/generate-markdown.js +475 -0
- package/src/markdown-stats.js +200 -0
- package/src/organize-files.js +137 -0
- package/src/vtt.js +137 -0
- package/src/ytdlp.js +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# youtube-feed-to-markdown
|
|
2
|
+
|
|
3
|
+
I collect the videos of a few YouTube channels into `youtube_to_markdown.yaml`,
|
|
4
|
+
using [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch the metadata. It is
|
|
5
|
+
published on npmjs as `@stephane-klein/youtube-to-markdown`.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 22 or later;
|
|
10
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) on your `PATH`.
|
|
11
|
+
|
|
12
|
+
The commands call yt-dlp with `--js-runtimes node`: solving YouTube's JavaScript
|
|
13
|
+
challenges requires an external JavaScript runtime, and Node.js — already
|
|
14
|
+
required here — does the job.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
The package is published on npmjs as `@stephane-klein/youtube-to-markdown`.
|
|
19
|
+
Install it globally to get the `youtube-to-markdown` command on your `PATH`:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
$ npm install -g @stephane-klein/youtube-to-markdown
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or run it without installing it, with `npx`:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
$ npx @stephane-klein/youtube-to-markdown extract-video-metadata
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Either way, `yt-dlp` must be on your `PATH` (see Requirements). The commands
|
|
32
|
+
read `youtube_to_markdown.yaml` from the current directory unless you pass
|
|
33
|
+
`--config`.
|
|
34
|
+
|
|
35
|
+
## Run
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
$ youtube-to-markdown extract-video-metadata
|
|
39
|
+
→ https://www.youtube.com/@le_science4all
|
|
40
|
+
→ https://www.youtube.com/@MonsieurPhi
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`youtube-to-markdown` exposes five subcommands: `extract-video-metadata`,
|
|
44
|
+
`download-vtt`, `generate-markdown`, `markdown-stats` and `reorganize`. Run
|
|
45
|
+
`youtube-to-markdown <command> --help` for the available options.
|
|
46
|
+
|
|
47
|
+
The configuration file is `youtube_to_markdown.yaml` (override it with
|
|
48
|
+
`--config` or `YT_TO_MD_CONFIG`). Its settings resolve in this order: command-line
|
|
49
|
+
flags, then `YT_TO_MD_*` environment variables, then the settings written in the
|
|
50
|
+
YAML file, then the built-in defaults. The API key stays a secret: `--api-key` or
|
|
51
|
+
`OPENAI_API_KEY`.
|
|
52
|
+
|
|
53
|
+
The `extract-video-metadata` command reads the channel URLs from the
|
|
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.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
`youtube_to_markdown.yaml` holds the settings and the channels. Settings are
|
|
61
|
+
`x_opencode_session`, `model_id`, `openaiapi_endpoint`, `concurrency` and
|
|
62
|
+
`ytdlp_concurrency` (plus the optional `api_key`, `retry_delays` and
|
|
63
|
+
`max_output_tokens`). I only set the channel URLs, and the
|
|
64
|
+
`extract-video-metadata` command fills the rest:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
x_opencode_session: "youtube-to-markdown/0.1"
|
|
68
|
+
model_id: "mimo-v2.5"
|
|
69
|
+
openaiapi_endpoint: "https://opencode.ai/zen/go/v1/chat/completions"
|
|
70
|
+
concurrency: "6"
|
|
71
|
+
ytdlp_concurrency: "2"
|
|
72
|
+
feed:
|
|
73
|
+
- title: Science4All
|
|
74
|
+
folder_slug: science-4-all
|
|
75
|
+
url: https://www.youtube.com/@le_science4all
|
|
76
|
+
videos:
|
|
77
|
+
- title:
|
|
78
|
+
fr: Pourquoi π est-il si fou ? Relativité 1
|
|
79
|
+
en: Why is π so crazy? Relativity 1
|
|
80
|
+
date: 2016-02-12
|
|
81
|
+
url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
|
|
82
|
+
- title: MrPhi
|
|
83
|
+
url: https://www.youtube.com/@MonsieurPhi
|
|
84
|
+
videos: []
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`contents_path` (optional) chooses the directory that holds the transcripts and
|
|
88
|
+
the generated Markdown. It accepts an absolute path or a path relative to the
|
|
89
|
+
directory of `youtube_to_markdown.yaml`; it defaults to `contents`. Unlike the
|
|
90
|
+
other settings, it can only be set in the YAML file — there is no flag or
|
|
91
|
+
environment variable for it. `.gitignore` only ignores the default `contents/`,
|
|
92
|
+
so add your own path there if you move it.
|
|
93
|
+
|
|
94
|
+
`folder_slug` (optional, on a channel) stores that channel's transcripts and
|
|
95
|
+
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 channel without
|
|
97
|
+
`folder_slug` keeps its files at the root of `contents_path`. The reserved name
|
|
98
|
+
`_orphans` is rejected. Change it whenever you like, then run
|
|
99
|
+
`youtube-to-markdown reorganize` to move the existing files.
|
|
100
|
+
|
|
101
|
+
## Transcripts
|
|
102
|
+
|
|
103
|
+
I mark the videos I want as transcripts with a `download_vtt` field:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
- title:
|
|
107
|
+
fr: Pourquoi π est-il si fou ? Relativité 1
|
|
108
|
+
en: Why is π so crazy? Relativity 1
|
|
109
|
+
date: 2016-02-12
|
|
110
|
+
url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
|
|
111
|
+
download_vtt:
|
|
112
|
+
fr: true
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
$ youtube-to-markdown download-vtt
|
|
119
|
+
21 transcript(s) to check
|
|
120
|
+
[ 1/21] downloaded 2016-07-14_le-sol-accelere-t-il-vraiment-vers-le-haut-debattonsmieux.fr.vtt
|
|
121
|
+
[ 2/21] already downloaded 2016-08-25_les-synonymes-a-connotations-opposees-debattonsmieux.fr.vtt
|
|
122
|
+
[ 3/21] missing 2017-06-26_favoriser-l-honnetete-democratie-18.fr.vtt
|
|
123
|
+
…
|
|
124
|
+
summary: 1 downloaded, 1 already downloaded, 1 missing, 0 error
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For each marked video, the command writes the subtitle to
|
|
128
|
+
`contents/<date>_<slug>.<lang>.vtt`, for example
|
|
129
|
+
`contents/2016-02-12_pourquoi-est-il-si-fou-relativite-1.fr.vtt`. It prefers
|
|
130
|
+
manual subtitles and falls back to the auto-generated ones. A file that already
|
|
131
|
+
exists is left untouched, so running the command again only fetches new
|
|
132
|
+
transcripts. The `extract-video-metadata` command keeps the `download_vtt`
|
|
133
|
+
field.
|
|
134
|
+
|
|
135
|
+
When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
|
|
136
|
+
marker and stops trying. I delete that marker to force a new attempt.
|
|
137
|
+
|
|
138
|
+
## Markdown
|
|
139
|
+
|
|
140
|
+
I mark the videos I want as Markdown with `generate_markdown`. The transcript is
|
|
141
|
+
downloaded first if it is not there yet:
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
download_vtt:
|
|
145
|
+
fr: true
|
|
146
|
+
generate_markdown:
|
|
147
|
+
fr: true
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Before the first run, provide your API key through the `OPENAI_API_KEY`
|
|
151
|
+
environment variable, or pass it with `--api-key`.
|
|
152
|
+
|
|
153
|
+
Then:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
$ youtube-to-markdown generate-markdown
|
|
157
|
+
74 markdown(s) to generate with mimo-v2.5
|
|
158
|
+
✔ Download transcripts (73/74)
|
|
159
|
+
› downloaded 2017-08-04_nietzsche-la-morale-des-winners-genealogie-de-la-morale-1-2.fr.md
|
|
160
|
+
› skipped (no transcript) 2017-06-26_favoriser-l-honnetete-democratie-18.fr.md
|
|
161
|
+
✔ Generate markdown (73/74, 37 already)
|
|
162
|
+
› generated 2017-10-13_le-scepticisme-le-trilemme-d-agrippa-grain-de-philo-14-ep-2.fr.md mimo-v2.5 llm 87.1s 4428 in / 3262 out ~$0.000927
|
|
163
|
+
…
|
|
164
|
+
summary: 36 generated, 37 already generated, 1 skipped, 0 error
|
|
165
|
+
total: 3135.6s, 159408 in / 117432 out, ~$0.033372
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The command sends each transcript to an LLM through ai-sdk, configured with the
|
|
169
|
+
API key (`OPENAI_API_KEY` or `--api-key`), the model (`YT_TO_MD_MODEL_ID` or
|
|
170
|
+
`--model`) and the endpoint (`YT_TO_MD_OPENAIAPI_ENDPOINT` or `--endpoint`), and
|
|
171
|
+
gets back Markdown prose with section headings when the talk needs them.
|
|
172
|
+
Paragraphs are hard-wrapped at 80 columns. The result goes to
|
|
173
|
+
`contents/<date>_<slug>.<lang>.md`. A second run reports `already generated` and
|
|
174
|
+
writes nothing.
|
|
175
|
+
|
|
176
|
+
The command runs in two sequential phases. `Download transcripts` reports how many
|
|
177
|
+
videos have a transcript (`available/total`) and fetches the missing ones, at
|
|
178
|
+
most `--ytdlp-concurrency` at a time (default 2); only downloads, skips and errors
|
|
179
|
+
are listed. The `Generate markdown` phase then converts the transcripts, at most
|
|
180
|
+
`--concurrency` at a time (default 6), reporting the number of up-to-date
|
|
181
|
+
Markdown files over the whole feed (`ready/total, N already`). Each phase keeps
|
|
182
|
+
only the last 20 events on screen, so it stays bounded even with a large feed.
|
|
183
|
+
|
|
184
|
+
Retryable API errors (HTTP 408, 409, 429 or >= 500) are retried with growing
|
|
185
|
+
delays — 10 s, 30 s, 60 s by default, configurable with `--retry-delays`
|
|
186
|
+
(comma-separated seconds, or `YT_TO_MD_RETRY_DELAYS`). A `Retry-After` header
|
|
187
|
+
from the server is honored when longer than the schedule. Each retry is shown in
|
|
188
|
+
the phase output; after the last attempt the file is reported as an error and is
|
|
189
|
+
retried on the next run.
|
|
190
|
+
|
|
191
|
+
### Frontmatter
|
|
192
|
+
|
|
193
|
+
Each generated file starts with a YAML frontmatter describing the run:
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
---
|
|
197
|
+
source_url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
|
|
198
|
+
video_title: Pourquoi π est-il si fou ? Relativité 1
|
|
199
|
+
generated_at: 2026-09-19T10:12:33.456Z
|
|
200
|
+
llm:
|
|
201
|
+
model: mimo-v2.5
|
|
202
|
+
duration_seconds: 87.1
|
|
203
|
+
input_tokens: 4428
|
|
204
|
+
cached_input_tokens: 0
|
|
205
|
+
output_tokens: 3262
|
|
206
|
+
estimated_cost_usd: 0.000927
|
|
207
|
+
finish_reason: stop
|
|
208
|
+
---
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`video_title` is the original video title in the file's language and
|
|
212
|
+
`generated_at` is the UTC time of the generation. `estimated_cost_usd` is `null`
|
|
213
|
+
when the model is not in the price table. Existing files are left untouched: run
|
|
214
|
+
`youtube-to-markdown generate-markdown --force` to regenerate every file, which
|
|
215
|
+
is also the way to add the frontmatter to files generated before it existed.
|
|
216
|
+
|
|
217
|
+
## Folders
|
|
218
|
+
|
|
219
|
+
A channel may set `folder_slug` to keep its files in their own subdirectory of
|
|
220
|
+
`contents_path`. When you add, change or remove that setting, `reorganize` moves
|
|
221
|
+
the already downloaded transcripts and generated Markdown to match. It runs for
|
|
222
|
+
real by default; add `--dry-run` to preview:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
$ youtube-to-markdown reorganize --dry-run
|
|
226
|
+
[ 1/38] moved science-4-all/2016-07-14_le-sol-accelere….fr.vtt (dry run)
|
|
227
|
+
…
|
|
228
|
+
dry run: no file was moved
|
|
229
|
+
summary: 38 to move, 0 to orphan, 112 already in place, 0 conflict
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
A file that no longer matches any video of the feed is an orphan: the command
|
|
233
|
+
moves it under `contents_path/_orphans/`, keeping its relative path. A file whose
|
|
234
|
+
target already exists, or a filename shared by two channels with different
|
|
235
|
+
`folder_slug`, is reported as a conflict and left untouched.
|
|
236
|
+
|
|
237
|
+
## Stats
|
|
238
|
+
|
|
239
|
+
`markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
|
|
240
|
+
(recursively) and prints global totals — tokens, estimated cost, processing
|
|
241
|
+
time — with a breakdown per model, per language and per folder (`folder_slug`,
|
|
242
|
+
or `(root)` when the file sits at the root of `contents_path`):
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
$ youtube-to-markdown markdown-stats
|
|
246
|
+
contents/ — 43 file(s)
|
|
247
|
+
with frontmatter 3
|
|
248
|
+
without frontmatter 40
|
|
249
|
+
|
|
250
|
+
tokens
|
|
251
|
+
input 16,134
|
|
252
|
+
cached input 16,000
|
|
253
|
+
output 14,486
|
|
254
|
+
total 30,620
|
|
255
|
+
|
|
256
|
+
cost
|
|
257
|
+
estimated $0.004120
|
|
258
|
+
unknown prices 0 file(s)
|
|
259
|
+
|
|
260
|
+
processing time
|
|
261
|
+
total 395.6s
|
|
262
|
+
mean 131.9s
|
|
263
|
+
min / max 111.8s / 165.3s
|
|
264
|
+
|
|
265
|
+
generated_at
|
|
266
|
+
first 2026-09-19T08:56:54.497Z
|
|
267
|
+
last 2026-09-19T08:57:47.976Z
|
|
268
|
+
|
|
269
|
+
models
|
|
270
|
+
mimo-v2.5 3 file(s) 16,134 in / 14,486 out $0.004120
|
|
271
|
+
|
|
272
|
+
languages
|
|
273
|
+
fr 3 file(s) 16,134 in / 14,486 out $0.004120
|
|
274
|
+
|
|
275
|
+
folders
|
|
276
|
+
science-4-all 2 file(s) 10,134 in / 9,486 out $0.002120
|
|
277
|
+
(root) 1 file(s) 6,000 in / 5,000 out $0.002000
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Files without frontmatter (generated before the frontmatter existed) are counted
|
|
281
|
+
but left out of the totals.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
To work on this project, [mise](https://mise.jdx.dev/) installs both yt-dlp and
|
|
286
|
+
Node.js and defines the `youtube-to-markdown` shell alias; `npm install` fetches
|
|
287
|
+
the JavaScript dependencies:
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
$ mise install
|
|
291
|
+
$ npm install
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The shell alias maps `youtube-to-markdown` to `node src/cli.js`. It is set when
|
|
295
|
+
you enter the project from an interactive bash, zsh or fish shell, so it is only
|
|
296
|
+
available in interactive shells; in scripts and CI, call
|
|
297
|
+
`node src/cli.js <command>` instead.
|
|
298
|
+
|
|
299
|
+
To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
|
|
300
|
+
`.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
|
|
301
|
+
that file automatically:
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
$ cp .secret.sh.example .secret.sh
|
|
305
|
+
$ $EDITOR .secret.sh
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Publish
|
|
309
|
+
|
|
310
|
+
I publish the package to npmjs from this repository with mise:
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
$ npm login
|
|
314
|
+
$ npm version patch
|
|
315
|
+
$ mise run publish-to-npmjs
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`npm login` authenticates against npmjs, once per machine. `npm version patch`
|
|
319
|
+
bumps the version and creates the matching git tag (use `minor` or `major` for
|
|
320
|
+
larger changes). `mise run publish-to-npmjs` first checks the authentication with
|
|
321
|
+
`npm whoami`, then runs `npm publish`; it publishes the version written in
|
|
322
|
+
`package.json`, so bump it first. The package is scoped, so `publishConfig` in
|
|
323
|
+
`package.json` forces public access.
|
|
324
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Stéphane Klein <contact@stephane-klein.info>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/stephane-klein/youtube-feed-to-markdown.git"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"youtube-to-markdown": "src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"youtube-to-markdown": "node src/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@ai-sdk/openai-compatible": "~3.0.51",
|
|
29
|
+
"ai": "~7.0.105",
|
|
30
|
+
"listr2": "~11.1.0",
|
|
31
|
+
"yaml": "~2.9.1",
|
|
32
|
+
"yargs": "~18.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import { loadConfig, loadEnv, settingsFromConfig } from "./config.js";
|
|
8
|
+
import { runExtract } from "./extract-video-metadata.js";
|
|
9
|
+
import { runDownloadVtt } from "./download-vtt.js";
|
|
10
|
+
import { runGenerateMarkdown } from "./generate-markdown.js";
|
|
11
|
+
import { runMarkdownStats } from "./markdown-stats.js";
|
|
12
|
+
import { runReorganize } from "./organize-files.js";
|
|
13
|
+
|
|
14
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
15
|
+
|
|
16
|
+
const SETTING_KEYS = [
|
|
17
|
+
"model",
|
|
18
|
+
"endpoint",
|
|
19
|
+
"apiKey",
|
|
20
|
+
"session",
|
|
21
|
+
"concurrency",
|
|
22
|
+
"ytdlpConcurrency",
|
|
23
|
+
"retryDelays",
|
|
24
|
+
"maxOutputTokens",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const handle = (run) => async (argv) => {
|
|
28
|
+
try {
|
|
29
|
+
await run(argv);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(`error: ${error.message}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const config = {
|
|
37
|
+
describe: "Path to the YAML configuration file",
|
|
38
|
+
type: "string",
|
|
39
|
+
default: "youtube_to_markdown.yaml",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const project = (argv) => {
|
|
43
|
+
const yaml = loadConfig(argv.config);
|
|
44
|
+
const settings = { ...settingsFromConfig(yaml) };
|
|
45
|
+
for (const key of SETTING_KEYS) {
|
|
46
|
+
if (argv[key] !== undefined) settings[key] = argv[key];
|
|
47
|
+
}
|
|
48
|
+
const dir = resolve(dirname(argv.config), yaml.contents_path ?? "contents");
|
|
49
|
+
return { path: argv.config, config: yaml, feed: yaml.feed ?? [], settings, dir };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
yargs(hideBin(process.argv))
|
|
53
|
+
.scriptName("youtube-to-markdown")
|
|
54
|
+
.config(loadEnv())
|
|
55
|
+
.option("config", config)
|
|
56
|
+
.command(
|
|
57
|
+
"extract-video-metadata",
|
|
58
|
+
"Fetch the channel videos from YouTube into the configuration file",
|
|
59
|
+
(yargs) =>
|
|
60
|
+
yargs
|
|
61
|
+
.option("force-dates", {
|
|
62
|
+
describe: "Fetch every upload date again, ignoring the known ones",
|
|
63
|
+
type: "boolean",
|
|
64
|
+
default: false,
|
|
65
|
+
}),
|
|
66
|
+
handle(async (argv) => {
|
|
67
|
+
const { path, config: yaml } = project(argv);
|
|
68
|
+
await runExtract({ path, config: yaml, forceDates: argv.forceDates });
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
.command(
|
|
72
|
+
"download-vtt",
|
|
73
|
+
"Download the VTT transcripts of the marked videos",
|
|
74
|
+
() => {},
|
|
75
|
+
handle(async (argv) => {
|
|
76
|
+
const { feed, dir } = project(argv);
|
|
77
|
+
await runDownloadVtt({ feed, dir });
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
.command(
|
|
81
|
+
"generate-markdown",
|
|
82
|
+
"Turn the VTT transcripts into Markdown prose with an LLM",
|
|
83
|
+
(yargs) =>
|
|
84
|
+
yargs
|
|
85
|
+
.option("model", {
|
|
86
|
+
describe: "LLM model id (default: model_id in the configuration)",
|
|
87
|
+
type: "string",
|
|
88
|
+
})
|
|
89
|
+
.option("endpoint", {
|
|
90
|
+
describe: "OpenAI-compatible chat completions endpoint",
|
|
91
|
+
type: "string",
|
|
92
|
+
})
|
|
93
|
+
.option("api-key", {
|
|
94
|
+
describe: "API key (defaults to OPENAI_API_KEY)",
|
|
95
|
+
type: "string",
|
|
96
|
+
})
|
|
97
|
+
.option("session", {
|
|
98
|
+
describe: "Value of the X-OpenCode-Session header",
|
|
99
|
+
type: "string",
|
|
100
|
+
})
|
|
101
|
+
.option("concurrency", {
|
|
102
|
+
describe: "Maximum number of concurrent LLM calls (default: 6)",
|
|
103
|
+
type: "number",
|
|
104
|
+
})
|
|
105
|
+
.option("ytdlp-concurrency", {
|
|
106
|
+
describe: "Maximum number of concurrent transcript downloads (default: 2)",
|
|
107
|
+
type: "number",
|
|
108
|
+
})
|
|
109
|
+
.option("retry-delays", {
|
|
110
|
+
describe: "Comma-separated retry delays in seconds (default: 10,30,60)",
|
|
111
|
+
type: "string",
|
|
112
|
+
})
|
|
113
|
+
.option("max-output-tokens", {
|
|
114
|
+
describe: "Override the computed output token budget",
|
|
115
|
+
type: "number",
|
|
116
|
+
})
|
|
117
|
+
.option("force", {
|
|
118
|
+
describe: "Regenerate every Markdown file",
|
|
119
|
+
type: "boolean",
|
|
120
|
+
default: false,
|
|
121
|
+
}),
|
|
122
|
+
handle(async (argv) => {
|
|
123
|
+
const { feed, settings, dir } = project(argv);
|
|
124
|
+
await runGenerateMarkdown({ feed, ...settings, dir, force: argv.force });
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
.command(
|
|
128
|
+
"markdown-stats",
|
|
129
|
+
"Report global tokens, cost and time from the Markdown frontmatter",
|
|
130
|
+
() => {},
|
|
131
|
+
handle(async (argv) => {
|
|
132
|
+
const { dir } = project(argv);
|
|
133
|
+
await runMarkdownStats({ dir });
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
.command(
|
|
137
|
+
"reorganize",
|
|
138
|
+
"Move the transcripts and Markdown into their folder_slug folders",
|
|
139
|
+
(yargs) =>
|
|
140
|
+
yargs.option("dry-run", {
|
|
141
|
+
describe: "Show the moves without doing them",
|
|
142
|
+
type: "boolean",
|
|
143
|
+
default: false,
|
|
144
|
+
}),
|
|
145
|
+
handle(async (argv) => {
|
|
146
|
+
const { feed, dir } = project(argv);
|
|
147
|
+
await runReorganize({ feed, dir, dryRun: argv.dryRun });
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
.demandCommand(1, "Use one of the available commands")
|
|
151
|
+
.strictCommands()
|
|
152
|
+
.recommendCommands()
|
|
153
|
+
.version(version)
|
|
154
|
+
.help()
|
|
155
|
+
.parse();
|
package/src/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
|
|
4
|
+
export function loadConfig(path) {
|
|
5
|
+
return parse(readFileSync(path, "utf8")) ?? {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const YAML_KEYS = {
|
|
9
|
+
model: "model_id",
|
|
10
|
+
endpoint: "openaiapi_endpoint",
|
|
11
|
+
apiKey: "api_key",
|
|
12
|
+
session: "x_opencode_session",
|
|
13
|
+
concurrency: "concurrency",
|
|
14
|
+
ytdlpConcurrency: "ytdlp_concurrency",
|
|
15
|
+
retryDelays: "retry_delays",
|
|
16
|
+
maxOutputTokens: "max_output_tokens",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function settingsFromConfig(config) {
|
|
20
|
+
return Object.fromEntries(
|
|
21
|
+
Object.entries(YAML_KEYS)
|
|
22
|
+
.map(([key, yamlKey]) => [key, config[yamlKey]])
|
|
23
|
+
.filter(([, value]) => value !== undefined),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ENV = {
|
|
28
|
+
config: "YT_TO_MD_CONFIG",
|
|
29
|
+
model: "YT_TO_MD_MODEL_ID",
|
|
30
|
+
endpoint: "YT_TO_MD_OPENAIAPI_ENDPOINT",
|
|
31
|
+
apiKey: "YT_TO_MD_API_KEY",
|
|
32
|
+
session: "YT_TO_MD_X_OPENCODE_SESSION",
|
|
33
|
+
concurrency: "YT_TO_MD_CONCURRENCY",
|
|
34
|
+
ytdlpConcurrency: "YT_TO_MD_YTDLP_CONCURRENCY",
|
|
35
|
+
retryDelays: "YT_TO_MD_RETRY_DELAYS",
|
|
36
|
+
maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function loadEnv(env = process.env) {
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(ENV)
|
|
42
|
+
.map(([key, name]) => [key, env[name]])
|
|
43
|
+
.filter(([, value]) => value !== undefined),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, relative } from "node:path";
|
|
3
|
+
import { DEFAULT_DIR, downloadVtt, exists, videoJobs, writeMarker } from "./vtt.js";
|
|
4
|
+
|
|
5
|
+
export async function runDownloadVtt({ feed = [], dir = DEFAULT_DIR } = {}) {
|
|
6
|
+
await mkdir(dir, { recursive: true });
|
|
7
|
+
|
|
8
|
+
const jobs = feed.flatMap((channel) =>
|
|
9
|
+
(channel.videos ?? []).flatMap((video) =>
|
|
10
|
+
videoJobs(video, dir, channel.folder_slug),
|
|
11
|
+
),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
console.error(`${jobs.length} transcript(s) to check`);
|
|
15
|
+
|
|
16
|
+
const width = String(jobs.length).length;
|
|
17
|
+
let downloaded = 0;
|
|
18
|
+
let present = 0;
|
|
19
|
+
let missing = 0;
|
|
20
|
+
let errors = 0;
|
|
21
|
+
|
|
22
|
+
for (const [index, job] of jobs.entries()) {
|
|
23
|
+
const prefix = `[${String(index + 1).padStart(width)}/${jobs.length}]`;
|
|
24
|
+
const name = relative(dir, job.vtt);
|
|
25
|
+
|
|
26
|
+
if (await exists(job.vtt)) {
|
|
27
|
+
present++;
|
|
28
|
+
console.error(`${prefix} ${"already downloaded".padEnd(18)} ${name}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (await exists(job.marker)) {
|
|
33
|
+
missing++;
|
|
34
|
+
console.error(`${prefix} ${"missing".padEnd(18)} ${name}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await mkdir(dirname(job.vtt), { recursive: true });
|
|
39
|
+
|
|
40
|
+
const status = await downloadVtt(job.url, job.lang, job.vtt);
|
|
41
|
+
if (status === "downloaded") {
|
|
42
|
+
downloaded++;
|
|
43
|
+
console.error(`${prefix} ${"downloaded".padEnd(18)} ${name}`);
|
|
44
|
+
} else if (status === "missing") {
|
|
45
|
+
await writeMarker(job.marker, job.url);
|
|
46
|
+
missing++;
|
|
47
|
+
console.error(`${prefix} ${"missing".padEnd(18)} ${name}`);
|
|
48
|
+
} else {
|
|
49
|
+
errors++;
|
|
50
|
+
console.error(
|
|
51
|
+
`${prefix} ${"error".padEnd(18)} ${name} (will retry on next run)`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.error(
|
|
57
|
+
`summary: ${downloaded} downloaded, ${present} already downloaded, ${missing} missing, ${errors} error`,
|
|
58
|
+
);
|
|
59
|
+
}
|