@stephane-klein/youtube-to-markdown 0.1.0 → 0.2.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 +130 -38
- package/package.json +1 -1
- package/src/cli.js +63 -5
- package/src/doctor.js +211 -0
- package/src/generate-markdown.js +12 -9
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 a YouTube channel's metadata
|
|
11
|
+
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,93 @@ 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 channel videos from YouTube into the
|
|
50
|
+
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
|
+
```
|
|
34
67
|
|
|
35
|
-
|
|
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 channels.
|
|
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
|
+
llm 1.0s ok (248 in / 8 out)
|
|
119
|
+
|
|
120
|
+
summary: 8 ok, 0 warning, 0 error
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`doctor` exits with a non-zero status as soon as one line is an `error`, so it
|
|
124
|
+
can gate a CI job.
|
|
125
|
+
|
|
126
|
+
**4. Fetch the channel videos** into the file:
|
|
36
127
|
|
|
37
128
|
```sh
|
|
38
129
|
$ youtube-to-markdown extract-video-metadata
|
|
@@ -40,29 +131,28 @@ $ youtube-to-markdown extract-video-metadata
|
|
|
40
131
|
→ https://www.youtube.com/@MonsieurPhi
|
|
41
132
|
```
|
|
42
133
|
|
|
43
|
-
|
|
44
|
-
`download-vtt`, `generate-markdown`, `markdown-stats` and `reorganize`. Run
|
|
45
|
-
`youtube-to-markdown <command> --help` for the available options.
|
|
134
|
+
The command is idempotent: a new run only adds the missing videos.
|
|
46
135
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
YAML file, then the built-in defaults. The API key stays a secret: `--api-key` or
|
|
51
|
-
`OPENAI_API_KEY`.
|
|
136
|
+
**5. Generate the Markdown** with `download-vtt`, then `generate-markdown`. The
|
|
137
|
+
other commands are `markdown-stats`, `reorganize` and `doctor`; run
|
|
138
|
+
`youtube-to-markdown <command> --help` for their options.
|
|
52
139
|
|
|
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.
|
|
140
|
+
## Configure the settings and the channels
|
|
57
141
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
142
|
+
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 channels.
|
|
144
|
+
Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
|
|
145
|
+
variables, then the settings written in the YAML file, then the built-in
|
|
146
|
+
defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
|
|
147
|
+
settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
|
|
148
|
+
`concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
|
|
149
|
+
`retry_delays` and `max_output_tokens`). I only set the channel URLs, and the
|
|
64
150
|
`extract-video-metadata` command fills the rest:
|
|
65
151
|
|
|
152
|
+
`x_opencode_session` is sent as the `X-OpenCode-Session` header; it is optional
|
|
153
|
+
and specific to OpenCode. `model_id` and `openaiapi_endpoint` are not: point them
|
|
154
|
+
at any OpenAI-compatible provider.
|
|
155
|
+
|
|
66
156
|
```yaml
|
|
67
157
|
x_opencode_session: "youtube-to-markdown/0.1"
|
|
68
158
|
model_id: "mimo-v2.5"
|
|
@@ -98,7 +188,7 @@ It is a relative path, so it stays inside `contents_path`; a channel without
|
|
|
98
188
|
`_orphans` is rejected. Change it whenever you like, then run
|
|
99
189
|
`youtube-to-markdown reorganize` to move the existing files.
|
|
100
190
|
|
|
101
|
-
##
|
|
191
|
+
## Download the transcripts of the videos
|
|
102
192
|
|
|
103
193
|
I mark the videos I want as transcripts with a `download_vtt` field:
|
|
104
194
|
|
|
@@ -135,7 +225,7 @@ field.
|
|
|
135
225
|
When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
|
|
136
226
|
marker and stops trying. I delete that marker to force a new attempt.
|
|
137
227
|
|
|
138
|
-
## Markdown
|
|
228
|
+
## Generate Markdown text from the transcripts
|
|
139
229
|
|
|
140
230
|
I mark the videos I want as Markdown with `generate_markdown`. The transcript is
|
|
141
231
|
downloaded first if it is not there yet:
|
|
@@ -188,7 +278,7 @@ from the server is honored when longer than the schedule. Each retry is shown in
|
|
|
188
278
|
the phase output; after the last attempt the file is reported as an error and is
|
|
189
279
|
retried on the next run.
|
|
190
280
|
|
|
191
|
-
###
|
|
281
|
+
### Generated frontmatter
|
|
192
282
|
|
|
193
283
|
Each generated file starts with a YAML frontmatter describing the run:
|
|
194
284
|
|
|
@@ -214,7 +304,7 @@ when the model is not in the price table. Existing files are left untouched: run
|
|
|
214
304
|
`youtube-to-markdown generate-markdown --force` to regenerate every file, which
|
|
215
305
|
is also the way to add the frontmatter to files generated before it existed.
|
|
216
306
|
|
|
217
|
-
##
|
|
307
|
+
## Organize the files into folders
|
|
218
308
|
|
|
219
309
|
A channel may set `folder_slug` to keep its files in their own subdirectory of
|
|
220
310
|
`contents_path`. When you add, change or remove that setting, `reorganize` moves
|
|
@@ -234,7 +324,7 @@ moves it under `contents_path/_orphans/`, keeping its relative path. A file whos
|
|
|
234
324
|
target already exists, or a filename shared by two channels with different
|
|
235
325
|
`folder_slug`, is reported as a conflict and left untouched.
|
|
236
326
|
|
|
237
|
-
##
|
|
327
|
+
## Report the tokens, cost and time
|
|
238
328
|
|
|
239
329
|
`markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
|
|
240
330
|
(recursively) and prints global totals — tokens, estimated cost, processing
|
|
@@ -280,21 +370,23 @@ folders
|
|
|
280
370
|
Files without frontmatter (generated before the frontmatter existed) are counted
|
|
281
371
|
but left out of the totals.
|
|
282
372
|
|
|
283
|
-
##
|
|
373
|
+
## Set up the development environment
|
|
284
374
|
|
|
285
375
|
To work on this project, [mise](https://mise.jdx.dev/) installs both yt-dlp and
|
|
286
|
-
Node.js
|
|
287
|
-
the JavaScript dependencies:
|
|
376
|
+
Node.js; `npm install` fetches the JavaScript dependencies:
|
|
288
377
|
|
|
289
378
|
```sh
|
|
290
379
|
$ mise install
|
|
291
380
|
$ npm install
|
|
292
381
|
```
|
|
293
382
|
|
|
294
|
-
The
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
383
|
+
The `enter` hook defines a `youtube-to-markdown` shell function that calls
|
|
384
|
+
`node src/cli.js`, and loads the shell completion for bash and zsh; the `leave`
|
|
385
|
+
hook removes both. Everything is set when you enter the project from an
|
|
386
|
+
interactive bash or zsh shell, so it is only available in interactive shells; in
|
|
387
|
+
scripts and CI, call `node src/cli.js <command>` instead. A function, rather than
|
|
388
|
+
a shell alias, is what lets `youtube-to-markdown <TAB>` complete the subcommands:
|
|
389
|
+
zsh expands aliases before dispatching completion, which would bypass `compdef`.
|
|
298
390
|
|
|
299
391
|
To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
|
|
300
392
|
`.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
|
|
@@ -305,7 +397,7 @@ $ cp .secret.sh.example .secret.sh
|
|
|
305
397
|
$ $EDITOR .secret.sh
|
|
306
398
|
```
|
|
307
399
|
|
|
308
|
-
## Publish
|
|
400
|
+
## Publish the package to npmjs
|
|
309
401
|
|
|
310
402
|
I publish the package to npmjs from this repository with mise:
|
|
311
403
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,8 +50,9 @@ 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(
|
|
@@ -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,211 @@
|
|
|
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 { exists } from "./vtt.js";
|
|
7
|
+
import { runYtDlp } from "./ytdlp.js";
|
|
8
|
+
|
|
9
|
+
const MIN_NODE_MAJOR = 22;
|
|
10
|
+
|
|
11
|
+
function resolveApiKey(overrides, config, env = process.env) {
|
|
12
|
+
if (overrides.apiKey !== undefined) {
|
|
13
|
+
const envKey = env.YT_TO_MD_API_KEY;
|
|
14
|
+
const source = envKey !== undefined && envKey === overrides.apiKey
|
|
15
|
+
? "YT_TO_MD_API_KEY"
|
|
16
|
+
: "--api-key";
|
|
17
|
+
return { key: overrides.apiKey, source };
|
|
18
|
+
}
|
|
19
|
+
if (config?.api_key !== undefined) {
|
|
20
|
+
return { key: config.api_key, source: "api_key (config)" };
|
|
21
|
+
}
|
|
22
|
+
if (env.OPENAI_API_KEY) {
|
|
23
|
+
return { key: env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
|
|
24
|
+
}
|
|
25
|
+
return { key: undefined, source: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function checkContents(dir) {
|
|
29
|
+
try {
|
|
30
|
+
await access(dir, constants.W_OK);
|
|
31
|
+
return { status: "ok", note: null };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT") {
|
|
34
|
+
return { status: "error", note: "not writable" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let parent = dirname(dir);
|
|
38
|
+
while (true) {
|
|
39
|
+
try {
|
|
40
|
+
await access(parent, constants.W_OK);
|
|
41
|
+
return { status: "warn", note: "not created yet" };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== "ENOENT") {
|
|
44
|
+
return { status: "error", note: "not writable" };
|
|
45
|
+
}
|
|
46
|
+
const next = dirname(parent);
|
|
47
|
+
if (next === parent) return { status: "error", note: "not writable" };
|
|
48
|
+
parent = next;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkNode() {
|
|
54
|
+
const version = process.versions.node;
|
|
55
|
+
const major = Number(version.split(".")[0]);
|
|
56
|
+
return major >= MIN_NODE_MAJOR
|
|
57
|
+
? { status: "ok", value: `v${version}`, note: null }
|
|
58
|
+
: { status: "error", value: `v${version}`, note: `requires >= ${MIN_NODE_MAJOR}` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function checkYtDlp() {
|
|
62
|
+
try {
|
|
63
|
+
const { stdout } = await runYtDlp(["--version"]);
|
|
64
|
+
return { status: "ok", value: stdout.trim() || "found", note: null };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { status: "error", value: "-", note: error.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadConfigSafe(configPath) {
|
|
71
|
+
if (!(await exists(configPath))) {
|
|
72
|
+
return {
|
|
73
|
+
config: null,
|
|
74
|
+
row: { status: "warn", value: configPath, note: "not found" },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const config = loadConfig(configPath);
|
|
79
|
+
const channels = config.feed?.length ?? 0;
|
|
80
|
+
const videos = (config.feed ?? []).reduce(
|
|
81
|
+
(total, channel) => total + (channel.videos?.length ?? 0),
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
return {
|
|
85
|
+
config,
|
|
86
|
+
row: {
|
|
87
|
+
status: "ok",
|
|
88
|
+
value: configPath,
|
|
89
|
+
note: `${channels} channel(s), ${videos} video(s)`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return {
|
|
94
|
+
config: null,
|
|
95
|
+
row: { status: "error", value: configPath, note: error.message },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkEndpoint(endpoint) {
|
|
101
|
+
if (!endpoint) return { status: "error", value: "-", note: "not set" };
|
|
102
|
+
try {
|
|
103
|
+
new URL(endpoint);
|
|
104
|
+
return { status: "ok", value: endpoint, note: null };
|
|
105
|
+
} catch {
|
|
106
|
+
return { status: "error", value: endpoint, note: "not a valid URL" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function checkLlm({ llm, settings, key, timeoutMs }) {
|
|
111
|
+
if (!llm) {
|
|
112
|
+
return { status: "warn", value: "-", note: "skipped (--no-llm)" };
|
|
113
|
+
}
|
|
114
|
+
if (!settings.model) return { status: "error", value: "-", note: "model not set" };
|
|
115
|
+
if (!settings.endpoint) return { status: "error", value: "-", note: "endpoint not set" };
|
|
116
|
+
if (!key) return { status: "error", value: "-", note: "api key not set" };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const model = createModel({
|
|
120
|
+
modelId: settings.model,
|
|
121
|
+
endpoint: settings.endpoint,
|
|
122
|
+
apiKey: key,
|
|
123
|
+
session: settings.session,
|
|
124
|
+
});
|
|
125
|
+
const started = performance.now();
|
|
126
|
+
const { usage } = await generateText({
|
|
127
|
+
model,
|
|
128
|
+
prompt: "ping",
|
|
129
|
+
maxOutputTokens: 8,
|
|
130
|
+
maxRetries: 0,
|
|
131
|
+
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
132
|
+
});
|
|
133
|
+
const elapsed = (performance.now() - started) / 1000;
|
|
134
|
+
return {
|
|
135
|
+
status: "ok",
|
|
136
|
+
value: `${elapsed.toFixed(1)}s`,
|
|
137
|
+
note: `${usage?.inputTokens ?? 0} in / ${usage?.outputTokens ?? 0} out`,
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { status: "error", value: "-", note: describeError(error, 1) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function render(rows) {
|
|
145
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
146
|
+
const valueWidth = Math.max(...rows.map((row) => row.value.length));
|
|
147
|
+
return rows.map((row) => {
|
|
148
|
+
const note = row.note ? ` (${row.note})` : "";
|
|
149
|
+
return `${row.label.padEnd(labelWidth)} ${row.value.padEnd(valueWidth)} ${row.status}${note}`;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function runDoctor({
|
|
154
|
+
configPath = "youtube_to_markdown.yaml",
|
|
155
|
+
overrides = {},
|
|
156
|
+
llm = true,
|
|
157
|
+
timeoutMs = 30000,
|
|
158
|
+
} = {}) {
|
|
159
|
+
const { config, row: configRow } = await loadConfigSafe(configPath);
|
|
160
|
+
|
|
161
|
+
const settings = { ...settingsFromConfig(config ?? {}) };
|
|
162
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
163
|
+
if (value !== undefined) settings[key] = value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { key, source } = resolveApiKey(overrides, config);
|
|
167
|
+
const dir = resolve(dirname(configPath), config?.contents_path ?? "contents");
|
|
168
|
+
|
|
169
|
+
const rows = [
|
|
170
|
+
{ label: "node", ...checkNode() },
|
|
171
|
+
{ label: "yt-dlp", ...(await checkYtDlp()) },
|
|
172
|
+
];
|
|
173
|
+
rows.push({ label: "config", ...configRow });
|
|
174
|
+
rows.push({
|
|
175
|
+
label: "contents",
|
|
176
|
+
value: config?.contents_path ?? "contents",
|
|
177
|
+
...(await checkContents(dir)),
|
|
178
|
+
});
|
|
179
|
+
rows.push({
|
|
180
|
+
label: "model",
|
|
181
|
+
value: settings.model ?? "-",
|
|
182
|
+
status: settings.model ? "ok" : "error",
|
|
183
|
+
note: settings.model ? null : "not set",
|
|
184
|
+
});
|
|
185
|
+
rows.push({ label: "endpoint", ...checkEndpoint(settings.endpoint) });
|
|
186
|
+
rows.push({
|
|
187
|
+
label: "api key",
|
|
188
|
+
value: source ?? "-",
|
|
189
|
+
status: key ? "ok" : "error",
|
|
190
|
+
note: key ? null : "not set",
|
|
191
|
+
});
|
|
192
|
+
if (settings.session) {
|
|
193
|
+
rows.push({ label: "session", value: "set", status: "info", note: null });
|
|
194
|
+
}
|
|
195
|
+
const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
|
|
196
|
+
rows.push({ label: "llm", ...llmRow });
|
|
197
|
+
|
|
198
|
+
const counts = { ok: 0, warn: 0, error: 0 };
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
if (row.status in counts) counts[row.status]++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(`youtube-to-markdown doctor`);
|
|
204
|
+
console.log(render(rows).join("\n"));
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(
|
|
207
|
+
`summary: ${counts.ok} ok, ${counts.warn} warning, ${counts.error} error`,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (counts.error > 0) process.exitCode = 1;
|
|
211
|
+
}
|
package/src/generate-markdown.js
CHANGED
|
@@ -183,7 +183,7 @@ function retryDelayMs(error, fallbackMs) {
|
|
|
183
183
|
return Math.max(headerMs, fallbackMs);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
function describeError(error, attempts) {
|
|
186
|
+
export function describeError(error, attempts) {
|
|
187
187
|
const suffix = ` after ${attempts} attempt(s)`;
|
|
188
188
|
if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
|
|
189
189
|
|
|
@@ -201,6 +201,16 @@ function parseRetryDelays(value) {
|
|
|
201
201
|
.filter((item) => Number.isFinite(item) && item >= 0);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
export function createModel({ modelId, endpoint, apiKey, session }) {
|
|
205
|
+
const provider = createOpenAICompatible({
|
|
206
|
+
name: "opencode",
|
|
207
|
+
apiKey,
|
|
208
|
+
baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
|
|
209
|
+
headers: session ? { "X-OpenCode-Session": session } : {},
|
|
210
|
+
});
|
|
211
|
+
return provider(modelId);
|
|
212
|
+
}
|
|
213
|
+
|
|
204
214
|
async function toMarkdown(
|
|
205
215
|
{ model, retryDelays, maxOutputTokens },
|
|
206
216
|
title,
|
|
@@ -274,14 +284,7 @@ export async function runGenerateMarkdown({
|
|
|
274
284
|
const llmConcurrency = Number(concurrency);
|
|
275
285
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
276
286
|
|
|
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);
|
|
287
|
+
const model = createModel({ modelId, endpoint, apiKey: key, session });
|
|
285
288
|
|
|
286
289
|
await mkdir(dir, { recursive: true });
|
|
287
290
|
|