felo-ai 0.2.6 → 0.2.9

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.
Files changed (39) hide show
  1. package/.github/workflows/publish-npm.yml +39 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CONTRIBUTING.md +346 -346
  4. package/README.en.md +129 -129
  5. package/README.md +435 -408
  6. package/docs/EXAMPLES.md +632 -632
  7. package/docs/FAQ.md +479 -479
  8. package/felo-search/LICENSE +21 -21
  9. package/felo-search/README.md +440 -440
  10. package/felo-search/SKILL.md +291 -291
  11. package/felo-slides/LICENSE +21 -21
  12. package/felo-slides/README.md +87 -87
  13. package/felo-slides/SKILL.md +166 -166
  14. package/felo-slides/scripts/run_ppt_task.mjs +251 -251
  15. package/felo-superAgent/LICENSE +21 -0
  16. package/felo-superAgent/README.md +125 -0
  17. package/felo-superAgent/SKILL.md +165 -0
  18. package/felo-web-fetch/README.md +127 -0
  19. package/felo-web-fetch/SKILL.md +204 -0
  20. package/felo-web-fetch/scripts/run_web_fetch.mjs +316 -0
  21. package/felo-x-search/SKILL.md +204 -0
  22. package/felo-x-search/scripts/run_x_search.mjs +385 -0
  23. package/felo-youtube-subtitling/README.md +59 -59
  24. package/felo-youtube-subtitling/SKILL.md +161 -161
  25. package/felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs +239 -239
  26. package/package.json +37 -35
  27. package/src/cli.js +370 -252
  28. package/src/config.js +66 -66
  29. package/src/search.js +142 -142
  30. package/src/slides.js +332 -332
  31. package/src/superAgent.js +609 -0
  32. package/src/{webExtract.js → webFetch.js} +148 -148
  33. package/src/xSearch.js +366 -0
  34. package/src/youtubeSubtitling.js +179 -179
  35. package/tests/config.test.js +78 -78
  36. package/tests/search.test.js +100 -100
  37. package/felo-web-extract/README.md +0 -78
  38. package/felo-web-extract/SKILL.md +0 -200
  39. package/felo-web-extract/scripts/run_web_extract.mjs +0 -232
@@ -1,179 +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
- }
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
+ }
@@ -1,78 +1,78 @@
1
- import { describe, it, before, after } from 'node:test';
2
- import assert from 'node:assert';
3
- import fs from 'fs/promises';
4
- import path from 'path';
5
- import os from 'os';
6
-
7
- const tmpDir = path.join(os.tmpdir(), `felo-test-${Date.now()}`);
8
- const testConfigFile = path.join(tmpDir, 'config.json');
9
- process.env.FELO_CONFIG_FILE = testConfigFile;
10
-
11
- import * as config from '../src/config.js';
12
-
13
- after(async () => {
14
- delete process.env.FELO_CONFIG_FILE;
15
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
16
- });
17
-
18
- describe('config', () => {
19
- it('getConfig returns {} when file does not exist', async () => {
20
- const c = await config.getConfig();
21
- assert.deepStrictEqual(c, {});
22
- });
23
-
24
- it('setConfig and getConfigValue', async () => {
25
- await config.setConfig('FELO_API_KEY', 'fk-secret-123');
26
- const v = await config.getConfigValue('FELO_API_KEY');
27
- assert.strictEqual(v, 'fk-secret-123');
28
- });
29
-
30
- it('listConfig returns all keys', async () => {
31
- await config.setConfig('OTHER', 'value');
32
- const c = await config.listConfig();
33
- assert.ok('FELO_API_KEY' in c);
34
- assert.ok('OTHER' in c);
35
- });
36
-
37
- it('unsetConfig removes key', async () => {
38
- await config.unsetConfig('OTHER');
39
- const v = await config.getConfigValue('OTHER');
40
- assert.strictEqual(v, undefined);
41
- });
42
-
43
- it('getConfigPath returns configured path', () => {
44
- assert.strictEqual(config.getConfigPath(), testConfigFile);
45
- });
46
-
47
- it('getConfig returns {} and warns on invalid JSON', async () => {
48
- await config.setConfig('X', '1');
49
- await fs.writeFile(testConfigFile, 'not json {', 'utf-8');
50
- let writeCalls = 0;
51
- const orig = process.stderr.write;
52
- process.stderr.write = () => { writeCalls++; };
53
- const c = await config.getConfig();
54
- process.stderr.write = orig;
55
- assert.deepStrictEqual(c, {});
56
- assert.ok(writeCalls >= 1);
57
- });
58
-
59
- describe('maskValueForDisplay', () => {
60
- it('masks FELO_API_KEY when value long enough', () => {
61
- const val = 'fk-abc123xyz789';
62
- assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', val), 'fk-a...z789');
63
- });
64
-
65
- it('does not mask short value', () => {
66
- assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', 'short'), 'short');
67
- });
68
-
69
- it('does not mask non-sensitive key', () => {
70
- assert.strictEqual(config.maskValueForDisplay('LOG_LEVEL', 'debug'), 'debug');
71
- });
72
-
73
- it('returns value for undefined/null', () => {
74
- assert.strictEqual(config.maskValueForDisplay('X', undefined), undefined);
75
- assert.strictEqual(config.maskValueForDisplay('X', null), null);
76
- });
77
- });
78
- });
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ const tmpDir = path.join(os.tmpdir(), `felo-test-${Date.now()}`);
8
+ const testConfigFile = path.join(tmpDir, 'config.json');
9
+ process.env.FELO_CONFIG_FILE = testConfigFile;
10
+
11
+ import * as config from '../src/config.js';
12
+
13
+ after(async () => {
14
+ delete process.env.FELO_CONFIG_FILE;
15
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
16
+ });
17
+
18
+ describe('config', () => {
19
+ it('getConfig returns {} when file does not exist', async () => {
20
+ const c = await config.getConfig();
21
+ assert.deepStrictEqual(c, {});
22
+ });
23
+
24
+ it('setConfig and getConfigValue', async () => {
25
+ await config.setConfig('FELO_API_KEY', 'fk-secret-123');
26
+ const v = await config.getConfigValue('FELO_API_KEY');
27
+ assert.strictEqual(v, 'fk-secret-123');
28
+ });
29
+
30
+ it('listConfig returns all keys', async () => {
31
+ await config.setConfig('OTHER', 'value');
32
+ const c = await config.listConfig();
33
+ assert.ok('FELO_API_KEY' in c);
34
+ assert.ok('OTHER' in c);
35
+ });
36
+
37
+ it('unsetConfig removes key', async () => {
38
+ await config.unsetConfig('OTHER');
39
+ const v = await config.getConfigValue('OTHER');
40
+ assert.strictEqual(v, undefined);
41
+ });
42
+
43
+ it('getConfigPath returns configured path', () => {
44
+ assert.strictEqual(config.getConfigPath(), testConfigFile);
45
+ });
46
+
47
+ it('getConfig returns {} and warns on invalid JSON', async () => {
48
+ await config.setConfig('X', '1');
49
+ await fs.writeFile(testConfigFile, 'not json {', 'utf-8');
50
+ let writeCalls = 0;
51
+ const orig = process.stderr.write;
52
+ process.stderr.write = () => { writeCalls++; };
53
+ const c = await config.getConfig();
54
+ process.stderr.write = orig;
55
+ assert.deepStrictEqual(c, {});
56
+ assert.ok(writeCalls >= 1);
57
+ });
58
+
59
+ describe('maskValueForDisplay', () => {
60
+ it('masks FELO_API_KEY when value long enough', () => {
61
+ const val = 'fk-abc123xyz789';
62
+ assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', val), 'fk-a...z789');
63
+ });
64
+
65
+ it('does not mask short value', () => {
66
+ assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', 'short'), 'short');
67
+ });
68
+
69
+ it('does not mask non-sensitive key', () => {
70
+ assert.strictEqual(config.maskValueForDisplay('LOG_LEVEL', 'debug'), 'debug');
71
+ });
72
+
73
+ it('returns value for undefined/null', () => {
74
+ assert.strictEqual(config.maskValueForDisplay('X', undefined), undefined);
75
+ assert.strictEqual(config.maskValueForDisplay('X', null), null);
76
+ });
77
+ });
78
+ });