kiku-agent 1.2.0 → 1.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 +459 -407
- package/package.json +27 -3
- package/server/agent/agentCore.js +635 -267
- package/server/agent/appPaths.js +2 -0
- package/server/agent/catalogEntityResolver.js +629 -0
- package/server/agent/citations.js +0 -21
- package/server/agent/clarificationAnalytics.js +1 -1
- package/server/agent/clarifyingQuestions.js +286 -216
- package/server/agent/cli.js +528 -73
- package/server/agent/commandFeatures.js +123 -24
- package/server/agent/commandRegistry.js +128 -62
- package/server/agent/commands.js +1410 -95
- package/server/agent/config.js +253 -163
- package/server/agent/contextCompactor.js +239 -25
- package/server/agent/dynamicCatalog.js +192 -0
- package/server/agent/envBootstrap.js +46 -0
- package/server/agent/envManager.js +317 -0
- package/server/agent/eventBus.js +3 -3
- package/server/agent/feedbackLearning.js +0 -8
- package/server/agent/gpuMetrics.js +188 -55
- package/server/agent/ingestionWorker.js +339 -0
- package/server/agent/lanDiscovery.js +369 -0
- package/server/agent/llmClient.js +111 -39
- package/server/agent/lmStudioWakeup.js +144 -0
- package/server/agent/mcpClient.js +2 -2
- package/server/agent/memory.js +635 -494
- package/server/agent/modelConfig.js +487 -0
- package/server/agent/newsIntelligence.js +219 -0
- package/server/agent/newsWatch.js +85 -0
- package/server/agent/prompts.js +146 -0
- package/server/agent/publicTools.js +7 -0
- package/server/agent/queryDetails.js +62 -1
- package/server/agent/research.js +314 -35
- package/server/agent/routingInterceptor.js +77 -0
- package/server/agent/rumoredProjects.js +15 -0
- package/server/agent/sanitizer.js +117 -17
- package/server/agent/subagent.js +52 -9
- package/server/agent/suggestionTui.js +33 -13
- package/server/agent/suggestions.js +29 -13
- package/server/agent/telemetry.js +0 -11
- package/server/agent/terminalImage.js +0 -1
- package/server/agent/textToolProtocol.js +190 -0
- package/server/agent/tokenTracker.js +220 -0
- package/server/agent/toolRouter.js +362 -216
- package/server/agent/tools.js +1155 -194
- package/server/agent/ui.js +144 -41
- package/server/agent/vramService.js +572 -0
- package/server/agent/webSearch.js +2279 -273
- package/server/agent/youtubeAuth.js +82 -73
- package/server/agent/youtubeTranscript.js +209 -0
- package/server/apiRouter.js +383 -179
- package/server/mcp/data/brands.js +5 -5
- package/server/mcp/data/discography.js +56 -45
- package/server/mcp/data/gallery.js +9 -0
- package/server/mcp/data/index.js +1 -0
- package/server/mcp/data/industryIndex.js +251 -0
- package/server/mcp/data/profile.js +5 -5
- package/server/mcp/data/timeline.js +2 -2
- package/server/mcp/data/works.js +2 -2
- package/server/mcp/handler.js +0 -2
- package/server/mcp/prompts.js +42 -2
- package/server/mcp/resources.js +5 -1
- package/server/mcp/security.js +25 -14
- package/server/mcp/server.js +92 -3
- package/server/mcp/tools.js +20 -11
- package/server/web-chat/core/connection.js +218 -194
- package/server/web-chat/core/conversationContext.js +1 -1
- package/server/web-chat/core/directCatalogEngine.js +190 -184
- package/server/web-chat/core/followUpEngine.js +88 -11
- package/server/web-chat/core/security.js +7 -4
- package/server/web-chat/router.js +54 -17
- package/server/web-chat/routes/bilibiliRoute.js +34 -0
- package/server/web-chat/routes/chatRoute.js +488 -397
- package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
- package/server/web-chat/routes/exportRoute.js +2 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
- package/server/web-chat/routes/newsRoute.js +36 -6
- package/server/web-chat/routes/providerRoute.js +87 -0
- package/server/web-chat/routes/staticRoute.js +61 -13
- package/server/web-chat/routes/statusRoute.js +23 -2
- package/server/web-chat/routes/vramRoute.js +22 -0
- package/server/web-chat/server.js +135 -106
- package/server/web-chat/ui/htmlBuilder.js +8 -8
- package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
- package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
- package/server/web-chat/ui/scripts/client.js +10526 -8397
- package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
- package/server/web-chat/ui/styles/main.css +7471 -4246
- package/server/web-chat/ui/templates/body.html +305 -179
- package/src/data/brandsData.js +29 -6
- package/src/data/discographyData.js +918 -900
- package/src/data/galleryData.js +66 -0
- package/src/data/industryIndexData.js +292 -0
- package/src/data/musicVideoData.js +48 -5
- package/src/data/profileData.js +15 -1
- package/src/data/timelineData.js +4 -3
- package/src/data/worksData.js +536 -535
- package/src/lib/vram/estimator.js +464 -0
- package/src/lib/vram/modelRegistry.js +387 -0
- package/tokens.css +33 -2
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { spawn,
|
|
3
|
+
import { spawn, execFile } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
import { KIKU_DATA_DIR } from './appPaths.js';
|
|
7
|
+
import { PACKAGE_ENV_PATH, USER_ENV_PATH, upsertEnvKey } from './envManager.js';
|
|
8
|
+
import { bootstrapEnv } from './envBootstrap.js';
|
|
6
9
|
|
|
7
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
bootstrapEnv();
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const getPrimaryEnvPath = () => (fs.existsSync(PACKAGE_ENV_PATH) ? PACKAGE_ENV_PATH : USER_ENV_PATH);
|
|
9
16
|
const AUTH_CONFIG_PATH = path.join(KIKU_DATA_DIR, 'yt_auth.json');
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
|
|
17
|
+
const COOKIE_FILE_PATH = path.join(KIKU_DATA_DIR, 'cookies.txt');
|
|
18
|
+
|
|
19
|
+
const YTDLP_CMD = () => process.env.KIKU_YTDLP || 'yt-dlp';
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
const BROWSERS = ['firefox', 'chrome', 'edge', 'brave'];
|
|
23
|
+
let browserDetectionCache = null;
|
|
16
24
|
|
|
17
|
-
function
|
|
25
|
+
async function findBrowserExeAsync(browser, { execImpl = execFileAsync } = {}) {
|
|
18
26
|
const isWin = process.platform === 'win32';
|
|
19
27
|
const probes = {
|
|
20
28
|
firefox: {
|
|
21
|
-
cmd:
|
|
29
|
+
cmd: 'firefox',
|
|
22
30
|
paths: [
|
|
23
31
|
'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
|
|
24
32
|
'C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe',
|
|
@@ -53,7 +61,7 @@ function findBrowserExe(browser) {
|
|
|
53
61
|
const p = probes[browser];
|
|
54
62
|
if (!p) return null;
|
|
55
63
|
try {
|
|
56
|
-
|
|
64
|
+
await execImpl(isWin ? 'where' : 'which', [p.cmd], { windowsHide: true, timeout: 1500 });
|
|
57
65
|
return p.cmd;
|
|
58
66
|
} catch {}
|
|
59
67
|
|
|
@@ -65,12 +73,18 @@ function findBrowserExe(browser) {
|
|
|
65
73
|
return null;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
function
|
|
69
|
-
|
|
76
|
+
async function detectBrowsersCached({ execImpl } = {}) {
|
|
77
|
+
if (browserDetectionCache) return browserDetectionCache;
|
|
78
|
+
const found = [];
|
|
79
|
+
for (const b of BROWSERS) {
|
|
80
|
+
if (await findBrowserExeAsync(b, { execImpl })) found.push(b);
|
|
81
|
+
}
|
|
82
|
+
browserDetectionCache = found;
|
|
83
|
+
return found;
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
function openBrowser(url, browser) {
|
|
73
|
-
const exe = browser ?
|
|
86
|
+
async function openBrowser(url, browser) {
|
|
87
|
+
const exe = browser ? await findBrowserExeAsync(browser) : null;
|
|
74
88
|
const isWin = process.platform === 'win32';
|
|
75
89
|
try {
|
|
76
90
|
if (exe) {
|
|
@@ -105,8 +119,7 @@ function writeAuthConfig(updates) {
|
|
|
105
119
|
} catch {}
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
function
|
|
109
|
-
|
|
122
|
+
function refreshYtAuthEnv() {
|
|
110
123
|
const userConfig = readAuthConfig();
|
|
111
124
|
if (userConfig.KIKU_YT_BROWSER && process.env.KIKU_YT_BROWSER === undefined) {
|
|
112
125
|
process.env.KIKU_YT_BROWSER = userConfig.KIKU_YT_BROWSER;
|
|
@@ -114,62 +127,40 @@ function loadEnv() {
|
|
|
114
127
|
if (userConfig.KIKU_YT_COOKIES && process.env.KIKU_YT_COOKIES === undefined) {
|
|
115
128
|
process.env.KIKU_YT_COOKIES = userConfig.KIKU_YT_COOKIES;
|
|
116
129
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
if (!fs.existsSync(ENV_PATH)) return;
|
|
121
|
-
const text = fs.readFileSync(ENV_PATH, 'utf8');
|
|
122
|
-
for (const line of text.split('\n')) {
|
|
123
|
-
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
|
|
124
|
-
if (!m) continue;
|
|
125
|
-
const key = m[1].trim();
|
|
126
|
-
const val = m[2].trim().replace(/^["']|["']$/g, '');
|
|
127
|
-
if (process.env[key] === undefined) process.env[key] = val;
|
|
128
|
-
}
|
|
129
|
-
} catch {}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function readEnvMap() {
|
|
133
|
-
const map = {};
|
|
134
|
-
try {
|
|
135
|
-
if (!fs.existsSync(ENV_PATH)) return map;
|
|
136
|
-
const text = fs.readFileSync(ENV_PATH, 'utf8');
|
|
137
|
-
for (const line of text.split('\n')) {
|
|
138
|
-
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
|
|
139
|
-
if (m) map[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
140
|
-
}
|
|
141
|
-
} catch {}
|
|
142
|
-
return map;
|
|
143
130
|
}
|
|
144
131
|
|
|
145
132
|
function writeEnv(updates) {
|
|
146
|
-
|
|
147
|
-
for (const [k, v] of Object.entries(updates)) {
|
|
148
|
-
|
|
133
|
+
const cleanUpdates = {};
|
|
134
|
+
for (const [k, v] of Object.entries(updates || {})) {
|
|
135
|
+
const cleanKey = String(k || '').replace(/[\r\n]+/g, '').trim();
|
|
136
|
+
const cleanVal = String(v ?? '').replace(/[\r\n]+/g, '').trim();
|
|
137
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(cleanKey)) {
|
|
138
|
+
cleanUpdates[cleanKey] = cleanVal;
|
|
139
|
+
process.env[cleanKey] = cleanVal;
|
|
140
|
+
}
|
|
149
141
|
}
|
|
150
|
-
writeAuthConfig(
|
|
142
|
+
writeAuthConfig(cleanUpdates);
|
|
151
143
|
|
|
152
|
-
|
|
153
144
|
try {
|
|
154
|
-
const
|
|
155
|
-
Object.
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
const envFile = getPrimaryEnvPath();
|
|
146
|
+
for (const [key, value] of Object.entries(cleanUpdates)) {
|
|
147
|
+
upsertEnvKey(envFile, key, value);
|
|
148
|
+
}
|
|
158
149
|
} catch {}
|
|
159
150
|
}
|
|
160
151
|
|
|
161
|
-
function hasSession(browser) {
|
|
152
|
+
function hasSession(browser, { timeoutMs = 6000 } = {}) {
|
|
162
153
|
return new Promise((resolve) => {
|
|
163
|
-
const
|
|
154
|
+
const probeUrl = process.env.KIKU_YT_PROBE_URL || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
|
164
155
|
const args = [
|
|
165
156
|
'--cookies-from-browser', browser,
|
|
166
157
|
'--print', '%(title)s',
|
|
167
158
|
'--no-warnings',
|
|
168
159
|
'--no-playlist',
|
|
169
160
|
'--no-check-certificates',
|
|
170
|
-
|
|
161
|
+
probeUrl
|
|
171
162
|
];
|
|
172
|
-
const p = spawn(YTDLP_CMD, args, {
|
|
163
|
+
const p = spawn(YTDLP_CMD(), args, {
|
|
173
164
|
windowsHide: true,
|
|
174
165
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
175
166
|
});
|
|
@@ -182,17 +173,18 @@ function hasSession(browser) {
|
|
|
182
173
|
if (!resolved) {
|
|
183
174
|
resolved = true;
|
|
184
175
|
try { p.kill('SIGKILL'); } catch {}
|
|
185
|
-
resolve(false);
|
|
176
|
+
resolve({ ok: false, reason: 'timeout' });
|
|
186
177
|
}
|
|
187
178
|
}, timeoutMs);
|
|
188
179
|
|
|
189
180
|
p.stdout?.on('data', d => { out += d.toString(); });
|
|
190
181
|
p.stderr?.on('data', d => { err += d.toString(); });
|
|
191
|
-
p.on('error', () => {
|
|
182
|
+
p.on('error', (spawnErr) => {
|
|
192
183
|
if (!resolved) {
|
|
193
184
|
resolved = true;
|
|
194
185
|
clearTimeout(timer);
|
|
195
|
-
|
|
186
|
+
const missing = spawnErr?.code === 'ENOENT';
|
|
187
|
+
resolve({ ok: false, reason: missing ? 'yt-dlp-missing' : 'spawn-error', detail: spawnErr?.message });
|
|
196
188
|
}
|
|
197
189
|
});
|
|
198
190
|
p.on('close', (code) => {
|
|
@@ -201,7 +193,11 @@ function hasSession(browser) {
|
|
|
201
193
|
clearTimeout(timer);
|
|
202
194
|
const title = out.trim();
|
|
203
195
|
const blocked = /403|Forbidden|Sign in|confirm you'?re not a bot|Login Required|Private Video|database is locked/i.test(err);
|
|
204
|
-
|
|
196
|
+
if (code === 0 && title.length > 0 && !blocked) {
|
|
197
|
+
resolve({ ok: true, reason: 'session-found' });
|
|
198
|
+
} else {
|
|
199
|
+
resolve({ ok: false, reason: blocked ? 'blocked' : 'no-session', detail: err.slice(0, 200) });
|
|
200
|
+
}
|
|
205
201
|
});
|
|
206
202
|
});
|
|
207
203
|
}
|
|
@@ -248,18 +244,17 @@ export function isCookieFileValid(filePath) {
|
|
|
248
244
|
}
|
|
249
245
|
|
|
250
246
|
async function _ensureYouTubeCookiesImpl(opts = {}) {
|
|
251
|
-
|
|
247
|
+
refreshYtAuthEnv();
|
|
252
248
|
|
|
253
249
|
const cookiesCandidate = (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES))
|
|
254
250
|
? process.env.KIKU_YT_COOKIES
|
|
255
|
-
: (isCookieFileValid(
|
|
251
|
+
: (isCookieFileValid(COOKIE_FILE_PATH) ? COOKIE_FILE_PATH : null);
|
|
256
252
|
if (cookiesCandidate && isCookieFileValid(cookiesCandidate)) {
|
|
257
253
|
_ensured = { ok: true, source: 'file', cookiesPath: cookiesCandidate };
|
|
258
254
|
return _ensured;
|
|
259
255
|
}
|
|
260
256
|
|
|
261
|
-
|
|
262
|
-
const available = detectBrowsers();
|
|
257
|
+
const available = await detectBrowsersCached();
|
|
263
258
|
if (!available.length) {
|
|
264
259
|
_ensured = { ok: false, reason: 'no-browser', detail: 'No supported browser found (Firefox, Chrome, Edge, Brave)' };
|
|
265
260
|
_lastFailTime = Date.now();
|
|
@@ -271,12 +266,21 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
|
|
|
271
266
|
? [preferred, ...available.filter(b => b !== preferred)]
|
|
272
267
|
: available;
|
|
273
268
|
|
|
269
|
+
let sawYtdlpMissing = false;
|
|
274
270
|
for (const b of candidates) {
|
|
275
|
-
|
|
271
|
+
const probe = await hasSession(b, { timeoutMs: opts.probeTimeoutMs });
|
|
272
|
+
if (probe.ok) {
|
|
276
273
|
writeEnv({ KIKU_YT_BROWSER: b });
|
|
277
274
|
_ensured = { ok: true, source: 'existing-session', browser: b };
|
|
278
275
|
return _ensured;
|
|
279
276
|
}
|
|
277
|
+
if (probe.reason === 'yt-dlp-missing') sawYtdlpMissing = true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (sawYtdlpMissing) {
|
|
281
|
+
_ensured = { ok: false, reason: 'yt-dlp-missing', detail: `yt-dlp was not found (set KIKU_YTDLP in .env, e.g. ${YTDLP_CMD()})` };
|
|
282
|
+
_lastFailTime = Date.now();
|
|
283
|
+
return _ensured;
|
|
280
284
|
}
|
|
281
285
|
|
|
282
286
|
|
|
@@ -310,7 +314,8 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
|
|
|
310
314
|
}
|
|
311
315
|
|
|
312
316
|
for (const b of available) {
|
|
313
|
-
|
|
317
|
+
const probe = await hasSession(b, { timeoutMs: opts.probeTimeoutMs });
|
|
318
|
+
if (probe.ok) {
|
|
314
319
|
writeEnv({ KIKU_YT_BROWSER: b });
|
|
315
320
|
if (process.stdout.isTTY) {
|
|
316
321
|
process.stdout.write('\r' + ' '.repeat(65) + '\r');
|
|
@@ -335,9 +340,9 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
|
|
|
335
340
|
}
|
|
336
341
|
|
|
337
342
|
export async function exportCookiesToFile() {
|
|
338
|
-
|
|
343
|
+
refreshYtAuthEnv();
|
|
339
344
|
|
|
340
|
-
const available =
|
|
345
|
+
const available = await detectBrowsersCached();
|
|
341
346
|
const preferred = process.env.KIKU_YT_BROWSER;
|
|
342
347
|
const candidates = preferred && available.includes(preferred)
|
|
343
348
|
? [preferred, ...available.filter(b => b !== preferred)]
|
|
@@ -348,18 +353,18 @@ export async function exportCookiesToFile() {
|
|
|
348
353
|
fs.mkdirSync(KIKU_DATA_DIR, { recursive: true });
|
|
349
354
|
const args = [
|
|
350
355
|
'--cookies-from-browser', b,
|
|
351
|
-
'--cookies',
|
|
356
|
+
'--cookies', COOKIE_FILE_PATH,
|
|
352
357
|
'--no-warnings',
|
|
353
358
|
'--skip-download',
|
|
354
|
-
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
359
|
+
process.env.KIKU_YT_PROBE_URL || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
355
360
|
];
|
|
356
361
|
await new Promise((resolve, reject) => {
|
|
357
|
-
const p = spawn(YTDLP_CMD, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
362
|
+
const p = spawn(YTDLP_CMD(), args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
358
363
|
let err = '';
|
|
359
364
|
p.stderr?.on('data', d => { err += d.toString(); });
|
|
360
365
|
p.on('error', reject);
|
|
361
366
|
p.on('close', code => {
|
|
362
|
-
if (code === 0 && fs.existsSync(
|
|
367
|
+
if (code === 0 && fs.existsSync(COOKIE_FILE_PATH)) {
|
|
363
368
|
resolve();
|
|
364
369
|
} else {
|
|
365
370
|
reject(new Error(err || `yt-dlp exited ${code}`));
|
|
@@ -367,8 +372,11 @@ export async function exportCookiesToFile() {
|
|
|
367
372
|
});
|
|
368
373
|
});
|
|
369
374
|
|
|
370
|
-
|
|
371
|
-
|
|
375
|
+
if (process.platform !== 'win32') {
|
|
376
|
+
try { fs.chmodSync(COOKIE_FILE_PATH, 0o600); } catch {}
|
|
377
|
+
}
|
|
378
|
+
writeEnv({ KIKU_YT_COOKIES: COOKIE_FILE_PATH });
|
|
379
|
+
return { ok: true, browser: b, cookiesPath: COOKIE_FILE_PATH };
|
|
372
380
|
} catch {}
|
|
373
381
|
}
|
|
374
382
|
|
|
@@ -384,12 +392,13 @@ export function resetCookieCheck() {
|
|
|
384
392
|
export function getCookieStatus() {
|
|
385
393
|
const envCookies = (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES))
|
|
386
394
|
? process.env.KIKU_YT_COOKIES
|
|
387
|
-
: (isCookieFileValid(
|
|
395
|
+
: (isCookieFileValid(COOKIE_FILE_PATH) ? COOKIE_FILE_PATH : null);
|
|
388
396
|
return {
|
|
389
397
|
ensured: _ensured,
|
|
390
398
|
envBrowser: process.env.KIKU_YT_BROWSER || null,
|
|
391
399
|
envCookies,
|
|
392
|
-
envPath:
|
|
400
|
+
envPath: getPrimaryEnvPath(),
|
|
401
|
+
userEnvPath: USER_ENV_PATH,
|
|
393
402
|
authConfigPath: AUTH_CONFIG_PATH
|
|
394
403
|
};
|
|
395
404
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ensureYouTubeCookies, isCookieFileValid } from './youtubeAuth.js';
|
|
6
|
+
|
|
7
|
+
const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
|
|
8
|
+
|
|
9
|
+
const BASE_SUB_LANGS = ['th', 'en', 'zh-Hans', 'zh', 'zh-Hant', 'zh-CN', 'zh-TW', 'en-US'];
|
|
10
|
+
const LANG_PREFERENCES = {
|
|
11
|
+
th: ['th', 'th-TH'],
|
|
12
|
+
en: ['en', 'en-US', 'en-GB'],
|
|
13
|
+
zh: ['zh-Hans', 'zh-CN', 'zh', 'zh-Hant', 'zh-TW'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function extractYouTubeVideoId(input) {
|
|
17
|
+
if (!input || typeof input !== 'string') return null;
|
|
18
|
+
const trimmed = input.trim();
|
|
19
|
+
if (/^[\w-]{11}$/.test(trimmed)) return trimmed;
|
|
20
|
+
const patterns = [
|
|
21
|
+
/youtube\.com\/watch\?(?:[^#]*&)?v=([\w-]{11})/i,
|
|
22
|
+
/youtu\.be\/([\w-]{11})/i,
|
|
23
|
+
/youtube\.com\/shorts\/([\w-]{11})/i,
|
|
24
|
+
/youtube\.com\/embed\/([\w-]{11})/i,
|
|
25
|
+
/youtube\.com\/live\/([\w-]{11})/i,
|
|
26
|
+
];
|
|
27
|
+
for (const re of patterns) {
|
|
28
|
+
const match = trimmed.match(re);
|
|
29
|
+
if (match) return match[1];
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export function parseSubtitleToText(content) {
|
|
36
|
+
if (!content || typeof content !== 'string') return '';
|
|
37
|
+
const text = content.startsWith('\uFEFF') ? content.slice(1) : content;
|
|
38
|
+
const lines = text.split(/\r?\n/);
|
|
39
|
+
|
|
40
|
+
const out = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
let l = lines[i].trim();
|
|
44
|
+
if (!l) continue;
|
|
45
|
+
if (/^WEBVTT/i.test(l) || /^(NOTE|STYLE|REGION)\b/i.test(l)) continue;
|
|
46
|
+
if (l.includes('-->')) continue;
|
|
47
|
+
|
|
48
|
+
if (/^\d+$/.test(l)) {
|
|
49
|
+
let j = i + 1;
|
|
50
|
+
while (j < lines.length && !lines[j].trim()) j++;
|
|
51
|
+
if (j < lines.length && lines[j].includes('-->')) continue;
|
|
52
|
+
}
|
|
53
|
+
l = l.replace(/<[^>]+>/g, '');
|
|
54
|
+
l = l
|
|
55
|
+
.replace(/&/g, '&')
|
|
56
|
+
.replace(/'/g, "'")
|
|
57
|
+
.replace(/'/g, "'")
|
|
58
|
+
.replace(/"/g, '"')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/ /g, ' ');
|
|
62
|
+
if (!l) continue;
|
|
63
|
+
const key = l.toLowerCase();
|
|
64
|
+
if (seen.has(key)) continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
out.push(l);
|
|
67
|
+
}
|
|
68
|
+
return out.join(' ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveCookieArgs() {
|
|
72
|
+
if (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES)) {
|
|
73
|
+
return ['--cookies', process.env.KIKU_YT_COOKIES];
|
|
74
|
+
}
|
|
75
|
+
if (process.env.KIKU_YT_BROWSER) {
|
|
76
|
+
return ['--cookies-from-browser', process.env.KIKU_YT_BROWSER];
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const status = await ensureYouTubeCookies({ silent: true });
|
|
80
|
+
if (status?.ok) {
|
|
81
|
+
if (status.cookiesPath) return ['--cookies', status.cookiesPath];
|
|
82
|
+
if (status.browser) return ['--cookies-from-browser', status.browser];
|
|
83
|
+
}
|
|
84
|
+
} catch { }
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runYtDlp(args, timeoutMs) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const proc = spawn(YTDLP_CMD, args, { windowsHide: true });
|
|
91
|
+
let stdout = '';
|
|
92
|
+
let stderr = '';
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
try { proc.kill('SIGKILL'); } catch { }
|
|
95
|
+
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
proc.stdout.on('data', d => { stdout += d.toString(); });
|
|
98
|
+
proc.stderr.on('data', d => { stderr += d.toString(); });
|
|
99
|
+
proc.on('error', err => { clearTimeout(timer); reject(err); });
|
|
100
|
+
proc.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function listSubtitleFiles(dir) {
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(dir)
|
|
107
|
+
.map(name => {
|
|
108
|
+
const match = name.match(/^transcript\.(.+)\.(vtt|srt)$/i);
|
|
109
|
+
return match ? { file: join(dir, name), lang: match[1] } : null;
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickBestSubtitleFile(files, prefs) {
|
|
118
|
+
const score = (entry) => {
|
|
119
|
+
const lang = entry.lang.toLowerCase();
|
|
120
|
+
let best = prefs.length > 0 ? 100 : 0;
|
|
121
|
+
prefs.forEach((p, i) => {
|
|
122
|
+
const pLower = p.toLowerCase();
|
|
123
|
+
if (lang === pLower) best = Math.min(best, i);
|
|
124
|
+
else if (lang.startsWith(pLower)) best = Math.min(best, i + 0.5);
|
|
125
|
+
});
|
|
126
|
+
if (/(^|\.)orig$/i.test(lang) || /-orig$/i.test(lang)) best += 10;
|
|
127
|
+
return best;
|
|
128
|
+
};
|
|
129
|
+
return [...files].sort((a, b) => score(a) - score(b))[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function fetchYoutubeTranscript({ url, videoId, lang, maxChars } = {}) {
|
|
133
|
+
const id = videoId ? String(videoId).trim() : extractYouTubeVideoId(url);
|
|
134
|
+
if (!id || !/^[\w-]{11}$/.test(id)) {
|
|
135
|
+
return {
|
|
136
|
+
status: 'error',
|
|
137
|
+
message: 'Provide a valid YouTube URL (watch/youtu.be/shorts/embed/live) or an 11-character videoId.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), 'kiku-transcript-'));
|
|
142
|
+
try {
|
|
143
|
+
const prefs = LANG_PREFERENCES[String(lang || '').toLowerCase()] || [];
|
|
144
|
+
const subLangs = [...new Set([...prefs, ...BASE_SUB_LANGS])].join(',');
|
|
145
|
+
const baseArgs = [
|
|
146
|
+
'--skip-download', '--no-warnings', '--quiet',
|
|
147
|
+
'--write-subs', '--write-auto-subs',
|
|
148
|
+
'--sub-format', 'vtt/srt/best',
|
|
149
|
+
'--sub-langs', subLangs,
|
|
150
|
+
'-o', join(dir, 'transcript.%(ext)s'),
|
|
151
|
+
];
|
|
152
|
+
const watchUrl = `https://www.youtube.com/watch?v=${id}`;
|
|
153
|
+
|
|
154
|
+
const cookieArgs = await resolveCookieArgs();
|
|
155
|
+
let attempt = { code: 1, stdout: '', stderr: '' };
|
|
156
|
+
try {
|
|
157
|
+
attempt = await runYtDlp([...baseArgs, ...cookieArgs, watchUrl], 60_000);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
attempt = { code: 1, stdout: '', stderr: err.message };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let files = listSubtitleFiles(dir);
|
|
163
|
+
if (files.length === 0 && cookieArgs.length > 0) {
|
|
164
|
+
try {
|
|
165
|
+
attempt = await runYtDlp([...baseArgs, watchUrl], 60_000);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
attempt = { code: 1, stdout: '', stderr: err.message };
|
|
168
|
+
}
|
|
169
|
+
files = listSubtitleFiles(dir);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (files.length === 0) {
|
|
173
|
+
const blocked = /sign in|confirm you|cookies|age/i.test(attempt.stderr || '');
|
|
174
|
+
return {
|
|
175
|
+
status: 'not_found',
|
|
176
|
+
videoId: id,
|
|
177
|
+
url: watchUrl,
|
|
178
|
+
message: blocked
|
|
179
|
+
? `YouTube refused the caption request for ${id} (sign-in/cookies or age gate). Set KIKU_YT_COOKIES or KIKU_YT_BROWSER to enable authenticated caption downloads.`
|
|
180
|
+
: `No captions are available for this video (${id}), or yt-dlp could not reach it. Verify the video exists and has captions enabled.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const best = pickBestSubtitleFile(files, prefs);
|
|
185
|
+
const text = parseSubtitleToText(readFileSync(best.file, 'utf8'));
|
|
186
|
+
if (!text) {
|
|
187
|
+
return {
|
|
188
|
+
status: 'not_found',
|
|
189
|
+
videoId: id,
|
|
190
|
+
url: watchUrl,
|
|
191
|
+
message: `Captions were downloaded for ${id} but contained no readable text.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const max = Math.max(500, Math.min(Number(maxChars) || 6000, 20000));
|
|
196
|
+
const truncated = text.length > max;
|
|
197
|
+
return {
|
|
198
|
+
status: 'success',
|
|
199
|
+
videoId: id,
|
|
200
|
+
url: watchUrl,
|
|
201
|
+
lang: best.lang,
|
|
202
|
+
charCount: text.length,
|
|
203
|
+
truncated,
|
|
204
|
+
text: truncated ? `${text.slice(0, max)}…[truncated]` : text,
|
|
205
|
+
};
|
|
206
|
+
} finally {
|
|
207
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { }
|
|
208
|
+
}
|
|
209
|
+
}
|