vipcare 0.1.0

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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # VIPCare
2
+
3
+ Auto-build VIP person profiles from Twitter/LinkedIn public data.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g vipcare
9
+ ```
10
+
11
+ Or run directly:
12
+
13
+ ```bash
14
+ npx vipcare --help
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # Add a person by name
21
+ vip add "Sam Altman" --company "OpenAI"
22
+
23
+ # Add from Twitter URL
24
+ vip add https://twitter.com/sama
25
+
26
+ # List all profiles
27
+ vip list
28
+
29
+ # Show a profile
30
+ vip show sam-altman
31
+
32
+ # Search across profiles
33
+ vip search "AI"
34
+
35
+ # Edit a profile
36
+ vip edit sam-altman --note "Met at conference"
37
+
38
+ # Delete a profile
39
+ vip rm sam-altman -y
40
+
41
+ # Refresh a profile
42
+ vip update sam-altman
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - **Auto profile building** — Give a name or URL, get a structured profile
48
+ - **Multi-source data** — Twitter (via [bird CLI](https://github.com/nickytonline/bird)), LinkedIn, web search
49
+ - **AI synthesis** — Claude CLI, Anthropic API, or GitHub Copilot CLI
50
+ - **Auto monitoring** — Scheduled profile refresh with change detection (macOS launchd)
51
+ - **Markdown output** — One `.md` file per person
52
+
53
+ ## AI Backend
54
+
55
+ Auto-detected in this order:
56
+
57
+ | Backend | Setup |
58
+ |---------|-------|
59
+ | Anthropic API | Set `ANTHROPIC_API_KEY` env var |
60
+ | Claude CLI | Install [Claude Code](https://claude.ai/code) |
61
+ | GitHub Copilot | Install `gh copilot` |
62
+
63
+ Override: `VIP_AI_BACKEND=anthropic vip add "Name"`
64
+
65
+ ## Claude Code Skill
66
+
67
+ Install the `/vip` slash command for Claude Code:
68
+
69
+ ```bash
70
+ cp skill/vip.md ~/.claude/commands/vip.md
71
+ ```
72
+
73
+ Then use natural language:
74
+
75
+ ```
76
+ /vip add Jensen Huang from NVIDIA
77
+ /vip who works in AI?
78
+ /vip compare Sam Altman and Elon Musk
79
+ /vip add a note to sam: met at dinner
80
+ ```
81
+
82
+ ## Monitoring
83
+
84
+ ```bash
85
+ vip monitor start # Start auto-refresh (every 24h)
86
+ vip monitor stop # Stop
87
+ vip monitor status # Check status
88
+ vip monitor run # Run once now
89
+ vip digest # View recent changes
90
+ ```
91
+
92
+ ## Config
93
+
94
+ ```bash
95
+ vip config # View settings
96
+ ```
97
+
98
+ Settings: `~/.vip-crm/config.json` | Profiles: `~/Projects/vip-crm/profiles/`
package/bin/vip.js ADDED
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import { execSync as exec } from 'child_process';
7
+ import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
8
+ import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles } from '../lib/profile.js';
9
+ import { isUrl, resolveFromName, resolveFromUrl } from '../lib/resolver.js';
10
+ import * as twitter from '../lib/fetchers/twitter.js';
11
+ import { searchPerson } from '../lib/fetchers/search.js';
12
+ import { synthesizeProfile, getBackendName } from '../lib/synthesizer.js';
13
+ import { readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
14
+ import { install, uninstall, status } from '../lib/scheduler.js';
15
+
16
+ // Colors
17
+ const c = {
18
+ cyan: s => `\x1b[36m${s}\x1b[0m`,
19
+ green: s => `\x1b[32m${s}\x1b[0m`,
20
+ yellow: s => `\x1b[33m${s}\x1b[0m`,
21
+ red: s => `\x1b[31m${s}\x1b[0m`,
22
+ dim: s => `\x1b[90m${s}\x1b[0m`,
23
+ bold: s => `\x1b[1m${s}\x1b[0m`,
24
+ };
25
+
26
+ function spinner(msg) {
27
+ const chars = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
28
+ let i = 0;
29
+ const id = setInterval(() => {
30
+ process.stdout.write(`\r${chars[i++ % chars.length]} ${msg}`);
31
+ }, 100);
32
+ return () => { clearInterval(id); process.stdout.write(`\r${' '.repeat(msg.length + 4)}\r`); };
33
+ }
34
+
35
+ function gatherData(person) {
36
+ const rawParts = [];
37
+ const sources = [];
38
+
39
+ if (person.twitterHandle) {
40
+ console.log(c.dim(` Fetching Twitter @${person.twitterHandle}...`));
41
+ const data = twitter.fetchProfile(person.twitterHandle);
42
+ if (data?.rawOutput) {
43
+ rawParts.push(`=== Twitter (@${person.twitterHandle}) ===\n${data.rawOutput}`);
44
+ sources.push(`https://twitter.com/${person.twitterHandle}`);
45
+ } else if (!twitter.isAvailable()) {
46
+ console.log(c.yellow(' (bird CLI not found, skipping Twitter)'));
47
+ }
48
+ }
49
+
50
+ if (person.linkedinUrl) sources.push(person.linkedinUrl);
51
+
52
+ if (person.rawSnippets.length) {
53
+ rawParts.push('=== Web Search Results ===');
54
+ rawParts.push(...person.rawSnippets);
55
+ }
56
+
57
+ if (rawParts.length < 2 && person.name) {
58
+ console.log(c.dim(` Searching the web for ${person.name}...`));
59
+ const results = searchPerson(person.name);
60
+ for (const r of results) {
61
+ rawParts.push(`${r.title}\n${r.body}`);
62
+ if (!sources.includes(r.url)) sources.push(r.url);
63
+ }
64
+ }
65
+
66
+ return [rawParts.join('\n\n'), sources];
67
+ }
68
+
69
+ // Show unread count
70
+ try {
71
+ const count = unreadCount();
72
+ if (count > 0) console.log(c.yellow(`[${count} new change(s) - run 'vip digest' to view]`));
73
+ } catch {}
74
+
75
+ const program = new Command();
76
+ program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.1.0');
77
+
78
+ // --- add ---
79
+ program.command('add')
80
+ .description('Add a new VIP profile')
81
+ .argument('<query>', 'Name or URL')
82
+ .option('-c, --company <company>', 'Company name')
83
+ .option('--dry-run', 'Print without saving')
84
+ .option('--no-ai', 'Skip AI synthesis')
85
+ .option('-f, --force', 'Overwrite existing')
86
+ .action(async (query, opts) => {
87
+ console.log(c.cyan(`Resolving ${query}...`));
88
+
89
+ let person;
90
+ if (isUrl(query)) {
91
+ const stop = spinner('Searching for profile...');
92
+ person = resolveFromUrl(query);
93
+ stop();
94
+ if (person.name) {
95
+ console.log(c.green(` Found: ${person.name}`));
96
+ if (person.twitterHandle) console.log(` Twitter: @${person.twitterHandle}`);
97
+ const stop2 = spinner('Enriching profile data...');
98
+ const enriched = resolveFromName(person.name);
99
+ stop2();
100
+ person.linkedinUrl = person.linkedinUrl || enriched.linkedinUrl;
101
+ const existing = new Set(person.rawSnippets);
102
+ for (const s of enriched.rawSnippets) if (!existing.has(s)) person.rawSnippets.push(s);
103
+ for (const u of enriched.otherUrls) if (!person.otherUrls.includes(u)) person.otherUrls.push(u);
104
+ }
105
+ } else {
106
+ const stop = spinner('Searching for profile...');
107
+ person = resolveFromName(query, opts.company);
108
+ stop();
109
+ }
110
+
111
+ if (!person.name) { console.error(c.red('Could not identify person.')); process.exit(1); }
112
+
113
+ console.log(c.green(` Name: ${person.name}`));
114
+ if (person.twitterHandle) console.log(` Twitter: @${person.twitterHandle}`);
115
+ if (person.linkedinUrl) console.log(` LinkedIn: ${person.linkedinUrl}`);
116
+
117
+ if (!opts.force && profileExists(person.name)) {
118
+ console.log(`Profile for '${person.name}' already exists. Use -f to overwrite.`);
119
+ return;
120
+ }
121
+
122
+ console.log(c.cyan('Gathering data...'));
123
+ const [rawData, sources] = gatherData(person);
124
+ if (!rawData.trim()) { console.error(c.red('No data found.')); process.exit(1); }
125
+
126
+ let profile;
127
+ if (opts.ai === false) {
128
+ profile = `# ${person.name}\n\n## Raw Data\n\n${rawData}`;
129
+ } else {
130
+ const stop = spinner('Synthesizing profile with AI...');
131
+ try {
132
+ profile = await synthesizeProfile(rawData, sources);
133
+ } finally { stop(); }
134
+ }
135
+
136
+ if (opts.dryRun) {
137
+ console.log('\n' + '='.repeat(60));
138
+ console.log(profile);
139
+ } else {
140
+ const filepath = saveProfile(person.name, profile);
141
+ console.log(c.green(`\nProfile saved: ${filepath}`));
142
+ }
143
+ });
144
+
145
+ // --- list ---
146
+ program.command('list')
147
+ .description('List all VIP profiles')
148
+ .action(() => {
149
+ const profiles = listProfiles();
150
+ if (!profiles.length) { console.log(c.dim('No profiles yet. Use "vip add" to create one.')); return; }
151
+
152
+ const cols = process.stdout.columns || 80;
153
+ const nameW = Math.min(30, Math.max(15, cols / 4 | 0));
154
+ const dateW = 12;
155
+ const sumW = Math.max(20, cols - nameW - dateW - 4);
156
+
157
+ console.log(c.bold(c.cyan(`\n${'Name'.padEnd(nameW)} ${'Summary'.padEnd(sumW)} ${'Updated'.padEnd(dateW)}`)));
158
+ console.log('─'.repeat(nameW + sumW + dateW + 2));
159
+ for (const p of profiles) {
160
+ const name = p.name.slice(0, nameW - 1).padEnd(nameW);
161
+ const summary = c.dim(p.summary.slice(0, sumW - 1).padEnd(sumW));
162
+ console.log(`${name} ${summary} ${p.updated}`);
163
+ }
164
+ console.log(`\nTotal: ${profiles.length} profile(s)`);
165
+ });
166
+
167
+ // --- show ---
168
+ program.command('show')
169
+ .description('Show a VIP profile')
170
+ .argument('<name>')
171
+ .action((name) => {
172
+ const content = loadProfile(name);
173
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
174
+
175
+ for (const line of content.split('\n')) {
176
+ if (line.startsWith('# ')) console.log(c.bold(c.cyan(line)));
177
+ else if (line.startsWith('## ')) console.log(c.bold(c.green(line)));
178
+ else if (line.startsWith('> ')) console.log(c.yellow(line));
179
+ else if (line.startsWith('---') || line.startsWith('*Last') || line.startsWith('*Sources')) console.log(c.dim(line));
180
+ else console.log(line);
181
+ }
182
+ });
183
+
184
+ // --- search ---
185
+ program.command('search')
186
+ .description('Search across all profiles')
187
+ .argument('<keyword>')
188
+ .action((keyword) => {
189
+ const results = searchProfiles(keyword);
190
+ if (!results.length) { console.log(c.dim(`No matches for '${keyword}'.`)); return; }
191
+
192
+ console.log(c.green(`Found ${results.length} profile(s) matching '${keyword}':\n`));
193
+ for (const r of results) {
194
+ console.log(c.bold(c.cyan(` ${r.name}`)));
195
+ for (const m of r.matches) console.log(` > ${m}`);
196
+ console.log();
197
+ }
198
+ });
199
+
200
+ // --- open ---
201
+ program.command('open')
202
+ .description('Open a profile in editor')
203
+ .argument('<name>')
204
+ .action((name) => {
205
+ const p = getProfilePath(name);
206
+ if (!fs.existsSync(p)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
207
+ const editor = process.env.EDITOR || 'open';
208
+ exec(`${editor} "${p}"`);
209
+ });
210
+
211
+ // --- update ---
212
+ program.command('update')
213
+ .description('Refresh an existing profile')
214
+ .argument('<name>')
215
+ .option('--no-ai', 'Skip AI synthesis')
216
+ .action(async (name, opts) => {
217
+ const content = loadProfile(name);
218
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
219
+
220
+ const { extractMetadata } = await import('../lib/monitor.js');
221
+ const meta = extractMetadata(content);
222
+ const personName = meta.name || name;
223
+
224
+ console.log(c.cyan(`Refreshing profile for ${personName}...`));
225
+ const stop = spinner('Resolving...');
226
+ const person = resolveFromName(personName);
227
+ stop();
228
+
229
+ if (meta.twitterHandle) person.twitterHandle = person.twitterHandle || meta.twitterHandle;
230
+ if (meta.linkedinUrl) person.linkedinUrl = person.linkedinUrl || meta.linkedinUrl;
231
+
232
+ const [rawData, sources] = gatherData(person);
233
+ if (!rawData.trim()) { console.log(c.yellow('No new data found.')); return; }
234
+
235
+ let profile;
236
+ if (opts.ai === false) {
237
+ profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
238
+ } else {
239
+ const stop2 = spinner('Re-synthesizing profile...');
240
+ try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
241
+ }
242
+
243
+ const filepath = saveProfile(personName, profile);
244
+ console.log(c.green(`Profile updated: ${filepath}`));
245
+ });
246
+
247
+ // --- rm ---
248
+ program.command('rm')
249
+ .description('Delete a VIP profile')
250
+ .argument('<name>')
251
+ .option('-y, --yes', 'Skip confirmation')
252
+ .action((name, opts) => {
253
+ if (!loadProfile(name)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
254
+ if (!opts.yes) { console.log('Use -y to confirm deletion.'); return; }
255
+ deleteProfile(name);
256
+ console.log(c.green(`Profile deleted: ${name}`));
257
+ });
258
+
259
+ // --- edit ---
260
+ program.command('edit')
261
+ .description('Edit profile fields')
262
+ .argument('<name>')
263
+ .option('--title <title>', 'Set job title')
264
+ .option('--company <company>', 'Set company')
265
+ .option('--twitter <handle>', 'Set Twitter handle')
266
+ .option('--linkedin <url>', 'Set LinkedIn URL')
267
+ .option('--note <note>', 'Append a note')
268
+ .action((name, opts) => {
269
+ let content = loadProfile(name);
270
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
271
+
272
+ let modified = false;
273
+
274
+ if (opts.title) { content = content.replace(/(\*\*Title:\*\*) .+/, `$1 ${opts.title}`); modified = true; }
275
+ if (opts.company) { content = content.replace(/(\*\*Company:\*\*) .+/, `$1 ${opts.company}`); modified = true; }
276
+ if (opts.twitter) {
277
+ const handle = opts.twitter.replace(/^@/, '');
278
+ content = content.replace(/(Twitter:) .+/, `$1 https://twitter.com/${handle}`);
279
+ modified = true;
280
+ }
281
+ if (opts.linkedin) { content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`); modified = true; }
282
+ if (opts.note) {
283
+ if (content.includes('## Notes')) {
284
+ content = content.replace('## Notes\n', `## Notes\n- ${opts.note}\n`);
285
+ } else if (content.includes('\n---\n')) {
286
+ content = content.replace('\n---\n', `\n## Notes\n- ${opts.note}\n\n---\n`);
287
+ } else {
288
+ content = content.trimEnd() + `\n\n## Notes\n- ${opts.note}\n`;
289
+ }
290
+ modified = true;
291
+ }
292
+
293
+ if (modified) { saveProfile(name, content); console.log(c.green('Profile updated.')); }
294
+ else console.log(c.yellow('No changes. Use --title, --company, --twitter, --linkedin, or --note.'));
295
+ });
296
+
297
+ // --- digest ---
298
+ program.command('digest')
299
+ .description('Show recent changes')
300
+ .action(() => {
301
+ const entries = readChangelog(30);
302
+ if (!entries.length) { console.log(c.dim('No recent changes.')); return; }
303
+
304
+ console.log(c.bold(c.cyan('Changes in the last 30 days:\n')));
305
+ for (const e of entries.reverse()) {
306
+ console.log(c.green(` [${(e.timestamp || '').slice(0, 10)}] ${e.name}`));
307
+ console.log(` ${e.summary}`);
308
+ console.log();
309
+ }
310
+ });
311
+
312
+ // --- monitor ---
313
+ const mon = program.command('monitor').description('Manage automatic monitoring');
314
+
315
+ mon.command('start').description('Start monitoring').action(() => {
316
+ try { install(); const s = status(); console.log(c.green(`Monitor started (every ${s.intervalHours}h)`)); }
317
+ catch (e) { console.error(c.red(e.message)); process.exit(1); }
318
+ });
319
+
320
+ mon.command('stop').description('Stop monitoring').action(() => { uninstall(); console.log(c.green('Monitor stopped.')); });
321
+
322
+ mon.command('status').description('Show status').action(() => {
323
+ const s = status();
324
+ console.log(`Status: ${s.running ? c.green('running') : c.red('stopped')}`);
325
+ console.log(`Interval: every ${s.intervalHours}h`);
326
+ console.log(`Installed: ${s.installed}`);
327
+ if (s.installed) console.log(`Plist: ${s.plistPath}`);
328
+ });
329
+
330
+ mon.command('run').description('Run monitoring now').option('-v, --verbose', 'Verbose').action(async (opts) => {
331
+ console.log(c.cyan('Running monitor...'));
332
+ const changes = await runMonitor(null, opts.verbose);
333
+ if (changes.length) {
334
+ console.log(c.green(`\n${changes.length} profile(s) updated:`));
335
+ for (const ch of changes) console.log(` - ${ch.name}: ${ch.summary}`);
336
+ } else console.log(c.dim('No significant changes detected.'));
337
+ });
338
+
339
+ // --- config ---
340
+ program.command('config')
341
+ .description('View/edit settings')
342
+ .action(() => {
343
+ const cfg = loadConfig();
344
+ console.log(c.bold(c.cyan('Current config:')));
345
+ console.log(` Profiles dir: ${cfg.profiles_dir}`);
346
+ console.log(` Monitor interval: ${cfg.monitor_interval_hours}h`);
347
+ console.log(` Bird CLI: ${checkTool('bird') ? c.green('available') : c.red('not found')}`);
348
+ console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
349
+ });
350
+
351
+ program.parse();
package/lib/config.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execSync } from 'child_process';
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), '.vip-crm');
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
+ const CHANGELOG_FILE = path.join(CONFIG_DIR, 'changelog.jsonl');
9
+
10
+ const DEFAULT_CONFIG = {
11
+ profiles_dir: path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles'),
12
+ monitor_interval_hours: 24,
13
+ };
14
+
15
+ export { CONFIG_DIR, CONFIG_FILE, CHANGELOG_FILE };
16
+
17
+ export function loadConfig() {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
+
20
+ if (fs.existsSync(CONFIG_FILE)) {
21
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
22
+ return { ...DEFAULT_CONFIG, ...config };
23
+ }
24
+
25
+ saveConfig(DEFAULT_CONFIG);
26
+ return { ...DEFAULT_CONFIG };
27
+ }
28
+
29
+ export function saveConfig(config) {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
32
+ }
33
+
34
+ export function getProfilesDir() {
35
+ const config = loadConfig();
36
+ const dir = config.profiles_dir.replace(/^~/, os.homedir());
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ return dir;
39
+ }
40
+
41
+ export function checkTool(name) {
42
+ try {
43
+ execSync(`which ${name}`, { stdio: 'ignore' });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
@@ -0,0 +1,73 @@
1
+ import { execSync } from 'child_process';
2
+ import { checkTool } from '../config.js';
3
+
4
+ export function search(query, maxResults = 5) {
5
+ // Use ddgs CLI (installed via npm or pip)
6
+ if (checkTool('ddgs')) {
7
+ try {
8
+ const output = execSync(
9
+ `ddgs text "${query.replace(/"/g, '\\"')}" -m ${maxResults} -o json`,
10
+ { encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }
11
+ );
12
+
13
+ const results = JSON.parse(output);
14
+ return results.map(r => ({
15
+ title: r.title || '',
16
+ url: r.href || r.url || '',
17
+ body: r.body || '',
18
+ }));
19
+ } catch {
20
+ // fall through
21
+ }
22
+ }
23
+
24
+ // Fallback: use curl with DuckDuckGo lite
25
+ try {
26
+ const encoded = encodeURIComponent(query);
27
+ const output = execSync(
28
+ `curl -s "https://lite.duckduckgo.com/lite/?q=${encoded}" -H "User-Agent: VIPCare/0.1"`,
29
+ { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
30
+ );
31
+
32
+ // Basic parsing of DDG lite HTML results
33
+ const results = [];
34
+ const linkRegex = /<a[^>]+href="([^"]+)"[^>]*class="result-link"[^>]*>([^<]+)<\/a>/g;
35
+ const snippetRegex = /<td class="result-snippet">([^<]+)<\/td>/g;
36
+
37
+ let match;
38
+ while ((match = linkRegex.exec(output)) && results.length < maxResults) {
39
+ results.push({ title: match[2].trim(), url: match[1], body: '' });
40
+ }
41
+
42
+ let i = 0;
43
+ while ((match = snippetRegex.exec(output)) && i < results.length) {
44
+ results[i].body = match[1].trim();
45
+ i++;
46
+ }
47
+
48
+ return results;
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ export function searchPerson(name, company) {
55
+ const queries = [`"${name}"`];
56
+ if (company) queries.push(`"${name}" "${company}"`);
57
+ queries.push(`"${name}" site:twitter.com OR site:x.com`);
58
+ queries.push(`"${name}" site:linkedin.com/in`);
59
+
60
+ const allResults = [];
61
+ const seenUrls = new Set();
62
+
63
+ for (const q of queries) {
64
+ for (const r of search(q, 5)) {
65
+ if (!seenUrls.has(r.url)) {
66
+ seenUrls.add(r.url);
67
+ allResults.push(r);
68
+ }
69
+ }
70
+ }
71
+
72
+ return allResults;
73
+ }
@@ -0,0 +1,74 @@
1
+ import { execSync } from 'child_process';
2
+ import { checkTool } from '../config.js';
3
+
4
+ export function isAvailable() {
5
+ return checkTool('bird');
6
+ }
7
+
8
+ export function parseTweets(output) {
9
+ const tweets = [];
10
+ let current = [];
11
+
12
+ for (const line of output.split('\n')) {
13
+ const stripped = line.trim();
14
+
15
+ if (/^[─━═]+$/.test(stripped) || !stripped) {
16
+ if (current.length) {
17
+ const text = current.join(' ').trim();
18
+ if (text.length > 5) tweets.push(text);
19
+ current = [];
20
+ }
21
+ continue;
22
+ }
23
+
24
+ if (/^\d+ (retweets?|likes?|replies|views)/i.test(stripped)) continue;
25
+ if (/^\d{4}-\d{2}-\d{2}/.test(stripped)) continue;
26
+
27
+ current.push(stripped);
28
+ }
29
+
30
+ if (current.length) {
31
+ const text = current.join(' ').trim();
32
+ if (text.length > 5) tweets.push(text);
33
+ }
34
+
35
+ return tweets;
36
+ }
37
+
38
+ export function fetchProfile(handle) {
39
+ if (!isAvailable()) return null;
40
+
41
+ handle = handle.replace(/^@/, '');
42
+ const data = { handle, bio: '', displayName: '', tweets: [], rawOutput: '' };
43
+
44
+ try {
45
+ const output = execSync(`bird search "from:${handle}" --count 10`, {
46
+ encoding: 'utf-8',
47
+ timeout: 30000,
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ });
50
+
51
+ if (output.trim()) {
52
+ data.rawOutput = output;
53
+ data.tweets = parseTweets(output);
54
+ }
55
+ } catch {
56
+ // timeout or not found
57
+ }
58
+
59
+ return data;
60
+ }
61
+
62
+ export function fetchTweetsByUrl(url) {
63
+ if (!isAvailable()) return null;
64
+
65
+ try {
66
+ return execSync(`bird read "${url}"`, {
67
+ encoding: 'utf-8',
68
+ timeout: 30000,
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ });
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,29 @@
1
+ export async function fetchPageText(url, timeout = 15000) {
2
+ try {
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), timeout);
5
+
6
+ const resp = await fetch(url, {
7
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; VIPCare/0.1)' },
8
+ signal: controller.signal,
9
+ });
10
+
11
+ clearTimeout(timer);
12
+
13
+ if (!resp.ok) return '';
14
+
15
+ const html = await resp.text();
16
+ // Strip tags
17
+ let text = html
18
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
19
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
20
+ .replace(/<[^>]+>/g, ' ')
21
+ .replace(/\s+/g, ' ')
22
+ .trim();
23
+
24
+ if (text.length > 5000) text = text.slice(0, 5000) + '...';
25
+ return text;
26
+ } catch {
27
+ return '';
28
+ }
29
+ }