@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { stringify } from "yaml";
|
|
3
|
+
import { runYtDlp } from "./ytdlp.js";
|
|
4
|
+
|
|
5
|
+
const DATE_CHUNK = 50;
|
|
6
|
+
|
|
7
|
+
const toIso = (raw) =>
|
|
8
|
+
/^\d{8}$/.test(raw)
|
|
9
|
+
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
|
10
|
+
: "";
|
|
11
|
+
|
|
12
|
+
async function ytDlp(args, { allowFailure = false } = {}) {
|
|
13
|
+
const { stdout } = await runYtDlp(args, { stderr: "inherit", allowFailure });
|
|
14
|
+
return stdout;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function uploadsPlaylistUrl(channelUrl) {
|
|
18
|
+
const out = await ytDlp([
|
|
19
|
+
"--flat-playlist",
|
|
20
|
+
"--playlist-end",
|
|
21
|
+
"1",
|
|
22
|
+
"-O",
|
|
23
|
+
"%(playlist_channel_id)s",
|
|
24
|
+
channelUrl,
|
|
25
|
+
]);
|
|
26
|
+
const channelId = out.split("\n").map((line) => line.trim()).filter(Boolean)[0];
|
|
27
|
+
|
|
28
|
+
if (!channelId?.startsWith("UC")) {
|
|
29
|
+
throw new Error(`ID de chaîne introuvable pour ${channelUrl}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `https://www.youtube.com/playlist?list=UU${channelId.slice(2)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function listEntries(playlistUrl, lang) {
|
|
36
|
+
const args = [
|
|
37
|
+
"--flat-playlist",
|
|
38
|
+
"-O",
|
|
39
|
+
'{"titre": %(title)j, "url": %(webpage_url)j}',
|
|
40
|
+
"--extractor-args",
|
|
41
|
+
`youtube:lang=${lang}`,
|
|
42
|
+
];
|
|
43
|
+
args.push(playlistUrl);
|
|
44
|
+
|
|
45
|
+
const out = await ytDlp(args);
|
|
46
|
+
return out
|
|
47
|
+
.split("\n")
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((line) => JSON.parse(line));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
|
|
53
|
+
|
|
54
|
+
async function fetchExactDates(urls) {
|
|
55
|
+
const dates = new Map();
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < urls.length; i += DATE_CHUNK) {
|
|
58
|
+
const chunk = urls.slice(i, i + DATE_CHUNK);
|
|
59
|
+
console.error(
|
|
60
|
+
` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
|
|
61
|
+
);
|
|
62
|
+
const out = await ytDlp(
|
|
63
|
+
[
|
|
64
|
+
"--skip-download",
|
|
65
|
+
"--no-playlist",
|
|
66
|
+
"--ignore-errors",
|
|
67
|
+
"--print",
|
|
68
|
+
"%(id)s|%(upload_date)s",
|
|
69
|
+
...chunk,
|
|
70
|
+
],
|
|
71
|
+
{ allowFailure: true },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
75
|
+
const [id, raw] = line.split("|");
|
|
76
|
+
const iso = toIso(raw ?? "");
|
|
77
|
+
if (id && iso) dates.set(id, iso);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return dates;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchVideos(channelUrl, knownDates) {
|
|
85
|
+
const playlistUrl = await uploadsPlaylistUrl(channelUrl);
|
|
86
|
+
const en = await listEntries(playlistUrl, "en");
|
|
87
|
+
const fr = await listEntries(playlistUrl, "fr");
|
|
88
|
+
const frTitles = new Map(fr.map((entry) => [entry.url, entry.titre]));
|
|
89
|
+
|
|
90
|
+
const videos = en
|
|
91
|
+
.filter((entry) => !entry.url.includes("/shorts/"))
|
|
92
|
+
.map((entry) => {
|
|
93
|
+
const enTitle = entry.titre.trim();
|
|
94
|
+
const frTitle = (frTitles.get(entry.url) ?? enTitle).trim();
|
|
95
|
+
const title =
|
|
96
|
+
frTitle && enTitle && frTitle !== enTitle
|
|
97
|
+
? { fr: frTitle, en: enTitle }
|
|
98
|
+
: { fr: frTitle || enTitle };
|
|
99
|
+
return { title, url: entry.url };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const missing = videos
|
|
103
|
+
.filter((video) => !knownDates.has(videoId(video.url)))
|
|
104
|
+
.map((video) => video.url);
|
|
105
|
+
const fetched = await fetchExactDates(missing);
|
|
106
|
+
|
|
107
|
+
for (const video of videos) {
|
|
108
|
+
const id = videoId(video.url);
|
|
109
|
+
video.date = knownDates.get(id) ?? fetched.get(id) ?? "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return videos
|
|
113
|
+
.map((video) => ({ title: video.title, date: video.date, url: video.url }))
|
|
114
|
+
.reverse();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeEntry(entry) {
|
|
118
|
+
const { titre, ...rest } = entry;
|
|
119
|
+
if (typeof titre === "string") {
|
|
120
|
+
return { title: { fr: titre }, ...rest };
|
|
121
|
+
}
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const MANAGED_KEYS = new Set(["title", "date", "url", "titre"]);
|
|
126
|
+
|
|
127
|
+
function userFields(entry) {
|
|
128
|
+
return Object.fromEntries(
|
|
129
|
+
Object.entries(entry).filter(([key]) => !MANAGED_KEYS.has(key)),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mergeVideos(existing, fetched) {
|
|
134
|
+
const existingByUrl = new Map(
|
|
135
|
+
existing.filter((v) => v.url).map((v) => [v.url, v]),
|
|
136
|
+
);
|
|
137
|
+
const merged = fetched.map((video) => {
|
|
138
|
+
const previous = existingByUrl.get(video.url);
|
|
139
|
+
return previous
|
|
140
|
+
? { ...video, ...userFields(previous), date: video.date || previous.date }
|
|
141
|
+
: video;
|
|
142
|
+
});
|
|
143
|
+
const fetchedUrls = new Set(fetched.map((v) => v.url));
|
|
144
|
+
const extras = existing
|
|
145
|
+
.filter((v) => v.url && !fetchedUrls.has(v.url))
|
|
146
|
+
.map(normalizeEntry);
|
|
147
|
+
return [...merged, ...extras].sort((a, b) =>
|
|
148
|
+
(a.date ?? "").localeCompare(b.date ?? ""),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function runExtract({
|
|
153
|
+
path = "youtube_to_markdown.yaml",
|
|
154
|
+
config = {},
|
|
155
|
+
forceDates = false,
|
|
156
|
+
} = {}) {
|
|
157
|
+
for (const channel of config.feed ?? []) {
|
|
158
|
+
if (typeof channel.url !== "string" || !channel.url) continue;
|
|
159
|
+
|
|
160
|
+
const videos = channel.videos ?? [];
|
|
161
|
+
const knownDates = forceDates
|
|
162
|
+
? new Map()
|
|
163
|
+
: new Map(
|
|
164
|
+
videos
|
|
165
|
+
.filter((video) => video.url && video.date)
|
|
166
|
+
.map((video) => [videoId(video.url), video.date]),
|
|
167
|
+
);
|
|
168
|
+
console.error(`→ ${channel.url}`);
|
|
169
|
+
channel.videos = mergeVideos(
|
|
170
|
+
videos,
|
|
171
|
+
await fetchVideos(channel.url, knownDates),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await writeFile(path, stringify(config, { lineWidth: 0 }));
|
|
176
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, relative } from "node:path";
|
|
3
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
4
|
+
import { APICallError, generateText } from "ai";
|
|
5
|
+
import { Listr, ListrLogger, ProcessOutput } from "listr2";
|
|
6
|
+
import { stringify } from "yaml";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_DIR,
|
|
9
|
+
downloadVtt,
|
|
10
|
+
exists,
|
|
11
|
+
titleForLang,
|
|
12
|
+
videoJobs,
|
|
13
|
+
writeMarker,
|
|
14
|
+
} from "./vtt.js";
|
|
15
|
+
|
|
16
|
+
const SYSTEM = [
|
|
17
|
+
"You convert a YouTube video transcript into clean, readable Markdown prose.",
|
|
18
|
+
"- Write in the same language as the transcript.",
|
|
19
|
+
"- Do not summarize and do not add facts that are not in the transcript.",
|
|
20
|
+
"- Merge the fragmented cues into full sentences and paragraphs.",
|
|
21
|
+
"- You may add section headings (##, ###) when the transcript clearly moves between topics.",
|
|
22
|
+
"- Do not add a main title, it is provided separately.",
|
|
23
|
+
"- Do not wrap the output in code fences.",
|
|
24
|
+
"- Output only the Markdown body.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
const WIDTH = 80;
|
|
28
|
+
|
|
29
|
+
const PRICES = {
|
|
30
|
+
"mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function cachedInputTokens(usage) {
|
|
34
|
+
const details = usage?.inputTokenDetails ?? {};
|
|
35
|
+
return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function estimateCost(modelId, usage) {
|
|
39
|
+
const price = PRICES[modelId];
|
|
40
|
+
if (!price || !usage) return null;
|
|
41
|
+
|
|
42
|
+
const details = usage.inputTokenDetails ?? {};
|
|
43
|
+
const cached = cachedInputTokens(usage);
|
|
44
|
+
const input =
|
|
45
|
+
details.noCacheTokens ?? Math.max((usage.inputTokens ?? 0) - cached, 0);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
(input / 1e6) * price.input +
|
|
49
|
+
(cached / 1e6) * (price.cachedInput ?? 0) +
|
|
50
|
+
((usage.outputTokens ?? 0) / 1e6) * price.output
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const OUTPUT_FACTOR = 2;
|
|
55
|
+
const OUTPUT_FLOOR = 2048;
|
|
56
|
+
const OUTPUT_CEILING = 32768;
|
|
57
|
+
|
|
58
|
+
function outputBudget(transcript, override) {
|
|
59
|
+
if (override !== undefined && override !== null && override !== "") {
|
|
60
|
+
return Number(override);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const inputTokens = Math.ceil(transcript.length / 3.5);
|
|
64
|
+
return Math.min(
|
|
65
|
+
OUTPUT_CEILING,
|
|
66
|
+
Math.max(OUTPUT_FLOOR, Math.ceil(inputTokens * OUTPUT_FACTOR)),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function metricsLine(modelId, usage, elapsed, cost) {
|
|
71
|
+
return [
|
|
72
|
+
modelId,
|
|
73
|
+
`llm ${elapsed.toFixed(1)}s`,
|
|
74
|
+
usage
|
|
75
|
+
? `${usage.inputTokens ?? 0} in / ${usage.outputTokens ?? 0} out`
|
|
76
|
+
: "usage ?",
|
|
77
|
+
cost === null ? null : `~$${cost.toFixed(6)}`,
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(" ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
|
|
84
|
+
const data = {
|
|
85
|
+
source_url: job.url,
|
|
86
|
+
video_title: titleForLang(job.titles, job.lang),
|
|
87
|
+
generated_at: new Date().toISOString(),
|
|
88
|
+
llm: {
|
|
89
|
+
model: modelId,
|
|
90
|
+
duration_seconds: Number(elapsed.toFixed(1)),
|
|
91
|
+
input_tokens: usage?.inputTokens ?? null,
|
|
92
|
+
cached_input_tokens: usage ? cachedInputTokens(usage) : null,
|
|
93
|
+
output_tokens: usage?.outputTokens ?? null,
|
|
94
|
+
estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
|
|
95
|
+
finish_reason: finishReason,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isHeading = (text) => /^#{1,6}\s/.test(text);
|
|
103
|
+
|
|
104
|
+
const isStructured = (text) =>
|
|
105
|
+
text
|
|
106
|
+
.split("\n")
|
|
107
|
+
.some((line) => /^\s*(-|\*|\d+\.)\s/.test(line) || /^\s*\|/.test(line));
|
|
108
|
+
|
|
109
|
+
function wrapText(text) {
|
|
110
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
111
|
+
const lines = [];
|
|
112
|
+
let line = "";
|
|
113
|
+
|
|
114
|
+
for (const word of words) {
|
|
115
|
+
if (!line) {
|
|
116
|
+
line = word;
|
|
117
|
+
} else if (line.length + 1 + word.length <= WIDTH) {
|
|
118
|
+
line += ` ${word}`;
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(line);
|
|
121
|
+
line = word;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (line) lines.push(line);
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hardWrap(body) {
|
|
130
|
+
return body
|
|
131
|
+
.split(/\n{2,}/)
|
|
132
|
+
.map((block) => {
|
|
133
|
+
const text = block.trim();
|
|
134
|
+
if (!text) return "";
|
|
135
|
+
if (isHeading(text) || isStructured(text)) return text;
|
|
136
|
+
return wrapText(text.replace(/\s*\n\s*/g, " "));
|
|
137
|
+
})
|
|
138
|
+
.join("\n\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function vttToText(vtt) {
|
|
142
|
+
const lines = vtt
|
|
143
|
+
.split(/\r?\n/)
|
|
144
|
+
.map((line) => line.replace(/<[^>]*>/g, "").trim())
|
|
145
|
+
.filter(
|
|
146
|
+
(line) =>
|
|
147
|
+
line &&
|
|
148
|
+
!line.startsWith("WEBVTT") &&
|
|
149
|
+
!line.startsWith("Kind:") &&
|
|
150
|
+
!line.startsWith("Language:") &&
|
|
151
|
+
!line.includes("-->") &&
|
|
152
|
+
!/^\d+$/.test(line),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (out[out.length - 1] !== line) out.push(line);
|
|
158
|
+
}
|
|
159
|
+
return out.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
163
|
+
|
|
164
|
+
function isRetryable(error) {
|
|
165
|
+
return APICallError.isInstance(error) && error.isRetryable;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function retryDelayMs(error, fallbackMs) {
|
|
169
|
+
const headers = APICallError.isInstance(error)
|
|
170
|
+
? error.responseHeaders
|
|
171
|
+
: undefined;
|
|
172
|
+
let headerMs;
|
|
173
|
+
if (headers?.["retry-after-ms"] !== undefined) {
|
|
174
|
+
headerMs = Number.parseFloat(headers["retry-after-ms"]);
|
|
175
|
+
} else if (headers?.["retry-after"] !== undefined) {
|
|
176
|
+
const seconds = Number.parseFloat(headers["retry-after"]);
|
|
177
|
+
headerMs = Number.isNaN(seconds)
|
|
178
|
+
? Date.parse(headers["retry-after"]) - Date.now()
|
|
179
|
+
: seconds * 1000;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!Number.isFinite(headerMs) || headerMs <= 0) return fallbackMs;
|
|
183
|
+
return Math.max(headerMs, fallbackMs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function describeError(error, attempts) {
|
|
187
|
+
const suffix = ` after ${attempts} attempt(s)`;
|
|
188
|
+
if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
|
|
189
|
+
|
|
190
|
+
const status = error.statusCode ? `HTTP ${error.statusCode}` : "API error";
|
|
191
|
+
const body = error.responseBody?.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
192
|
+
return `${status} ${error.message}${suffix}${body ? `: ${body}` : ""}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseRetryDelays(value) {
|
|
196
|
+
return `${value}`
|
|
197
|
+
.split(",")
|
|
198
|
+
.map((item) => item.trim())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.map((item) => Number(item) * 1000)
|
|
201
|
+
.filter((item) => Number.isFinite(item) && item >= 0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function toMarkdown(
|
|
205
|
+
{ model, retryDelays, maxOutputTokens },
|
|
206
|
+
title,
|
|
207
|
+
transcript,
|
|
208
|
+
onRetry,
|
|
209
|
+
) {
|
|
210
|
+
const totalAttempts = retryDelays.length + 1;
|
|
211
|
+
|
|
212
|
+
for (let attempt = 1;; attempt++) {
|
|
213
|
+
const started = performance.now();
|
|
214
|
+
try {
|
|
215
|
+
const { text, usage, finishReason } = await generateText({
|
|
216
|
+
model,
|
|
217
|
+
system: SYSTEM,
|
|
218
|
+
prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
|
|
219
|
+
temperature: 0.2,
|
|
220
|
+
maxOutputTokens: outputBudget(transcript, maxOutputTokens),
|
|
221
|
+
maxRetries: 0,
|
|
222
|
+
});
|
|
223
|
+
const elapsed = (performance.now() - started) / 1000;
|
|
224
|
+
|
|
225
|
+
const body = text
|
|
226
|
+
.trim()
|
|
227
|
+
.replace(/^```[a-z]*\n?/, "")
|
|
228
|
+
.replace(/\n?```$/, "")
|
|
229
|
+
.trim();
|
|
230
|
+
|
|
231
|
+
return { body, usage, finishReason, elapsed };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
234
|
+
throw new Error(describeError(error, attempt), { cause: error });
|
|
235
|
+
}
|
|
236
|
+
const waitMs = retryDelayMs(error, retryDelays[attempt - 1]);
|
|
237
|
+
onRetry?.(attempt + 1, totalAttempts, waitMs);
|
|
238
|
+
await delay(waitMs);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function forEachConcurrent(items, limit, worker) {
|
|
244
|
+
let next = 0;
|
|
245
|
+
await Promise.all(
|
|
246
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
247
|
+
while (next < items.length) {
|
|
248
|
+
const item = items[next++];
|
|
249
|
+
await worker(item);
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function runGenerateMarkdown({
|
|
256
|
+
feed = [],
|
|
257
|
+
model: modelId,
|
|
258
|
+
endpoint,
|
|
259
|
+
apiKey,
|
|
260
|
+
session,
|
|
261
|
+
concurrency = 6,
|
|
262
|
+
ytdlpConcurrency = 2,
|
|
263
|
+
retryDelays = "10,30,60",
|
|
264
|
+
maxOutputTokens,
|
|
265
|
+
force = false,
|
|
266
|
+
dir = DEFAULT_DIR,
|
|
267
|
+
} = {}) {
|
|
268
|
+
const key = apiKey ?? process.env.OPENAI_API_KEY;
|
|
269
|
+
if (!key) throw new Error("api key is not set (--api-key or OPENAI_API_KEY)");
|
|
270
|
+
if (!modelId) throw new Error("model is not set (--model)");
|
|
271
|
+
if (!endpoint) throw new Error("endpoint is not set (--endpoint)");
|
|
272
|
+
|
|
273
|
+
const RETRY_DELAYS = parseRetryDelays(retryDelays);
|
|
274
|
+
const llmConcurrency = Number(concurrency);
|
|
275
|
+
const vttConcurrency = Number(ytdlpConcurrency);
|
|
276
|
+
|
|
277
|
+
const provider = createOpenAICompatible({
|
|
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);
|
|
285
|
+
|
|
286
|
+
await mkdir(dir, { recursive: true });
|
|
287
|
+
|
|
288
|
+
const jobs = feed.flatMap((channel) =>
|
|
289
|
+
(channel.videos ?? [])
|
|
290
|
+
.filter((video) => video.generate_markdown)
|
|
291
|
+
.flatMap((video) => videoJobs(video, dir, channel.folder_slug)),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
console.error(`${jobs.length} markdown(s) to generate with ${modelId}`);
|
|
295
|
+
|
|
296
|
+
const stats = {
|
|
297
|
+
generated: 0,
|
|
298
|
+
already: 0,
|
|
299
|
+
skipped: 0,
|
|
300
|
+
errors: 0,
|
|
301
|
+
totalSeconds: 0,
|
|
302
|
+
totalInput: 0,
|
|
303
|
+
totalOutput: 0,
|
|
304
|
+
totalCost: 0,
|
|
305
|
+
costKnown: true,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const rendererOptions = {
|
|
309
|
+
logger: new ListrLogger({
|
|
310
|
+
processOutput: new ProcessOutput(process.stderr, process.stderr),
|
|
311
|
+
}),
|
|
312
|
+
showErrorMessage: true,
|
|
313
|
+
collapseErrors: false,
|
|
314
|
+
collapseSkips: false,
|
|
315
|
+
icon: {
|
|
316
|
+
SKIPPED: "–",
|
|
317
|
+
SKIPPED_WITH_COLLAPSE: "–",
|
|
318
|
+
SKIPPED_WITHOUT_COLLAPSE: "–",
|
|
319
|
+
},
|
|
320
|
+
color: {
|
|
321
|
+
SKIPPED: (message) => message,
|
|
322
|
+
SKIPPED_WITH_COLLAPSE: (message) => message,
|
|
323
|
+
SKIPPED_WITHOUT_COLLAPSE: (message) => message,
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const phaseOptions = { outputBar: 20, persistentOutput: true };
|
|
328
|
+
|
|
329
|
+
const pending = [];
|
|
330
|
+
for (const job of jobs) {
|
|
331
|
+
if (!force && (await exists(`${job.base}.md`))) stats.already++;
|
|
332
|
+
else pending.push(job);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const downloadTask = {
|
|
336
|
+
title: "Download transcripts",
|
|
337
|
+
rendererOptions: phaseOptions,
|
|
338
|
+
task: async (_ctx, task) => {
|
|
339
|
+
const total = jobs.length;
|
|
340
|
+
if (total === 0) {
|
|
341
|
+
task.skip("no video");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const pendingSet = new Set(pending);
|
|
346
|
+
let available = 0;
|
|
347
|
+
const items = [];
|
|
348
|
+
for (const job of jobs) {
|
|
349
|
+
if (await exists(job.vtt)) {
|
|
350
|
+
available++;
|
|
351
|
+
} else if (pendingSet.has(job)) {
|
|
352
|
+
items.push(job);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
task.title = `Download transcripts (${available}/${total})`;
|
|
356
|
+
|
|
357
|
+
await forEachConcurrent(items, vttConcurrency, async (job) => {
|
|
358
|
+
const name = relative(dir, `${job.base}.md`);
|
|
359
|
+
try {
|
|
360
|
+
if (await exists(job.marker)) {
|
|
361
|
+
stats.skipped++;
|
|
362
|
+
task.output = `skipped (no transcript) ${name}`;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await mkdir(dirname(job.vtt), { recursive: true });
|
|
367
|
+
const status = await downloadVtt(job.url, job.lang, job.vtt);
|
|
368
|
+
if (status === "missing") {
|
|
369
|
+
await writeMarker(job.marker, job.url);
|
|
370
|
+
stats.skipped++;
|
|
371
|
+
task.output = `skipped (no transcript) ${name}`;
|
|
372
|
+
} else if (status === "error") {
|
|
373
|
+
stats.errors++;
|
|
374
|
+
task.output = `error (download failed) ${name}`;
|
|
375
|
+
} else {
|
|
376
|
+
available++;
|
|
377
|
+
task.output = `downloaded ${name}`;
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
stats.errors++;
|
|
381
|
+
task.output = `error (${error.message}) ${name}`;
|
|
382
|
+
} finally {
|
|
383
|
+
task.title = `Download transcripts (${available}/${total})`;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const generateTask = {
|
|
390
|
+
title: "Generate markdown",
|
|
391
|
+
rendererOptions: phaseOptions,
|
|
392
|
+
task: async (_ctx, task) => {
|
|
393
|
+
const items = [];
|
|
394
|
+
for (const job of pending) {
|
|
395
|
+
if (await exists(job.vtt)) items.push(job);
|
|
396
|
+
}
|
|
397
|
+
if (items.length === 0) {
|
|
398
|
+
task.skip("nothing to generate");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const already = stats.already > 0 ? `, ${stats.already} already` : "";
|
|
403
|
+
task.title =
|
|
404
|
+
`Generate markdown (${stats.already}/${jobs.length}${already})`;
|
|
405
|
+
await forEachConcurrent(items, llmConcurrency, async (job) => {
|
|
406
|
+
const md = `${job.base}.md`;
|
|
407
|
+
const name = relative(dir, md);
|
|
408
|
+
try {
|
|
409
|
+
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
410
|
+
const { body, usage, finishReason, elapsed } = await toMarkdown(
|
|
411
|
+
{ model, retryDelays: RETRY_DELAYS, maxOutputTokens },
|
|
412
|
+
job.title,
|
|
413
|
+
transcript,
|
|
414
|
+
(nextAttempt, totalAttempts, waitMs) => {
|
|
415
|
+
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
416
|
+
`(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
stats.totalSeconds += elapsed;
|
|
421
|
+
stats.totalInput += usage?.inputTokens ?? 0;
|
|
422
|
+
stats.totalOutput += usage?.outputTokens ?? 0;
|
|
423
|
+
const cost = estimateCost(modelId, usage);
|
|
424
|
+
if (cost === null) stats.costKnown = false;
|
|
425
|
+
else stats.totalCost += cost;
|
|
426
|
+
const metrics = metricsLine(modelId, usage, elapsed, cost);
|
|
427
|
+
|
|
428
|
+
if (finishReason !== "stop") {
|
|
429
|
+
stats.errors++;
|
|
430
|
+
task.output = `error (finishReason=${finishReason}) ${name}`;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const front = frontmatter(job, {
|
|
435
|
+
modelId,
|
|
436
|
+
usage,
|
|
437
|
+
elapsed,
|
|
438
|
+
cost,
|
|
439
|
+
finishReason,
|
|
440
|
+
});
|
|
441
|
+
await mkdir(dirname(md), { recursive: true });
|
|
442
|
+
await writeFile(
|
|
443
|
+
md,
|
|
444
|
+
`${front}# ${job.title}\n\n${hardWrap(body)}\n`,
|
|
445
|
+
);
|
|
446
|
+
stats.generated++;
|
|
447
|
+
task.output = `generated ${name} ${metrics}`;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
stats.errors++;
|
|
450
|
+
task.output = `error (${error.message}) ${name}`;
|
|
451
|
+
} finally {
|
|
452
|
+
const upToDate = stats.already + stats.generated;
|
|
453
|
+
task.title = `Generate markdown (${upToDate}/${jobs.length}${already})`;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
await new Listr([downloadTask, generateTask], {
|
|
460
|
+
exitOnError: false,
|
|
461
|
+
rendererOptions,
|
|
462
|
+
fallbackRenderer: "simple",
|
|
463
|
+
fallbackRendererOptions: rendererOptions,
|
|
464
|
+
}).run();
|
|
465
|
+
|
|
466
|
+
console.error(
|
|
467
|
+
`summary: ${stats.generated} generated, ${stats.already} already generated, ${stats.skipped} skipped, ${stats.errors} error`,
|
|
468
|
+
);
|
|
469
|
+
const seconds = stats.totalSeconds.toFixed(1);
|
|
470
|
+
console.error(
|
|
471
|
+
`total: ${seconds}s, ${stats.totalInput} in / ${stats.totalOutput} out${
|
|
472
|
+
stats.costKnown ? `, ~$${stats.totalCost.toFixed(6)}` : ""
|
|
473
|
+
}`,
|
|
474
|
+
);
|
|
475
|
+
}
|