felo-ai 0.2.5 → 0.2.7
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/CHANGELOG.md +30 -0
- package/README.en.md +24 -2
- package/README.md +68 -1
- package/felo-slides/SKILL.md +13 -1
- package/felo-web-fetch/README.md +78 -0
- package/felo-web-fetch/SKILL.md +200 -0
- package/felo-web-fetch/scripts/run_web_fetch.mjs +232 -0
- package/felo-youtube-subtitling/README.md +59 -0
- package/felo-youtube-subtitling/SKILL.md +161 -0
- package/felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs +239 -0
- package/package.json +4 -2
- package/src/cli.js +124 -50
- package/src/webFetch.js +148 -0
- package/src/youtubeSubtitling.js +179 -0
package/src/cli.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { createRequire } from
|
|
4
|
-
import { Command } from
|
|
5
|
-
import { search } from
|
|
6
|
-
import { slides } from
|
|
7
|
-
import
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { search } from "./search.js";
|
|
6
|
+
import { slides } from "./slides.js";
|
|
7
|
+
import { webFetch } from "./webFetch.js";
|
|
8
|
+
import { youtubeSubtitling } from "./youtubeSubtitling.js";
|
|
9
|
+
import * as config from "./config.js";
|
|
8
10
|
|
|
9
11
|
const require = createRequire(import.meta.url);
|
|
10
|
-
const pkg = require(
|
|
12
|
+
const pkg = require("../package.json");
|
|
11
13
|
|
|
12
14
|
/** Delay (ms) before process.exit to let Windows libuv finish handle cleanup. */
|
|
13
15
|
const EXIT_DELAY_MS = 50;
|
|
@@ -22,13 +24,13 @@ function flushStdioThenExit(code) {
|
|
|
22
24
|
const doExit = () => setTimeout(() => process.exit(code), EXIT_DELAY_MS);
|
|
23
25
|
const flushStderr = () => {
|
|
24
26
|
if (process.stderr?.writable && !process.stderr.destroyed) {
|
|
25
|
-
process.stderr.write(
|
|
27
|
+
process.stderr.write("", () => doExit());
|
|
26
28
|
} else {
|
|
27
29
|
doExit();
|
|
28
30
|
}
|
|
29
31
|
};
|
|
30
32
|
if (process.stdout?.writable && !process.stdout.destroyed) {
|
|
31
|
-
process.stdout.write(
|
|
33
|
+
process.stdout.write("", () => flushStderr());
|
|
32
34
|
} else {
|
|
33
35
|
flushStderr();
|
|
34
36
|
}
|
|
@@ -37,17 +39,17 @@ function flushStdioThenExit(code) {
|
|
|
37
39
|
const program = new Command();
|
|
38
40
|
|
|
39
41
|
program
|
|
40
|
-
.name(
|
|
41
|
-
.description(
|
|
42
|
+
.name("felo")
|
|
43
|
+
.description("Felo AI CLI - real-time search from the terminal")
|
|
42
44
|
.version(pkg.version);
|
|
43
45
|
|
|
44
46
|
program
|
|
45
|
-
.command(
|
|
46
|
-
.description(
|
|
47
|
-
.argument(
|
|
48
|
-
.option(
|
|
49
|
-
.option(
|
|
50
|
-
.option(
|
|
47
|
+
.command("search")
|
|
48
|
+
.description("Search for current information (weather, news, docs, etc.)")
|
|
49
|
+
.argument("<query>", "search query")
|
|
50
|
+
.option("-j, --json", "output raw JSON")
|
|
51
|
+
.option("-v, --verbose", "show query analysis and sources")
|
|
52
|
+
.option("-t, --timeout <seconds>", "request timeout in seconds", "60")
|
|
51
53
|
.action(async (query, opts) => {
|
|
52
54
|
const timeoutMs = parseInt(opts.timeout, 10) * 1000;
|
|
53
55
|
const code = await search(query, {
|
|
@@ -60,13 +62,26 @@ program
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
program
|
|
63
|
-
.command(
|
|
64
|
-
.description(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
.command("slides")
|
|
66
|
+
.description(
|
|
67
|
+
"Generate PPT/slides from a prompt (async task, outputs live doc URL when done)"
|
|
68
|
+
)
|
|
69
|
+
.argument(
|
|
70
|
+
"<query>",
|
|
71
|
+
'PPT generation prompt (e.g. "Felo, 2 pages" or "Introduction to React")'
|
|
72
|
+
)
|
|
73
|
+
.option("-j, --json", "output raw JSON with task_id and live_doc_url")
|
|
74
|
+
.option("-v, --verbose", "show polling status")
|
|
75
|
+
.option(
|
|
76
|
+
"-t, --timeout <seconds>",
|
|
77
|
+
"request timeout in seconds for each API call",
|
|
78
|
+
"60"
|
|
79
|
+
)
|
|
80
|
+
.option(
|
|
81
|
+
"--poll-timeout <seconds>",
|
|
82
|
+
"max seconds to wait for task completion",
|
|
83
|
+
"1200"
|
|
84
|
+
)
|
|
70
85
|
.action(async (query, opts) => {
|
|
71
86
|
const timeoutMs = parseInt(opts.timeout, 10) * 1000;
|
|
72
87
|
const pollTimeoutMs = parseInt(opts.pollTimeout, 10) * 1000 || 1_200_000;
|
|
@@ -81,97 +96,156 @@ program
|
|
|
81
96
|
});
|
|
82
97
|
|
|
83
98
|
const configCmd = program
|
|
84
|
-
.command(
|
|
85
|
-
.description(
|
|
99
|
+
.command("config")
|
|
100
|
+
.description(
|
|
101
|
+
"Manage persisted config (e.g. FELO_API_KEY). Stored in ~/.felo/config.json"
|
|
102
|
+
);
|
|
86
103
|
|
|
87
104
|
configCmd
|
|
88
|
-
.command(
|
|
89
|
-
.description(
|
|
105
|
+
.command("set <key> <value>")
|
|
106
|
+
.description(
|
|
107
|
+
"Set a config value (e.g. felo config set FELO_API_KEY your-key)"
|
|
108
|
+
)
|
|
90
109
|
.action(async (key, value) => {
|
|
91
110
|
try {
|
|
92
111
|
await config.setConfig(key, value);
|
|
93
112
|
console.log(`Set ${key}`);
|
|
94
113
|
flushStdioThenExit(0);
|
|
95
114
|
} catch (e) {
|
|
96
|
-
console.error(
|
|
115
|
+
console.error("Error:", e.message);
|
|
97
116
|
flushStdioThenExit(1);
|
|
98
117
|
}
|
|
99
118
|
});
|
|
100
119
|
|
|
101
120
|
configCmd
|
|
102
|
-
.command(
|
|
103
|
-
.description(
|
|
121
|
+
.command("get <key>")
|
|
122
|
+
.description("Get a config value (sensitive keys are masked)")
|
|
104
123
|
.action(async (key) => {
|
|
105
124
|
try {
|
|
106
125
|
const value = await config.getConfigValue(key);
|
|
107
126
|
if (value === undefined || value === null) {
|
|
108
|
-
console.log(
|
|
127
|
+
console.log("(not set)");
|
|
109
128
|
} else {
|
|
110
129
|
console.log(config.maskValueForDisplay(key, value));
|
|
111
130
|
}
|
|
112
131
|
flushStdioThenExit(0);
|
|
113
132
|
} catch (e) {
|
|
114
|
-
console.error(
|
|
133
|
+
console.error("Error:", e.message);
|
|
115
134
|
flushStdioThenExit(1);
|
|
116
135
|
}
|
|
117
136
|
});
|
|
118
137
|
|
|
119
138
|
configCmd
|
|
120
|
-
.command(
|
|
121
|
-
.description(
|
|
139
|
+
.command("list")
|
|
140
|
+
.description("List all config keys (values are hidden)")
|
|
122
141
|
.action(async () => {
|
|
123
142
|
try {
|
|
124
143
|
const c = await config.listConfig();
|
|
125
144
|
const keys = Object.keys(c);
|
|
126
145
|
if (keys.length === 0) {
|
|
127
|
-
console.log(
|
|
146
|
+
console.log("No config set. Use: felo config set FELO_API_KEY <key>");
|
|
128
147
|
} else {
|
|
129
148
|
keys.forEach((k) => console.log(k));
|
|
130
149
|
}
|
|
131
150
|
flushStdioThenExit(0);
|
|
132
151
|
} catch (e) {
|
|
133
|
-
console.error(
|
|
152
|
+
console.error("Error:", e.message);
|
|
134
153
|
flushStdioThenExit(1);
|
|
135
154
|
}
|
|
136
155
|
});
|
|
137
156
|
|
|
138
157
|
configCmd
|
|
139
|
-
.command(
|
|
140
|
-
.description(
|
|
158
|
+
.command("unset <key>")
|
|
159
|
+
.description("Remove a config value")
|
|
141
160
|
.action(async (key) => {
|
|
142
161
|
try {
|
|
143
162
|
await config.unsetConfig(key);
|
|
144
163
|
console.log(`Unset ${key}`);
|
|
145
164
|
flushStdioThenExit(0);
|
|
146
165
|
} catch (e) {
|
|
147
|
-
console.error(
|
|
166
|
+
console.error("Error:", e.message);
|
|
148
167
|
flushStdioThenExit(1);
|
|
149
168
|
}
|
|
150
169
|
});
|
|
151
170
|
|
|
152
171
|
configCmd
|
|
153
|
-
.command(
|
|
154
|
-
.description(
|
|
172
|
+
.command("path")
|
|
173
|
+
.description("Show config file path")
|
|
155
174
|
.action(() => {
|
|
156
175
|
console.log(config.getConfigPath());
|
|
157
176
|
flushStdioThenExit(0);
|
|
158
177
|
});
|
|
159
178
|
|
|
160
179
|
program
|
|
161
|
-
.command(
|
|
162
|
-
.description(
|
|
163
|
-
.
|
|
180
|
+
.command("web-fetch")
|
|
181
|
+
.description("Fetch webpage content from a URL (markdown, text, or html)")
|
|
182
|
+
.requiredOption("-u, --url <url>", "page URL to fetch")
|
|
183
|
+
.option(
|
|
184
|
+
"-f, --format <format>",
|
|
185
|
+
"output format: html, text, markdown",
|
|
186
|
+
"markdown"
|
|
187
|
+
)
|
|
188
|
+
.option(
|
|
189
|
+
"--target-selector <selector>",
|
|
190
|
+
"CSS selector for target element only"
|
|
191
|
+
)
|
|
192
|
+
.option(
|
|
193
|
+
"--wait-for-selector <selector>",
|
|
194
|
+
"wait for selector before fetching"
|
|
195
|
+
)
|
|
196
|
+
.option("--readability", "use readability (main content only)")
|
|
197
|
+
.option("--crawl-mode <mode>", "crawl mode: fast or fine", "fast")
|
|
198
|
+
.option("-t, --timeout <seconds>", "request timeout in seconds", "60")
|
|
199
|
+
.option("-j, --json", "output full API response as JSON")
|
|
200
|
+
.action(async (opts) => {
|
|
201
|
+
const timeoutMs = parseInt(opts.timeout, 10) * 1000;
|
|
202
|
+
const code = await webFetch({
|
|
203
|
+
url: opts.url,
|
|
204
|
+
format: opts.format,
|
|
205
|
+
targetSelector: opts.targetSelector,
|
|
206
|
+
waitForSelector: opts.waitForSelector,
|
|
207
|
+
readability: opts.readability,
|
|
208
|
+
crawlMode: opts.crawlMode,
|
|
209
|
+
timeoutMs: Number.isNaN(timeoutMs) ? 60000 : timeoutMs,
|
|
210
|
+
json: opts.json,
|
|
211
|
+
});
|
|
212
|
+
process.exitCode = code;
|
|
213
|
+
flushStdioThenExit(code);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
program
|
|
217
|
+
.command("youtube-subtitling")
|
|
218
|
+
.description("Fetch YouTube video subtitles/captions by video URL or ID")
|
|
219
|
+
.requiredOption("-v, --video-code <url-or-id>", "YouTube video URL or video ID (e.g. https://youtube.com/watch?v=ID)")
|
|
220
|
+
.option("-l, --language <code>", "Subtitle language (e.g. en, zh-CN)")
|
|
221
|
+
.option("--with-time", "Include start/duration per segment")
|
|
222
|
+
.option("-j, --json", "Output full API response as JSON")
|
|
223
|
+
.action(async (opts) => {
|
|
224
|
+
const code = await youtubeSubtitling({
|
|
225
|
+
videoCode: opts.videoCode,
|
|
226
|
+
language: opts.language,
|
|
227
|
+
withTime: opts.withTime,
|
|
228
|
+
json: opts.json,
|
|
229
|
+
});
|
|
230
|
+
process.exitCode = code;
|
|
231
|
+
flushStdioThenExit(code);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
program
|
|
235
|
+
.command("summarize")
|
|
236
|
+
.description("Summarize text or URL (coming when API is available)")
|
|
237
|
+
.argument("[input]", "text or URL to summarize")
|
|
164
238
|
.action(() => {
|
|
165
|
-
console.error(
|
|
239
|
+
console.error("summarize: not yet implemented. Use felo search for now.");
|
|
166
240
|
flushStdioThenExit(1);
|
|
167
241
|
});
|
|
168
242
|
|
|
169
243
|
program
|
|
170
|
-
.command(
|
|
171
|
-
.description(
|
|
172
|
-
.argument(
|
|
244
|
+
.command("translate")
|
|
245
|
+
.description("Translate text (coming when API is available)")
|
|
246
|
+
.argument("[text]", "text to translate")
|
|
173
247
|
.action(() => {
|
|
174
|
-
console.error(
|
|
248
|
+
console.error("translate: not yet implemented. Use felo search for now.");
|
|
175
249
|
flushStdioThenExit(1);
|
|
176
250
|
});
|
|
177
251
|
|
package/src/webFetch.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getApiKey, NO_KEY_MESSAGE } from './search.js';
|
|
2
|
+
import * as config from './config.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_API_BASE = 'https://openapi.felo.ai';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
6
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
8
|
+
const STATUS_PAD = 56;
|
|
9
|
+
|
|
10
|
+
function startSpinner(message) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
let i = 0;
|
|
13
|
+
const id = setInterval(() => {
|
|
14
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
15
|
+
const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
|
|
16
|
+
process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
|
|
17
|
+
i += 1;
|
|
18
|
+
}, SPINNER_INTERVAL_MS);
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stopSpinner(id) {
|
|
23
|
+
if (id != null) clearInterval(id);
|
|
24
|
+
process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getApiBase() {
|
|
28
|
+
let base = process.env.FELO_API_BASE?.trim();
|
|
29
|
+
if (!base) {
|
|
30
|
+
const v = await config.getConfigValue('FELO_API_BASE');
|
|
31
|
+
base = typeof v === 'string' ? v.trim() : '';
|
|
32
|
+
}
|
|
33
|
+
return (base || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getMessage(payload) {
|
|
37
|
+
return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringifyContent(content) {
|
|
41
|
+
if (content == null) return '';
|
|
42
|
+
if (typeof content === 'string') return content;
|
|
43
|
+
if (typeof content === 'object') {
|
|
44
|
+
if (content.markdown) return content.markdown;
|
|
45
|
+
if (content.text) return content.text;
|
|
46
|
+
if (content.html) return content.html;
|
|
47
|
+
return JSON.stringify(content, null, 2);
|
|
48
|
+
}
|
|
49
|
+
return String(content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchContent(apiBase, apiKey, body, timeoutMs) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${apiBase}/v2/web/extract`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
Accept: 'application/json',
|
|
60
|
+
Authorization: `Bearer ${apiKey}`,
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
let data = {};
|
|
67
|
+
try {
|
|
68
|
+
data = await res.json();
|
|
69
|
+
} catch {
|
|
70
|
+
data = {};
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
|
|
73
|
+
const code = data.code;
|
|
74
|
+
const hasData = data?.data != null;
|
|
75
|
+
const successCodes = [0, 200];
|
|
76
|
+
const ok =
|
|
77
|
+
successCodes.includes(Number(code)) ||
|
|
78
|
+
code === undefined ||
|
|
79
|
+
code === null ||
|
|
80
|
+
(hasData && res.ok);
|
|
81
|
+
if (!ok) throw new Error(getMessage(data));
|
|
82
|
+
return data;
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run web fetch and print result. Returns exit code (0 or 1).
|
|
90
|
+
* @param {Object} opts - { url, format, targetSelector, waitForSelector, readability, timeoutMs, json }
|
|
91
|
+
*/
|
|
92
|
+
export async function webFetch(opts) {
|
|
93
|
+
const apiKey = await getApiKey();
|
|
94
|
+
if (!apiKey) {
|
|
95
|
+
console.error(NO_KEY_MESSAGE.trim());
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
if (!opts?.url || typeof opts.url !== 'string' || !opts.url.trim()) {
|
|
99
|
+
process.stderr.write('ERROR: URL is required and must be a non-empty string.\n');
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const apiBase = await getApiBase();
|
|
104
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
105
|
+
? opts.timeoutMs
|
|
106
|
+
: DEFAULT_TIMEOUT_MS;
|
|
107
|
+
|
|
108
|
+
const shortUrl = opts.url.length > 45 ? opts.url.slice(0, 42) + '...' : opts.url;
|
|
109
|
+
const spinnerId = startSpinner(`Fetching ${shortUrl}`);
|
|
110
|
+
|
|
111
|
+
const body = {
|
|
112
|
+
url: opts.url,
|
|
113
|
+
output_format: opts.format || 'markdown',
|
|
114
|
+
crawl_mode: opts.crawlMode || 'fast',
|
|
115
|
+
with_readability: Boolean(opts.readability),
|
|
116
|
+
timeout: timeoutMs,
|
|
117
|
+
};
|
|
118
|
+
if (opts.targetSelector) body.target_selector = opts.targetSelector;
|
|
119
|
+
if (opts.waitForSelector) body.wait_for_selector = opts.waitForSelector;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const payload = await fetchContent(apiBase, apiKey, body, timeoutMs);
|
|
123
|
+
const content = payload?.data?.content;
|
|
124
|
+
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const out = stringifyContent(content);
|
|
131
|
+
const isEmpty = out == null || String(out).trim() === '';
|
|
132
|
+
if (isEmpty) {
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
`No content fetched from ${opts.url}. The page may be empty, blocked, or the selector did not match.\n`
|
|
135
|
+
);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
console.log(out);
|
|
139
|
+
return 0;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
`Web fetch failed for ${opts.url}: ${err?.message || err}\n`
|
|
143
|
+
);
|
|
144
|
+
return 1;
|
|
145
|
+
} finally {
|
|
146
|
+
stopSpinner(spinnerId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getApiKey, NO_KEY_MESSAGE } from './search.js';
|
|
2
|
+
import * as config from './config.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_API_BASE = 'https://openapi.felo.ai';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
8
|
+
const STATUS_PAD = 52;
|
|
9
|
+
|
|
10
|
+
function startSpinner(message) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
let i = 0;
|
|
13
|
+
const id = setInterval(() => {
|
|
14
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
15
|
+
const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
|
|
16
|
+
process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
|
|
17
|
+
i += 1;
|
|
18
|
+
}, SPINNER_INTERVAL_MS);
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stopSpinner(id) {
|
|
23
|
+
if (id != null) clearInterval(id);
|
|
24
|
+
process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extract video ID from a YouTube URL or return the string if it looks like a plain ID. Returns null if invalid. */
|
|
28
|
+
function extractVideoId(urlOrId) {
|
|
29
|
+
const s = typeof urlOrId === 'string' ? urlOrId.trim() : '';
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
try {
|
|
32
|
+
if (s.startsWith('http://') || s.startsWith('https://')) {
|
|
33
|
+
const u = new URL(s);
|
|
34
|
+
if (u.hostname === 'youtu.be') return u.pathname.slice(1).split('?')[0] || null;
|
|
35
|
+
if (u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') {
|
|
36
|
+
if (u.pathname === '/watch') return u.searchParams.get('v');
|
|
37
|
+
const m = u.pathname.match(/^\/(?:embed|v)\/([a-zA-Z0-9_-]{10,12})/);
|
|
38
|
+
if (m) return m[1];
|
|
39
|
+
return u.searchParams.get('v');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (/^[a-zA-Z0-9_-]{10,12}$/.test(s)) return s;
|
|
43
|
+
return null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getApiBase() {
|
|
50
|
+
let base = process.env.FELO_API_BASE?.trim();
|
|
51
|
+
if (!base) {
|
|
52
|
+
const v = await config.getConfigValue('FELO_API_BASE');
|
|
53
|
+
base = typeof v === 'string' ? v.trim() : '';
|
|
54
|
+
}
|
|
55
|
+
return (base || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getMessage(payload) {
|
|
59
|
+
return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatContents(contents, withTime) {
|
|
63
|
+
if (!Array.isArray(contents) || contents.length === 0) return '';
|
|
64
|
+
return contents
|
|
65
|
+
.map((c) => {
|
|
66
|
+
if (withTime && (c.start != null || c.duration != null)) {
|
|
67
|
+
const start = Number(c.start);
|
|
68
|
+
const dur = Number(c.duration);
|
|
69
|
+
const startSec = Number.isFinite(start) ? start : 0;
|
|
70
|
+
const durSec = Number.isFinite(dur) ? dur : 0;
|
|
71
|
+
return `[${startSec.toFixed(2)}s, +${durSec.toFixed(2)}s] ${c.text ?? ''}`;
|
|
72
|
+
}
|
|
73
|
+
return c.text ?? '';
|
|
74
|
+
})
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchSubtitling(apiBase, apiKey, params, timeoutMs) {
|
|
80
|
+
const url = `${apiBase}/v2/youtube/subtitling?${params.toString()}`;
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method: 'GET',
|
|
86
|
+
headers: {
|
|
87
|
+
Accept: 'application/json',
|
|
88
|
+
Authorization: `Bearer ${apiKey}`,
|
|
89
|
+
},
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
let data = {};
|
|
93
|
+
try {
|
|
94
|
+
data = await res.json();
|
|
95
|
+
} catch {
|
|
96
|
+
data = {};
|
|
97
|
+
}
|
|
98
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
|
|
99
|
+
const code = data.code;
|
|
100
|
+
const hasData = data?.data != null;
|
|
101
|
+
const successCodes = [0, 200];
|
|
102
|
+
const ok =
|
|
103
|
+
successCodes.includes(Number(code)) ||
|
|
104
|
+
code === undefined ||
|
|
105
|
+
code === null ||
|
|
106
|
+
(hasData && res.ok);
|
|
107
|
+
if (!ok) throw new Error(getMessage(data));
|
|
108
|
+
return data;
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run YouTube subtitling and print result. Returns exit code (0 or 1).
|
|
116
|
+
* @param {Object} opts - { videoCode, language, withTime, json }
|
|
117
|
+
*/
|
|
118
|
+
export async function youtubeSubtitling(opts) {
|
|
119
|
+
const apiKey = await getApiKey();
|
|
120
|
+
if (!apiKey) {
|
|
121
|
+
console.error(NO_KEY_MESSAGE.trim());
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const raw = opts?.videoCode != null ? String(opts.videoCode).trim() : '';
|
|
125
|
+
if (!raw) {
|
|
126
|
+
process.stderr.write('ERROR: YouTube video URL or video ID is required.\n');
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const videoCode = extractVideoId(raw);
|
|
130
|
+
if (!videoCode) {
|
|
131
|
+
process.stderr.write('ERROR: Invalid YouTube URL or video ID. Use a link (e.g. https://youtube.com/watch?v=ID) or an 11-character video ID.\n');
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const apiBase = await getApiBase();
|
|
136
|
+
const timeoutMs =
|
|
137
|
+
Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
138
|
+
|
|
139
|
+
const spinnerId = startSpinner(`Fetching subtitles ${videoCode}`);
|
|
140
|
+
|
|
141
|
+
const params = new URLSearchParams({ video_code: videoCode });
|
|
142
|
+
if (opts.language && String(opts.language).trim()) params.set('language', String(opts.language).trim());
|
|
143
|
+
if (opts.withTime) params.set('with_time', 'true');
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const payload = await fetchSubtitling(apiBase, apiKey, params, timeoutMs);
|
|
147
|
+
const data = payload?.data ?? {};
|
|
148
|
+
const title = data?.title ?? '';
|
|
149
|
+
const contents = data?.contents ?? [];
|
|
150
|
+
|
|
151
|
+
if (opts.json) {
|
|
152
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const text = formatContents(contents, opts.withTime);
|
|
157
|
+
const isEmpty = !text || text.trim() === '';
|
|
158
|
+
|
|
159
|
+
if (isEmpty) {
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
`No subtitles found for video ${videoCode}. The video may have no captions or the language is not available.\n`
|
|
162
|
+
);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (title) {
|
|
167
|
+
console.log(`# ${title}\n`);
|
|
168
|
+
}
|
|
169
|
+
console.log(text);
|
|
170
|
+
return 0;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`YouTube subtitling failed for ${videoCode}: ${err?.message || err}\n`
|
|
174
|
+
);
|
|
175
|
+
return 1;
|
|
176
|
+
} finally {
|
|
177
|
+
stopSpinner(spinnerId);
|
|
178
|
+
}
|
|
179
|
+
}
|