felo-ai 0.2.7 → 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 -30
- package/CONTRIBUTING.md +346 -346
- package/README.en.md +129 -129
- package/README.md +435 -414
- 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 -78
- package/felo-web-fetch/SKILL.md +204 -200
- package/felo-web-fetch/scripts/run_web_fetch.mjs +316 -232
- 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/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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: felo-x-search
|
|
3
|
+
description: "Search X (Twitter) data using Felo X Search API. Use when users ask about X/Twitter users, tweets, trending topics on X, tweet replies, or when explicit commands like /felo-x-search are used. Supports user lookup, user search, user tweets, tweet search, and tweet replies."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Felo X Search Skill
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Trigger this skill when the user wants to:
|
|
11
|
+
|
|
12
|
+
- Look up X (Twitter) user profiles by username
|
|
13
|
+
- Search for X users by keyword
|
|
14
|
+
- Get tweets from a specific X user
|
|
15
|
+
- Search tweets by keyword or advanced query
|
|
16
|
+
- Get replies to specific tweets
|
|
17
|
+
|
|
18
|
+
Trigger keywords (examples):
|
|
19
|
+
|
|
20
|
+
- English: twitter, tweet, X user, X search, tweets from, replies to, trending on X
|
|
21
|
+
- 简体中文: 推特, 推文, X用户, X搜索, 推文回复
|
|
22
|
+
- 日本語: ツイッター, ツイート, Xユーザー, X検索
|
|
23
|
+
|
|
24
|
+
Explicit commands: `/felo-x-search`, "search X", "search twitter"
|
|
25
|
+
|
|
26
|
+
Do NOT use for:
|
|
27
|
+
|
|
28
|
+
- General web search (use `felo-search`)
|
|
29
|
+
- Webpage extraction (use `felo-web-extract`)
|
|
30
|
+
- Generating slides (use `felo-slides`)
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
### 1. Get API key
|
|
35
|
+
|
|
36
|
+
1. Visit [felo.ai](https://felo.ai)
|
|
37
|
+
2. Open Settings -> API Keys
|
|
38
|
+
3. Create and copy your API key
|
|
39
|
+
|
|
40
|
+
### 2. Configure environment variable
|
|
41
|
+
|
|
42
|
+
Linux/macOS:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export FELO_API_KEY="your-api-key-here"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Windows PowerShell:
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
$env:FELO_API_KEY="your-api-key-here"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How to Execute
|
|
55
|
+
|
|
56
|
+
### Option A: Use the bundled script or packaged CLI
|
|
57
|
+
|
|
58
|
+
**Packaged CLI** (after `npm install -g felo-ai`):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
felo x [query] [options]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Script** (from repo):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node felo-x-search/scripts/run_x_search.mjs [query] [options]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Usage Pattern
|
|
71
|
+
|
|
72
|
+
The `x` command uses parameter combinations to infer intent — no subcommands needed.
|
|
73
|
+
|
|
74
|
+
**With query (search mode):**
|
|
75
|
+
|
|
76
|
+
| Usage | Behavior |
|
|
77
|
+
|-------|----------|
|
|
78
|
+
| `felo x "AI news"` | Search tweets (default) |
|
|
79
|
+
| `felo x -q "AI news"` | Same as above |
|
|
80
|
+
| `felo x "OpenAI" --user` | Search users |
|
|
81
|
+
|
|
82
|
+
**With --id (lookup mode):**
|
|
83
|
+
|
|
84
|
+
| Usage | Behavior |
|
|
85
|
+
|-------|----------|
|
|
86
|
+
| `felo x --id "1234567890"` | Get tweet replies (default) |
|
|
87
|
+
| `felo x --id "elonmusk" --user` | Get user info |
|
|
88
|
+
| `felo x --id "elonmusk" --user --tweets` | Get user tweets |
|
|
89
|
+
|
|
90
|
+
### Options
|
|
91
|
+
|
|
92
|
+
| Option | Description |
|
|
93
|
+
|--------|-------------|
|
|
94
|
+
| `[query]` | Positional arg, search keyword (equivalent to -q) |
|
|
95
|
+
| `-q, --query <text>` | Search keyword |
|
|
96
|
+
| `--id <values>` | Tweet IDs or usernames (comma-separated) |
|
|
97
|
+
| `--user` | Switch to user mode |
|
|
98
|
+
| `--tweets` | Get user tweets (with --id --user) |
|
|
99
|
+
| `-l, --limit <n>` | Number of results |
|
|
100
|
+
| `--cursor <str>` | Pagination cursor |
|
|
101
|
+
| `--include-replies` | Include replies (with --tweets) |
|
|
102
|
+
| `--query-type <type>` | Query type filter (tweet search) |
|
|
103
|
+
| `--since-time <val>` | Start time filter |
|
|
104
|
+
| `--until-time <val>` | End time filter |
|
|
105
|
+
| `-j, --json` | Output raw JSON |
|
|
106
|
+
| `-t, --timeout <seconds>` | Timeout in seconds (default: 30) |
|
|
107
|
+
|
|
108
|
+
### Option B: Call API with curl
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Search tweets
|
|
112
|
+
curl -X POST "https://openapi.felo.ai/x/tweet/search" \
|
|
113
|
+
-H "Authorization: Bearer $FELO_API_KEY" \
|
|
114
|
+
-H "Content-Type: application/json" \
|
|
115
|
+
-d '{"query": "AI news", "limit": 20}'
|
|
116
|
+
|
|
117
|
+
# Search users
|
|
118
|
+
curl -X POST "https://openapi.felo.ai/x/user/search" \
|
|
119
|
+
-H "Authorization: Bearer $FELO_API_KEY" \
|
|
120
|
+
-H "Content-Type: application/json" \
|
|
121
|
+
-d '{"query": "artificial intelligence"}'
|
|
122
|
+
|
|
123
|
+
# Get user info
|
|
124
|
+
curl -X POST "https://openapi.felo.ai/x/user/info" \
|
|
125
|
+
-H "Authorization: Bearer $FELO_API_KEY" \
|
|
126
|
+
-H "Content-Type: application/json" \
|
|
127
|
+
-d '{"usernames": ["elonmusk", "OpenAI"]}'
|
|
128
|
+
|
|
129
|
+
# Get user tweets
|
|
130
|
+
curl -X POST "https://openapi.felo.ai/x/user/tweets" \
|
|
131
|
+
-H "Authorization: Bearer $FELO_API_KEY" \
|
|
132
|
+
-H "Content-Type: application/json" \
|
|
133
|
+
-d '{"username": "elonmusk", "limit": 20}'
|
|
134
|
+
|
|
135
|
+
# Get tweet replies
|
|
136
|
+
curl -X POST "https://openapi.felo.ai/x/tweet/replies" \
|
|
137
|
+
-H "Authorization: Bearer $FELO_API_KEY" \
|
|
138
|
+
-H "Content-Type: application/json" \
|
|
139
|
+
-d '{"tweet_ids": ["1234567890"]}'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Output Format
|
|
143
|
+
|
|
144
|
+
### User Info (default, non-JSON)
|
|
145
|
+
|
|
146
|
+
```markdown
|
|
147
|
+
## @elonmusk (Elon Musk 🔵)
|
|
148
|
+
- User ID: `44196397`
|
|
149
|
+
- Followers: 100.0M
|
|
150
|
+
- Following: 500
|
|
151
|
+
- Total Tweets: 30.0K
|
|
152
|
+
- Bio: ...
|
|
153
|
+
- Location: Mars
|
|
154
|
+
- Account Created: 2009-06-02T00:00:00Z
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Tweet (default, non-JSON)
|
|
160
|
+
|
|
161
|
+
```markdown
|
|
162
|
+
### @elonmusk(Elon Musk ✓)
|
|
163
|
+
- Posted: 2026-03-09T12:00:00Z | Tweet ID: `1234567890`
|
|
164
|
+
|
|
165
|
+
Tweet content here...
|
|
166
|
+
|
|
167
|
+
Engagement: 5.0K likes | 1.0K retweets | 200 replies
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
### Common Error Codes
|
|
175
|
+
|
|
176
|
+
- `INVALID_API_KEY` — API Key is invalid or revoked
|
|
177
|
+
- `X_SEARCH_FAILED` — X search request failed (check parameters or downstream error)
|
|
178
|
+
|
|
179
|
+
### Missing API Key
|
|
180
|
+
|
|
181
|
+
If `FELO_API_KEY` is not set, display setup instructions and stop.
|
|
182
|
+
|
|
183
|
+
## API Reference (summary)
|
|
184
|
+
|
|
185
|
+
- **Base URL**: `https://openapi.felo.ai`. Override with `FELO_API_BASE` env if needed.
|
|
186
|
+
- **Auth**: `Authorization: Bearer YOUR_API_KEY`
|
|
187
|
+
- **Endpoints**:
|
|
188
|
+
- `POST /x/user/info` — Batch get user profiles
|
|
189
|
+
- `POST /x/user/search` — Search users
|
|
190
|
+
- `POST /x/user/tweets` — Get user tweets
|
|
191
|
+
- `POST /x/tweet/search` — Search tweets
|
|
192
|
+
- `POST /x/tweet/replies` — Get tweet replies
|
|
193
|
+
|
|
194
|
+
## Important Notes
|
|
195
|
+
|
|
196
|
+
- Always check `FELO_API_KEY` before calling; if missing, return setup instructions.
|
|
197
|
+
- Format output as readable Markdown by default; use `--json` for raw API response.
|
|
198
|
+
- Use pagination cursors (`next_cursor`) for fetching more results.
|
|
199
|
+
- For tweet search, advanced query syntax is supported (same as X advanced search).
|
|
200
|
+
|
|
201
|
+
## References
|
|
202
|
+
|
|
203
|
+
- [Felo X Search API](https://openapi.felo.ai/docs/api-reference/v2/x-search.html)
|
|
204
|
+
- [Felo Open Platform](https://openapi.felo.ai/docs/)
|
|
@@ -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
|
+
});
|