felo-ai 0.2.4 → 0.2.6

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.
@@ -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 fetchExtract(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 extract 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 webExtract(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 fetchExtract(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 extracted 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 extract 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
+ }