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
|
@@ -1,148 +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
|
|
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
|
|
90
|
-
* @param {Object} opts - { url, format, targetSelector, waitForSelector, readability, timeoutMs, json }
|
|
91
|
-
*/
|
|
92
|
-
export async function
|
|
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
|
|
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
|
|
135
|
-
);
|
|
136
|
-
return 1;
|
|
137
|
-
}
|
|
138
|
-
console.log(out);
|
|
139
|
-
return 0;
|
|
140
|
-
} catch (err) {
|
|
141
|
-
process.stderr.write(
|
|
142
|
-
`Web
|
|
143
|
-
);
|
|
144
|
-
return 1;
|
|
145
|
-
} finally {
|
|
146
|
-
stopSpinner(spinnerId);
|
|
147
|
-
}
|
|
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
|
+
}
|
package/src/xSearch.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { getApiKey, fetchWithTimeoutAndRetry, 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 = 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
|
+
async function postApi(apiBase, apiKey, path, body, timeoutMs) {
|
|
41
|
+
const res = await fetchWithTimeoutAndRetry(
|
|
42
|
+
`${apiBase}/v2${path}`,
|
|
43
|
+
{
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
Authorization: `Bearer ${apiKey}`,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
},
|
|
52
|
+
timeoutMs,
|
|
53
|
+
);
|
|
54
|
+
let data = {};
|
|
55
|
+
try {
|
|
56
|
+
data = await res.json();
|
|
57
|
+
} catch {
|
|
58
|
+
data = {};
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
|
|
61
|
+
if (data.status === 'error') throw new Error(getMessage(data));
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Formatting helpers ──
|
|
66
|
+
|
|
67
|
+
function formatNumber(num) {
|
|
68
|
+
if (num == null) return '0';
|
|
69
|
+
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
|
70
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
71
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
72
|
+
return String(num);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatUser(u, headerLevel = 2) {
|
|
76
|
+
if (!u) return '';
|
|
77
|
+
const h = '#'.repeat(Math.min(6, headerLevel));
|
|
78
|
+
|
|
79
|
+
let badge = '';
|
|
80
|
+
if (u.blue_verified) badge = ' 🔵';
|
|
81
|
+
else if (u.verified) badge = ' ✓';
|
|
82
|
+
|
|
83
|
+
let out = `${h} @${u.username} (${u.display_name || u.username}${badge})\n`;
|
|
84
|
+
out += `- User ID: \`${u.user_id}\`\n`;
|
|
85
|
+
|
|
86
|
+
if (u.verified_type) out += `- Verification Type: ${u.verified_type}\n`;
|
|
87
|
+
|
|
88
|
+
const status = [];
|
|
89
|
+
if (u.protected) status.push('Protected (Private)');
|
|
90
|
+
if (u.is_automated) status.push('Automated');
|
|
91
|
+
if (status.length) out += `- Account Status: ${status.join(' | ')}\n`;
|
|
92
|
+
|
|
93
|
+
out += `- Followers: ${formatNumber(u.followers_count)}\n`;
|
|
94
|
+
out += `- Following: ${formatNumber(u.following_count)}\n`;
|
|
95
|
+
out += `- Total Tweets: ${formatNumber(u.tweet_count)}\n`;
|
|
96
|
+
|
|
97
|
+
if (u.favorites_count > 0) out += `- Likes Given: ${formatNumber(u.favorites_count)}\n`;
|
|
98
|
+
if (u.media_count > 0) out += `- Media Count: ${formatNumber(u.media_count)}\n`;
|
|
99
|
+
if (u.description) out += `- Bio: ${u.description}\n`;
|
|
100
|
+
if (u.location) out += `- Location: ${u.location}\n`;
|
|
101
|
+
if (u.url) out += `- Website: ${u.url}\n`;
|
|
102
|
+
if (u.can_dm) out += `- Direct Messages: Open\n`;
|
|
103
|
+
if (u.profile_image_url) out += `- Profile Image: ${u.profile_image_url}\n`;
|
|
104
|
+
if (u.cover_image_url) out += `- Cover Image: ${u.cover_image_url}\n`;
|
|
105
|
+
if (u.pinned_tweet_ids?.length) out += `- Pinned Tweets: ${u.pinned_tweet_ids.length} tweet(s)\n`;
|
|
106
|
+
if (u.created_at) out += `- Account Created: ${u.created_at}\n`;
|
|
107
|
+
|
|
108
|
+
out += '\n---\n\n';
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatTweet(t, indent = '', headerLevel = 3) {
|
|
113
|
+
if (!t) return '';
|
|
114
|
+
const h = '#'.repeat(Math.min(6, headerLevel));
|
|
115
|
+
const author = t.author || {};
|
|
116
|
+
const verified = author.verified ? ' ✓' : '';
|
|
117
|
+
|
|
118
|
+
let out = `${indent}${h} @${author.username || 'unknown'}(${author.display_name || author.username || 'unknown'}${verified})\n`;
|
|
119
|
+
|
|
120
|
+
const meta = [`Posted: ${t.created_at || 'unknown'}`, `Tweet ID: \`${t.id}\``];
|
|
121
|
+
if (t.conversation_id) meta.push(`Conversation: \`${t.conversation_id}\``);
|
|
122
|
+
if (t.is_reply && t.in_reply_to_username) meta.push(`Reply to @${t.in_reply_to_username}`);
|
|
123
|
+
out += `${indent}- ${meta.join(' | ')}\n\n`;
|
|
124
|
+
|
|
125
|
+
const content = t.content || '';
|
|
126
|
+
for (const line of content.split('\n')) {
|
|
127
|
+
out += `${indent}${line}\n`;
|
|
128
|
+
}
|
|
129
|
+
out += '\n';
|
|
130
|
+
|
|
131
|
+
const metrics = t.metrics || {};
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (metrics.favorite_count) parts.push(`${formatNumber(metrics.favorite_count)} likes`);
|
|
134
|
+
if (metrics.retweet_count) parts.push(`${formatNumber(metrics.retweet_count)} retweets`);
|
|
135
|
+
if (metrics.reply_count) parts.push(`${formatNumber(metrics.reply_count)} replies`);
|
|
136
|
+
if (parts.length) out += `${indent}Engagement: ${parts.join(' | ')}\n`;
|
|
137
|
+
|
|
138
|
+
if (Array.isArray(t.media_urls) && t.media_urls.length) {
|
|
139
|
+
out += `${indent}Media (${t.media_urls.length}):\n`;
|
|
140
|
+
for (const media of t.media_urls) {
|
|
141
|
+
const type = media.type || 'photo';
|
|
142
|
+
if (type === 'video') {
|
|
143
|
+
out += `${indent} • [video] ${media.url || ''}\n`;
|
|
144
|
+
} else {
|
|
145
|
+
out += `${indent} • [photo] ${media.thumbnail || ''}\n`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
out += `\n${indent}---\n\n`;
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Subcommands ──
|
|
155
|
+
|
|
156
|
+
async function userInfo(usernames, opts) {
|
|
157
|
+
const apiKey = await getApiKey();
|
|
158
|
+
if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
|
|
159
|
+
if (!usernames || !usernames.length) {
|
|
160
|
+
process.stderr.write('ERROR: --usernames is required.\n');
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const apiBase = await getApiBase();
|
|
165
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
166
|
+
const spinnerId = startSpinner('Fetching user info');
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/info', { usernames }, timeoutMs);
|
|
170
|
+
if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
|
|
171
|
+
|
|
172
|
+
const users = payload?.data?.users || [];
|
|
173
|
+
if (!users.length) {
|
|
174
|
+
process.stderr.write('No users found.\n');
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
for (const u of users) {
|
|
178
|
+
process.stdout.write(formatUser(u));
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
process.stderr.write(`Failed to get user info: ${err?.message || err}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
} finally {
|
|
185
|
+
stopSpinner(spinnerId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function userSearch(query, opts) {
|
|
190
|
+
const apiKey = await getApiKey();
|
|
191
|
+
if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
|
|
192
|
+
if (!query) {
|
|
193
|
+
process.stderr.write('ERROR: --query is required.\n');
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const apiBase = await getApiBase();
|
|
198
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
199
|
+
const spinnerId = startSpinner(`Searching users: ${query}`);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const body = { query };
|
|
203
|
+
if (opts.cursor) body.cursor = opts.cursor;
|
|
204
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/search', body, timeoutMs);
|
|
205
|
+
if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
|
|
206
|
+
|
|
207
|
+
const data = payload?.data || {};
|
|
208
|
+
const users = data.users || [];
|
|
209
|
+
if (!users.length) {
|
|
210
|
+
process.stderr.write('No users found.\n');
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write(`Found ${data.total || users.length} user(s)\n\n`);
|
|
214
|
+
for (const u of users) {
|
|
215
|
+
process.stdout.write(formatUser(u));
|
|
216
|
+
}
|
|
217
|
+
if (data.has_next && data.next_cursor) {
|
|
218
|
+
process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
process.stderr.write(`Failed to search users: ${err?.message || err}\n`);
|
|
223
|
+
return 1;
|
|
224
|
+
} finally {
|
|
225
|
+
stopSpinner(spinnerId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function userTweets(opts) {
|
|
230
|
+
const apiKey = await getApiKey();
|
|
231
|
+
if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
|
|
232
|
+
if (!opts.username && !opts.xUserId) {
|
|
233
|
+
process.stderr.write('ERROR: --username or --x-user-id is required.\n');
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const apiBase = await getApiBase();
|
|
238
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
239
|
+
const label = opts.username || opts.xUserId;
|
|
240
|
+
const spinnerId = startSpinner(`Fetching tweets from ${label}`);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const body = {};
|
|
244
|
+
if (opts.username) body.username = opts.username;
|
|
245
|
+
if (opts.xUserId) body.x_user_id = opts.xUserId;
|
|
246
|
+
if (opts.limit) body.limit = opts.limit;
|
|
247
|
+
if (opts.cursor) body.cursor = opts.cursor;
|
|
248
|
+
if (opts.includeReplies) body.include_replies = true;
|
|
249
|
+
|
|
250
|
+
const payload = await postApi(apiBase, apiKey, '/x/user/tweets', body, timeoutMs);
|
|
251
|
+
if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
|
|
252
|
+
|
|
253
|
+
const data = payload?.data || {};
|
|
254
|
+
const tweets = data.tweets || [];
|
|
255
|
+
if (!tweets.length) {
|
|
256
|
+
process.stderr.write('No tweets found.\n');
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
|
|
260
|
+
for (const t of tweets) {
|
|
261
|
+
process.stdout.write(formatTweet(t));
|
|
262
|
+
}
|
|
263
|
+
if (data.has_next && data.next_cursor) {
|
|
264
|
+
process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
|
|
265
|
+
}
|
|
266
|
+
return 0;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
process.stderr.write(`Failed to get user tweets: ${err?.message || err}\n`);
|
|
269
|
+
return 1;
|
|
270
|
+
} finally {
|
|
271
|
+
stopSpinner(spinnerId);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function tweetSearch(query, opts) {
|
|
276
|
+
const apiKey = await getApiKey();
|
|
277
|
+
if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
|
|
278
|
+
if (!query) {
|
|
279
|
+
process.stderr.write('ERROR: --query is required.\n');
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const apiBase = await getApiBase();
|
|
284
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
285
|
+
const spinnerId = startSpinner(`Searching tweets: ${query}`);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const body = { query };
|
|
289
|
+
if (opts.queryType) body.query_type = opts.queryType;
|
|
290
|
+
if (opts.sinceTime) body.since_time = opts.sinceTime;
|
|
291
|
+
if (opts.untilTime) body.until_time = opts.untilTime;
|
|
292
|
+
if (opts.limit) body.limit = opts.limit;
|
|
293
|
+
if (opts.cursor) body.cursor = opts.cursor;
|
|
294
|
+
|
|
295
|
+
const payload = await postApi(apiBase, apiKey, '/x/tweet/search', body, timeoutMs);
|
|
296
|
+
if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
|
|
297
|
+
|
|
298
|
+
const data = payload?.data || {};
|
|
299
|
+
const tweets = data.tweets || [];
|
|
300
|
+
if (!tweets.length) {
|
|
301
|
+
process.stderr.write('No tweets found.\n');
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
|
|
305
|
+
for (const t of tweets) {
|
|
306
|
+
process.stdout.write(formatTweet(t));
|
|
307
|
+
}
|
|
308
|
+
if (data.has_next && data.next_cursor) {
|
|
309
|
+
process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
|
|
310
|
+
}
|
|
311
|
+
return 0;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
process.stderr.write(`Failed to search tweets: ${err?.message || err}\n`);
|
|
314
|
+
return 1;
|
|
315
|
+
} finally {
|
|
316
|
+
stopSpinner(spinnerId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function tweetReplies(tweetIds, opts) {
|
|
321
|
+
const apiKey = await getApiKey();
|
|
322
|
+
if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
|
|
323
|
+
if (!tweetIds || !tweetIds.length) {
|
|
324
|
+
process.stderr.write('ERROR: --tweet-ids is required.\n');
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const apiBase = await getApiBase();
|
|
329
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
330
|
+
const spinnerId = startSpinner(`Fetching replies for ${tweetIds.length} tweet(s)`);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const body = { tweet_ids: tweetIds };
|
|
334
|
+
if (opts.cursor) body.cursor = opts.cursor;
|
|
335
|
+
if (opts.sinceTime) body.since_time = opts.sinceTime;
|
|
336
|
+
if (opts.untilTime) body.until_time = opts.untilTime;
|
|
337
|
+
|
|
338
|
+
const payload = await postApi(apiBase, apiKey, '/x/tweet/replies', body, timeoutMs);
|
|
339
|
+
if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
|
|
340
|
+
|
|
341
|
+
const results = payload?.data?.results || [];
|
|
342
|
+
if (!results.length) {
|
|
343
|
+
process.stderr.write('No replies found.\n');
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
for (const r of results) {
|
|
347
|
+
process.stdout.write(`## Replies to tweet \`${r.tweet_id}\` (${r.total || 0} total)\n\n`);
|
|
348
|
+
const replies = r.replies || [];
|
|
349
|
+
for (const t of replies) {
|
|
350
|
+
process.stdout.write(formatTweet(t));
|
|
351
|
+
}
|
|
352
|
+
if (r.has_next && r.next_cursor) {
|
|
353
|
+
process.stderr.write(`More replies available for ${r.tweet_id}. Use --cursor "${r.next_cursor}" to fetch next page.\n`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return 0;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
process.stderr.write(`Failed to get tweet replies: ${err?.message || err}\n`);
|
|
359
|
+
return 1;
|
|
360
|
+
} finally {
|
|
361
|
+
stopSpinner(spinnerId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export { userInfo, userSearch, userTweets, tweetSearch, tweetReplies };
|
|
366
|
+
export { formatNumber, formatUser, formatTweet };
|