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.
- package/.github/workflows/publish-npm.yml +39 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +346 -346
- package/README.en.md +129 -129
- package/README.md +435 -408
- package/docs/EXAMPLES.md +632 -632
- package/docs/FAQ.md +479 -479
- package/felo-search/LICENSE +21 -21
- package/felo-search/README.md +440 -440
- package/felo-search/SKILL.md +291 -291
- package/felo-slides/LICENSE +21 -21
- package/felo-slides/README.md +87 -87
- package/felo-slides/SKILL.md +166 -166
- package/felo-slides/scripts/run_ppt_task.mjs +251 -251
- package/felo-superAgent/LICENSE +21 -0
- package/felo-superAgent/README.md +125 -0
- package/felo-superAgent/SKILL.md +165 -0
- package/felo-web-fetch/README.md +127 -0
- package/felo-web-fetch/SKILL.md +204 -0
- package/felo-web-fetch/scripts/run_web_fetch.mjs +316 -0
- package/felo-x-search/SKILL.md +204 -0
- package/felo-x-search/scripts/run_x_search.mjs +385 -0
- package/felo-youtube-subtitling/README.md +59 -59
- package/felo-youtube-subtitling/SKILL.md +161 -161
- package/felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs +239 -239
- package/package.json +37 -35
- package/src/cli.js +370 -252
- package/src/config.js +66 -66
- package/src/search.js +142 -142
- package/src/slides.js +332 -332
- package/src/superAgent.js +609 -0
- package/src/{webExtract.js → webFetch.js} +148 -148
- package/src/xSearch.js +366 -0
- package/src/youtubeSubtitling.js +179 -179
- package/tests/config.test.js +78 -78
- package/tests/search.test.js +100 -100
- package/felo-web-extract/README.md +0 -78
- package/felo-web-extract/SKILL.md +0 -200
- package/felo-web-extract/scripts/run_web_extract.mjs +0 -232
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_BASE = 'https://openapi.felo.ai';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
const MAX_RETRIES = 3;
|
|
6
|
+
const RETRY_BASE_MS = 1000;
|
|
7
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
8
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
9
|
+
const STATUS_PAD = 56;
|
|
10
|
+
|
|
11
|
+
function startSpinner(message) {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
let i = 0;
|
|
14
|
+
const id = setInterval(() => {
|
|
15
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
16
|
+
const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
|
|
17
|
+
process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
|
|
18
|
+
i += 1;
|
|
19
|
+
}, SPINNER_INTERVAL_MS);
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stopSpinner(id) {
|
|
24
|
+
if (id != null) clearInterval(id);
|
|
25
|
+
process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sleep(ms) {
|
|
29
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getMessage(payload) {
|
|
33
|
+
return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchWithRetry(url, init, timeoutMs) {
|
|
37
|
+
let lastError;
|
|
38
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
43
|
+
if (res.status >= 500 && attempt < MAX_RETRIES) {
|
|
44
|
+
await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return res;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
lastError = err;
|
|
50
|
+
if (err.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
|
|
51
|
+
if (attempt < MAX_RETRIES) {
|
|
52
|
+
await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw lastError;
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw lastError;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function postApi(apiBase, apiKey, path, body, timeoutMs) {
|
|
64
|
+
const res = await fetchWithRetry(
|
|
65
|
+
`${apiBase}${path}`,
|
|
66
|
+
{
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
Accept: 'application/json',
|
|
70
|
+
Authorization: `Bearer ${apiKey}`,
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
},
|
|
75
|
+
timeoutMs,
|
|
76
|
+
);
|
|
77
|
+
let data = {};
|
|
78
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
|
|
80
|
+
if (data.status === 'error') throw new Error(getMessage(data));
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Formatting ──
|
|
85
|
+
|
|
86
|
+
function formatNumber(num) {
|
|
87
|
+
if (num == null) return '0';
|
|
88
|
+
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
|
89
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
90
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
91
|
+
return String(num);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatUser(u, headerLevel = 2) {
|
|
95
|
+
if (!u) return '';
|
|
96
|
+
const h = '#'.repeat(Math.min(6, headerLevel));
|
|
97
|
+
let badge = '';
|
|
98
|
+
if (u.blue_verified) badge = ' 🔵';
|
|
99
|
+
else if (u.verified) badge = ' ✓';
|
|
100
|
+
|
|
101
|
+
let out = `${h} @${u.username} (${u.display_name || u.username}${badge})\n`;
|
|
102
|
+
out += `- User ID: \`${u.user_id}\`\n`;
|
|
103
|
+
if (u.verified_type) out += `- Verification Type: ${u.verified_type}\n`;
|
|
104
|
+
const status = [];
|
|
105
|
+
if (u.protected) status.push('Protected (Private)');
|
|
106
|
+
if (u.is_automated) status.push('Automated');
|
|
107
|
+
if (status.length) out += `- Account Status: ${status.join(' | ')}\n`;
|
|
108
|
+
out += `- Followers: ${formatNumber(u.followers_count)}\n`;
|
|
109
|
+
out += `- Following: ${formatNumber(u.following_count)}\n`;
|
|
110
|
+
out += `- Total Tweets: ${formatNumber(u.tweet_count)}\n`;
|
|
111
|
+
if (u.favorites_count > 0) out += `- Likes Given: ${formatNumber(u.favorites_count)}\n`;
|
|
112
|
+
if (u.media_count > 0) out += `- Media Count: ${formatNumber(u.media_count)}\n`;
|
|
113
|
+
if (u.description) out += `- Bio: ${u.description}\n`;
|
|
114
|
+
if (u.location) out += `- Location: ${u.location}\n`;
|
|
115
|
+
if (u.url) out += `- Website: ${u.url}\n`;
|
|
116
|
+
if (u.can_dm) out += `- Direct Messages: Open\n`;
|
|
117
|
+
if (u.profile_image_url) out += `- Profile Image: ${u.profile_image_url}\n`;
|
|
118
|
+
if (u.cover_image_url) out += `- Cover Image: ${u.cover_image_url}\n`;
|
|
119
|
+
if (u.pinned_tweet_ids?.length) out += `- Pinned Tweets: ${u.pinned_tweet_ids.length} tweet(s)\n`;
|
|
120
|
+
if (u.created_at) out += `- Account Created: ${u.created_at}\n`;
|
|
121
|
+
out += '\n---\n\n';
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatTweet(t, indent = '', headerLevel = 3) {
|
|
126
|
+
if (!t) return '';
|
|
127
|
+
const h = '#'.repeat(Math.min(6, headerLevel));
|
|
128
|
+
const author = t.author || {};
|
|
129
|
+
const verified = author.verified ? ' ✓' : '';
|
|
130
|
+
|
|
131
|
+
let out = `${indent}${h} @${author.username || 'unknown'}(${author.display_name || author.username || 'unknown'}${verified})\n`;
|
|
132
|
+
const meta = [`Posted: ${t.created_at || 'unknown'}`, `Tweet ID: \`${t.id}\``];
|
|
133
|
+
if (t.conversation_id) meta.push(`Conversation: \`${t.conversation_id}\``);
|
|
134
|
+
if (t.is_reply && t.in_reply_to_username) meta.push(`Reply to @${t.in_reply_to_username}`);
|
|
135
|
+
out += `${indent}- ${meta.join(' | ')}\n\n`;
|
|
136
|
+
|
|
137
|
+
for (const line of (t.content || '').split('\n')) {
|
|
138
|
+
out += `${indent}${line}\n`;
|
|
139
|
+
}
|
|
140
|
+
out += '\n';
|
|
141
|
+
|
|
142
|
+
const metrics = t.metrics || {};
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (metrics.favorite_count) parts.push(`${formatNumber(metrics.favorite_count)} likes`);
|
|
145
|
+
if (metrics.retweet_count) parts.push(`${formatNumber(metrics.retweet_count)} retweets`);
|
|
146
|
+
if (metrics.reply_count) parts.push(`${formatNumber(metrics.reply_count)} replies`);
|
|
147
|
+
if (parts.length) out += `${indent}Engagement: ${parts.join(' | ')}\n`;
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(t.media_urls) && t.media_urls.length) {
|
|
150
|
+
out += `${indent}Media (${t.media_urls.length}):\n`;
|
|
151
|
+
for (const media of t.media_urls) {
|
|
152
|
+
const type = media.type || 'photo';
|
|
153
|
+
if (type === 'video') {
|
|
154
|
+
out += `${indent} • [video] ${media.url || ''}\n`;
|
|
155
|
+
} else {
|
|
156
|
+
out += `${indent} • [photo] ${media.thumbnail || ''}\n`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
out += `\n${indent}---\n\n`;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── CLI ──
|
|
166
|
+
|
|
167
|
+
function usage() {
|
|
168
|
+
console.error(
|
|
169
|
+
[
|
|
170
|
+
'Usage:',
|
|
171
|
+
' node felo-x-search/scripts/run_x_search.mjs [query] [options]',
|
|
172
|
+
'',
|
|
173
|
+
'Examples:',
|
|
174
|
+
' run_x_search.mjs "AI news" Search tweets (default)',
|
|
175
|
+
' run_x_search.mjs "OpenAI" --user Search users',
|
|
176
|
+
' run_x_search.mjs --id "1234567890" Get tweet replies',
|
|
177
|
+
' run_x_search.mjs --id "elonmusk" --user Get user info',
|
|
178
|
+
' run_x_search.mjs --id "elonmusk" --user --tweets Get user tweets',
|
|
179
|
+
'',
|
|
180
|
+
'Options:',
|
|
181
|
+
' -q, --query <text> Search keyword (same as positional arg)',
|
|
182
|
+
' --id <values> Tweet IDs or usernames (comma-separated)',
|
|
183
|
+
' --user Switch to user mode',
|
|
184
|
+
' --tweets Get user tweets (with --id --user)',
|
|
185
|
+
' -l, --limit <n> Number of results',
|
|
186
|
+
' --cursor <str> Pagination cursor',
|
|
187
|
+
' --include-replies Include replies (with --tweets)',
|
|
188
|
+
' --query-type <type> Query type filter',
|
|
189
|
+
' --since-time <val> Start time filter',
|
|
190
|
+
' --until-time <val> End time filter',
|
|
191
|
+
' -j, --json Output raw JSON',
|
|
192
|
+
' -t, --timeout <ms> Request timeout in ms (default: 30000)',
|
|
193
|
+
' --help Show this help',
|
|
194
|
+
].join('\n'),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseArgs(argv) {
|
|
199
|
+
const out = {
|
|
200
|
+
query: '',
|
|
201
|
+
ids: [],
|
|
202
|
+
user: false,
|
|
203
|
+
tweets: false,
|
|
204
|
+
limit: 0,
|
|
205
|
+
cursor: '',
|
|
206
|
+
includeReplies: false,
|
|
207
|
+
queryType: '',
|
|
208
|
+
sinceTime: null,
|
|
209
|
+
untilTime: null,
|
|
210
|
+
json: false,
|
|
211
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
212
|
+
help: false,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const positional = [];
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < argv.length; i++) {
|
|
218
|
+
const a = argv[i];
|
|
219
|
+
if (a === '--help' || a === '-h') { out.help = true; }
|
|
220
|
+
else if (a === '--json' || a === '-j') { out.json = true; }
|
|
221
|
+
else if (a === '--user') { out.user = true; }
|
|
222
|
+
else if (a === '--tweets') { out.tweets = true; }
|
|
223
|
+
else if (a === '--include-replies') { out.includeReplies = true; }
|
|
224
|
+
else if (a === '-q' || a === '--query') { out.query = (argv[++i] || '').trim(); }
|
|
225
|
+
else if (a === '--id') { out.ids = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); }
|
|
226
|
+
else if (a === '-l' || a === '--limit') { out.limit = parseInt(argv[++i] || '', 10) || 0; }
|
|
227
|
+
else if (a === '--cursor') { out.cursor = (argv[++i] || '').trim(); }
|
|
228
|
+
else if (a === '--query-type') { out.queryType = (argv[++i] || '').trim(); }
|
|
229
|
+
else if (a === '--since-time') { out.sinceTime = argv[++i]; }
|
|
230
|
+
else if (a === '--until-time') { out.untilTime = argv[++i]; }
|
|
231
|
+
else if (a === '-t' || a === '--timeout') {
|
|
232
|
+
const n = parseInt(argv[++i] || '', 10);
|
|
233
|
+
if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
|
|
234
|
+
}
|
|
235
|
+
else if (!a.startsWith('-')) { positional.push(a); }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// positional arg as query
|
|
239
|
+
if (!out.query && positional.length) {
|
|
240
|
+
out.query = positional.join(' ').trim();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function main() {
|
|
247
|
+
const args = parseArgs(process.argv.slice(2));
|
|
248
|
+
if (args.help) { usage(); process.exit(0); }
|
|
249
|
+
|
|
250
|
+
const apiKey = process.env.FELO_API_KEY?.trim();
|
|
251
|
+
if (!apiKey) {
|
|
252
|
+
console.error('ERROR: FELO_API_KEY not set');
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const apiBase = (process.env.FELO_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
257
|
+
const { query, ids, json, timeoutMs } = args;
|
|
258
|
+
|
|
259
|
+
if (!query && !ids.length) {
|
|
260
|
+
usage();
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let code = 1;
|
|
265
|
+
try {
|
|
266
|
+
if (query) {
|
|
267
|
+
if (args.user) {
|
|
268
|
+
// search users
|
|
269
|
+
const spinnerId = startSpinner(`Searching users: ${query}`);
|
|
270
|
+
try {
|
|
271
|
+
const body = { query };
|
|
272
|
+
if (args.cursor) body.cursor = args.cursor;
|
|
273
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/search', body, timeoutMs);
|
|
274
|
+
if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
|
|
275
|
+
else {
|
|
276
|
+
const data = payload?.data || {};
|
|
277
|
+
const users = data.users || [];
|
|
278
|
+
if (!users.length) { process.stderr.write('No users found.\n'); }
|
|
279
|
+
else {
|
|
280
|
+
process.stdout.write(`Found ${data.total || users.length} user(s)\n\n`);
|
|
281
|
+
for (const u of users) process.stdout.write(formatUser(u));
|
|
282
|
+
if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
|
|
283
|
+
code = 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} finally { stopSpinner(spinnerId); }
|
|
287
|
+
} else {
|
|
288
|
+
// search tweets (default)
|
|
289
|
+
const spinnerId = startSpinner(`Searching tweets: ${query}`);
|
|
290
|
+
try {
|
|
291
|
+
const body = { query };
|
|
292
|
+
if (args.queryType) body.query_type = args.queryType;
|
|
293
|
+
if (args.sinceTime) body.since_time = args.sinceTime;
|
|
294
|
+
if (args.untilTime) body.until_time = args.untilTime;
|
|
295
|
+
if (args.limit) body.limit = args.limit;
|
|
296
|
+
if (args.cursor) body.cursor = args.cursor;
|
|
297
|
+
const payload = await postApi(apiBase, apiKey, '/x/tweet/search', body, timeoutMs);
|
|
298
|
+
if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
|
|
299
|
+
else {
|
|
300
|
+
const data = payload?.data || {};
|
|
301
|
+
const tweets = data.tweets || [];
|
|
302
|
+
if (!tweets.length) { process.stderr.write('No tweets found.\n'); }
|
|
303
|
+
else {
|
|
304
|
+
process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
|
|
305
|
+
for (const t of tweets) process.stdout.write(formatTweet(t));
|
|
306
|
+
if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
|
|
307
|
+
code = 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} finally { stopSpinner(spinnerId); }
|
|
311
|
+
}
|
|
312
|
+
} else if (ids.length) {
|
|
313
|
+
if (args.user) {
|
|
314
|
+
if (args.tweets) {
|
|
315
|
+
// get user tweets
|
|
316
|
+
const label = ids[0];
|
|
317
|
+
const spinnerId = startSpinner(`Fetching tweets from ${label}`);
|
|
318
|
+
try {
|
|
319
|
+
const body = { username: ids[0] };
|
|
320
|
+
if (args.limit) body.limit = args.limit;
|
|
321
|
+
if (args.cursor) body.cursor = args.cursor;
|
|
322
|
+
if (args.includeReplies) body.include_replies = true;
|
|
323
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/tweets', body, timeoutMs);
|
|
324
|
+
if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
|
|
325
|
+
else {
|
|
326
|
+
const data = payload?.data || {};
|
|
327
|
+
const tweets = data.tweets || [];
|
|
328
|
+
if (!tweets.length) { process.stderr.write('No tweets found.\n'); }
|
|
329
|
+
else {
|
|
330
|
+
process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
|
|
331
|
+
for (const t of tweets) process.stdout.write(formatTweet(t));
|
|
332
|
+
if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
|
|
333
|
+
code = 0;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} finally { stopSpinner(spinnerId); }
|
|
337
|
+
} else {
|
|
338
|
+
// get user info
|
|
339
|
+
const spinnerId = startSpinner('Fetching user info');
|
|
340
|
+
try {
|
|
341
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/info', { usernames: ids }, timeoutMs);
|
|
342
|
+
if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
|
|
343
|
+
else {
|
|
344
|
+
const users = payload?.data?.users || [];
|
|
345
|
+
if (!users.length) { process.stderr.write('No users found.\n'); }
|
|
346
|
+
else { for (const u of users) process.stdout.write(formatUser(u)); code = 0; }
|
|
347
|
+
}
|
|
348
|
+
} finally { stopSpinner(spinnerId); }
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
// get tweet replies (default for --id)
|
|
352
|
+
const spinnerId = startSpinner(`Fetching replies for ${ids.length} tweet(s)`);
|
|
353
|
+
try {
|
|
354
|
+
const body = { tweet_ids: ids };
|
|
355
|
+
if (args.cursor) body.cursor = args.cursor;
|
|
356
|
+
if (args.sinceTime) body.since_time = args.sinceTime;
|
|
357
|
+
if (args.untilTime) body.until_time = args.untilTime;
|
|
358
|
+
const payload = await postApi(apiBase, apiKey, '/x/tweet/replies', body, timeoutMs);
|
|
359
|
+
if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
|
|
360
|
+
else {
|
|
361
|
+
const results = payload?.data?.results || [];
|
|
362
|
+
if (!results.length) { process.stderr.write('No replies found.\n'); }
|
|
363
|
+
else {
|
|
364
|
+
for (const r of results) {
|
|
365
|
+
process.stdout.write(`## Replies to tweet \`${r.tweet_id}\` (${r.total || 0} total)\n\n`);
|
|
366
|
+
for (const t of (r.replies || [])) process.stdout.write(formatTweet(t));
|
|
367
|
+
if (r.has_next && r.next_cursor) process.stderr.write(`More replies for ${r.tweet_id}. Use --cursor "${r.next_cursor}"\n`);
|
|
368
|
+
}
|
|
369
|
+
code = 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} finally { stopSpinner(spinnerId); }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
process.stderr.write(`Error: ${err?.message || err}\n`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
process.exit(code);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
main().catch((err) => {
|
|
383
|
+
process.stderr.write(`Fatal: ${err?.message || err}\n`);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
});
|
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
# Felo YouTube Subtitling Skill
|
|
2
|
-
|
|
3
|
-
Fetch YouTube video subtitles/captions using the [Felo YouTube Subtitling API](https://openapi.felo.ai/docs/api-reference/v2/youtube-subtitling.html).
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- Get subtitles by **YouTube video URL** or **video ID** (e.g. `https://youtube.com/watch?v=ID` or `dQw4w9WgXcQ`)
|
|
8
|
-
- Optional **language** (e.g. `en`, `zh-CN`)
|
|
9
|
-
- Optional **timestamps** (`--with-time`) for each segment
|
|
10
|
-
- Same `FELO_API_KEY` as other Felo skills
|
|
11
|
-
|
|
12
|
-
## Quick Start
|
|
13
|
-
|
|
14
|
-
### 1) Configure API key
|
|
15
|
-
|
|
16
|
-
At [felo.ai](https://felo.ai) -> Settings -> API Keys, create a key, then:
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
# Linux/macOS
|
|
20
|
-
export FELO_API_KEY="your-api-key-here"
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
```powershell
|
|
24
|
-
# Windows PowerShell
|
|
25
|
-
$env:FELO_API_KEY="your-api-key-here"
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### 2) Run
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
# From repo: script (URL or video ID)
|
|
32
|
-
node felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs --video-code "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
33
|
-
|
|
34
|
-
# After npm install -g felo-ai: CLI
|
|
35
|
-
felo youtube-subtitling -v "https://youtu.be/dQw4w9WgXcQ"
|
|
36
|
-
felo youtube-subtitling -v "dQw4w9WgXcQ"
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## All parameters
|
|
40
|
-
|
|
41
|
-
| Parameter | Option | Example |
|
|
42
|
-
|-----------|--------|---------|
|
|
43
|
-
| Video URL or ID (required) | `-v`, `--video-code` | `--video-code "https://youtube.com/watch?v=ID"` or `"dQw4w9WgXcQ"` |
|
|
44
|
-
| Language | `-l`, `--language` | `--language zh-CN` |
|
|
45
|
-
| Include timestamps | `--with-time` | `--with-time` |
|
|
46
|
-
| Full JSON response | `-j`, `--json` | `-j` |
|
|
47
|
-
|
|
48
|
-
**Examples**
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
felo youtube-subtitling -v "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
52
|
-
felo youtube-subtitling -v "dQw4w9WgXcQ" --language en --with-time -j
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## When to use (Agent)
|
|
56
|
-
|
|
57
|
-
Trigger keywords: YouTube subtitles, get captions, video transcript, extract subtitles, `/felo-youtube-subtitling`.
|
|
58
|
-
|
|
59
|
-
See [SKILL.md](SKILL.md) for full agent instructions and API parameters.
|
|
1
|
+
# Felo YouTube Subtitling Skill
|
|
2
|
+
|
|
3
|
+
Fetch YouTube video subtitles/captions using the [Felo YouTube Subtitling API](https://openapi.felo.ai/docs/api-reference/v2/youtube-subtitling.html).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Get subtitles by **YouTube video URL** or **video ID** (e.g. `https://youtube.com/watch?v=ID` or `dQw4w9WgXcQ`)
|
|
8
|
+
- Optional **language** (e.g. `en`, `zh-CN`)
|
|
9
|
+
- Optional **timestamps** (`--with-time`) for each segment
|
|
10
|
+
- Same `FELO_API_KEY` as other Felo skills
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
### 1) Configure API key
|
|
15
|
+
|
|
16
|
+
At [felo.ai](https://felo.ai) -> Settings -> API Keys, create a key, then:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Linux/macOS
|
|
20
|
+
export FELO_API_KEY="your-api-key-here"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
# Windows PowerShell
|
|
25
|
+
$env:FELO_API_KEY="your-api-key-here"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2) Run
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# From repo: script (URL or video ID)
|
|
32
|
+
node felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs --video-code "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
33
|
+
|
|
34
|
+
# After npm install -g felo-ai: CLI
|
|
35
|
+
felo youtube-subtitling -v "https://youtu.be/dQw4w9WgXcQ"
|
|
36
|
+
felo youtube-subtitling -v "dQw4w9WgXcQ"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## All parameters
|
|
40
|
+
|
|
41
|
+
| Parameter | Option | Example |
|
|
42
|
+
|-----------|--------|---------|
|
|
43
|
+
| Video URL or ID (required) | `-v`, `--video-code` | `--video-code "https://youtube.com/watch?v=ID"` or `"dQw4w9WgXcQ"` |
|
|
44
|
+
| Language | `-l`, `--language` | `--language zh-CN` |
|
|
45
|
+
| Include timestamps | `--with-time` | `--with-time` |
|
|
46
|
+
| Full JSON response | `-j`, `--json` | `-j` |
|
|
47
|
+
|
|
48
|
+
**Examples**
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
felo youtube-subtitling -v "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
52
|
+
felo youtube-subtitling -v "dQw4w9WgXcQ" --language en --with-time -j
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## When to use (Agent)
|
|
56
|
+
|
|
57
|
+
Trigger keywords: YouTube subtitles, get captions, video transcript, extract subtitles, `/felo-youtube-subtitling`.
|
|
58
|
+
|
|
59
|
+
See [SKILL.md](SKILL.md) for full agent instructions and API parameters.
|