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/src/cli.js CHANGED
@@ -1,13 +1,15 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
- import { createRequire } from 'module';
4
- import { Command } from 'commander';
5
- import { search } from './search.js';
6
- import { slides } from './slides.js';
7
- import * as config from './config.js';
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('../package.json');
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('', () => doExit());
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('', () => flushStderr());
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('felo')
41
- .description('Felo AI CLI - real-time search from the terminal')
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('search')
46
- .description('Search for current information (weather, news, docs, etc.)')
47
- .argument('<query>', 'search query')
48
- .option('-j, --json', 'output raw JSON')
49
- .option('-v, --verbose', 'show query analysis and sources')
50
- .option('-t, --timeout <seconds>', 'request timeout in seconds', '60')
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('slides')
64
- .description('Generate PPT/slides from a prompt (async task, outputs live doc URL when done)')
65
- .argument('<query>', 'PPT generation prompt (e.g. "Felo, 2 pages" or "Introduction to React")')
66
- .option('-j, --json', 'output raw JSON with task_id and live_doc_url')
67
- .option('-v, --verbose', 'show polling status')
68
- .option('-t, --timeout <seconds>', 'request timeout in seconds for each API call', '60')
69
- .option('--poll-timeout <seconds>', 'max seconds to wait for task completion', '1200')
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('config')
85
- .description('Manage persisted config (e.g. FELO_API_KEY). Stored in ~/.felo/config.json');
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('set <key> <value>')
89
- .description('Set a config value (e.g. felo config set FELO_API_KEY your-key)')
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('Error:', e.message);
115
+ console.error("Error:", e.message);
97
116
  flushStdioThenExit(1);
98
117
  }
99
118
  });
100
119
 
101
120
  configCmd
102
- .command('get <key>')
103
- .description('Get a config value (sensitive keys are masked)')
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('(not set)');
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('Error:', e.message);
133
+ console.error("Error:", e.message);
115
134
  flushStdioThenExit(1);
116
135
  }
117
136
  });
118
137
 
119
138
  configCmd
120
- .command('list')
121
- .description('List all config keys (values are hidden)')
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('No config set. Use: felo config set FELO_API_KEY <key>');
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('Error:', e.message);
152
+ console.error("Error:", e.message);
134
153
  flushStdioThenExit(1);
135
154
  }
136
155
  });
137
156
 
138
157
  configCmd
139
- .command('unset <key>')
140
- .description('Remove a config value')
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('Error:', e.message);
166
+ console.error("Error:", e.message);
148
167
  flushStdioThenExit(1);
149
168
  }
150
169
  });
151
170
 
152
171
  configCmd
153
- .command('path')
154
- .description('Show config file path')
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('summarize')
162
- .description('Summarize text or URL (coming when API is available)')
163
- .argument('[input]', 'text or URL to summarize')
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('summarize: not yet implemented. Use felo search for now.');
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('translate')
171
- .description('Translate text (coming when API is available)')
172
- .argument('[text]', 'text to translate')
244
+ .command("translate")
245
+ .description("Translate text (coming when API is available)")
246
+ .argument("[text]", "text to translate")
173
247
  .action(() => {
174
- console.error('translate: not yet implemented. Use felo search for now.');
248
+ console.error("translate: not yet implemented. Use felo search for now.");
175
249
  flushStdioThenExit(1);
176
250
  });
177
251
 
@@ -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
+ }