imperium-crawl 2.0.0 → 2.2.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 +41 -77
- package/dist/cli-tui.d.ts +1 -1
- package/dist/cli-tui.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -18
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/formatters.d.ts +2 -2
- package/dist/formatters.js +2 -2
- package/dist/index.js +6 -28
- package/dist/index.js.map +1 -1
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +8 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/recipes/influencer-competitor-spy.json +14 -0
- package/dist/recipes/influencer-content-scout.json +14 -0
- package/dist/recipes/influencer-hashtag-scout.json +14 -0
- package/dist/recipes/influencer-niche-discovery.json +14 -0
- package/dist/skills/manager.d.ts +11 -2
- package/dist/skills/manager.d.ts.map +1 -1
- package/dist/skills/manager.js.map +1 -1
- package/dist/social/ai-fallback.d.ts +22 -0
- package/dist/social/ai-fallback.d.ts.map +1 -0
- package/dist/social/ai-fallback.js +156 -0
- package/dist/social/ai-fallback.js.map +1 -0
- package/dist/social/parsers.d.ts +28 -0
- package/dist/social/parsers.d.ts.map +1 -0
- package/dist/social/parsers.js +146 -0
- package/dist/social/parsers.js.map +1 -0
- package/dist/social/types.d.ts +86 -0
- package/dist/social/types.d.ts.map +1 -0
- package/dist/social/types.js +5 -0
- package/dist/social/types.js.map +1 -0
- package/dist/social/whisper.d.ts +29 -0
- package/dist/social/whisper.d.ts.map +1 -0
- package/dist/social/whisper.js +88 -0
- package/dist/social/whisper.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/instagram.d.ts +51 -0
- package/dist/tools/instagram.d.ts.map +1 -0
- package/dist/tools/instagram.js +462 -0
- package/dist/tools/instagram.js.map +1 -0
- package/dist/tools/interact.d.ts +4 -4
- package/dist/tools/interact.js +1 -1
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/manifest.d.ts +0 -1
- package/dist/tools/manifest.d.ts.map +1 -1
- package/dist/tools/manifest.js +17 -1
- package/dist/tools/manifest.js.map +1 -1
- package/dist/tools/reddit.d.ts +36 -0
- package/dist/tools/reddit.d.ts.map +1 -0
- package/dist/tools/reddit.js +190 -0
- package/dist/tools/reddit.js.map +1 -0
- package/dist/tools/run-skill.d.ts +24 -0
- package/dist/tools/run-skill.d.ts.map +1 -1
- package/dist/tools/run-skill.js +1015 -12
- package/dist/tools/run-skill.js.map +1 -1
- package/dist/tools/tiktok.d.ts +30 -0
- package/dist/tools/tiktok.d.ts.map +1 -0
- package/dist/tools/tiktok.js +246 -0
- package/dist/tools/tiktok.js.map +1 -0
- package/dist/tools/youtube.d.ts +33 -0
- package/dist/tools/youtube.d.ts.map +1 -0
- package/dist/tools/youtube.js +489 -0
- package/dist/tools/youtube.js.map +1 -0
- package/dist/utils/fetcher.d.ts.map +1 -1
- package/dist/utils/fetcher.js +1 -3
- package/dist/utils/fetcher.js.map +1 -1
- package/package.json +3 -9
package/dist/tools/run-skill.js
CHANGED
|
@@ -13,9 +13,18 @@ export const schema = z.object({
|
|
|
13
13
|
chrome_profile: z.string().max(1000).optional().describe("Path to Chrome user data directory for authenticated sessions (cookies, localStorage). Overrides CHROME_PROFILE_PATH env var."),
|
|
14
14
|
duration_seconds: z.number().min(1).max(300).optional().describe("Override WebSocket monitoring duration (seconds). Only applies to monitor_websocket recipes."),
|
|
15
15
|
max_messages: z.number().min(1).max(1000).optional().describe("Override max WebSocket messages to capture. Only applies to monitor_websocket recipes."),
|
|
16
|
+
// Influencer discovery params
|
|
17
|
+
niche: z.string().max(200).optional().describe("Niche keywords for influencer discovery"),
|
|
18
|
+
location: z.string().max(100).optional().describe("Location filter"),
|
|
19
|
+
hashtags: z.array(z.string()).max(10).optional().describe("Hashtags to search (hashtag_scout workflow)"),
|
|
20
|
+
competitor: z.string().max(200).optional().describe("Competitor brand/handle (competitor_spy workflow)"),
|
|
21
|
+
output_format: z.enum(["json", "markdown", "csv"]).optional().describe("Output format for influencer discovery"),
|
|
22
|
+
threshold: z.number().min(0).max(100).optional().describe("Tier qualification threshold (default 60)"),
|
|
23
|
+
min_subscribers: z.number().min(0).optional().describe("Min YouTube subscribers (content_scout, default 300)"),
|
|
24
|
+
max_subscribers: z.number().min(0).optional().describe("Max YouTube subscribers (content_scout, default 100000)"),
|
|
16
25
|
});
|
|
17
26
|
// --- Helpers ---
|
|
18
|
-
function
|
|
27
|
+
function toolResult(data) {
|
|
19
28
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
20
29
|
}
|
|
21
30
|
function extractField($, el, selectorRaw) {
|
|
@@ -68,7 +77,7 @@ async function runExtract(config, url, input) {
|
|
|
68
77
|
break;
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
|
-
return
|
|
80
|
+
return toolResult({
|
|
72
81
|
skill: config.name,
|
|
73
82
|
description: config.description,
|
|
74
83
|
tool: "extract",
|
|
@@ -85,7 +94,7 @@ async function runAiExtract(config, url, input) {
|
|
|
85
94
|
const { smartFetch } = await import("../stealth/index.js");
|
|
86
95
|
const { htmlToMarkdown } = await import("../utils/markdown.js");
|
|
87
96
|
if (!hasLLMConfigured()) {
|
|
88
|
-
return
|
|
97
|
+
return toolResult({
|
|
89
98
|
error: "LLM not configured",
|
|
90
99
|
message: "Set the LLM_API_KEY environment variable to enable AI extraction. Run `imperium-crawl setup` for guided configuration.",
|
|
91
100
|
});
|
|
@@ -110,7 +119,7 @@ async function runAiExtract(config, url, input) {
|
|
|
110
119
|
}
|
|
111
120
|
const client = await createLLMClient();
|
|
112
121
|
const result = await extractWithLLM(client, markdown, config.schema, config.max_tokens ?? 2000);
|
|
113
|
-
return
|
|
122
|
+
return toolResult({
|
|
114
123
|
skill: config.name,
|
|
115
124
|
description: config.description,
|
|
116
125
|
tool: "ai_extract",
|
|
@@ -131,7 +140,7 @@ async function runReadability(config, url, input) {
|
|
|
131
140
|
const reader = new Readability(document, { charThreshold: 50 });
|
|
132
141
|
const article = reader.parse();
|
|
133
142
|
if (!article) {
|
|
134
|
-
return
|
|
143
|
+
return toolResult({ error: "Could not extract article content", url: result.url });
|
|
135
144
|
}
|
|
136
145
|
const format = config.format ?? "markdown";
|
|
137
146
|
let content;
|
|
@@ -146,7 +155,7 @@ async function runReadability(config, url, input) {
|
|
|
146
155
|
default:
|
|
147
156
|
content = htmlToMarkdown(article.content);
|
|
148
157
|
}
|
|
149
|
-
return
|
|
158
|
+
return toolResult({
|
|
150
159
|
skill: config.name,
|
|
151
160
|
description: config.description,
|
|
152
161
|
tool: "readability",
|
|
@@ -208,7 +217,7 @@ async function runScrape(config, url, input) {
|
|
|
208
217
|
const { data: truncated, truncated: wasTruncated } = truncateJsonData(parsed, input.max_items);
|
|
209
218
|
data = truncated;
|
|
210
219
|
format = "json";
|
|
211
|
-
return
|
|
220
|
+
return toolResult({
|
|
212
221
|
skill: config.name,
|
|
213
222
|
description: config.description,
|
|
214
223
|
tool: "scrape",
|
|
@@ -224,7 +233,7 @@ async function runScrape(config, url, input) {
|
|
|
224
233
|
data = htmlToMarkdown(result.html);
|
|
225
234
|
format = "markdown";
|
|
226
235
|
}
|
|
227
|
-
return
|
|
236
|
+
return toolResult({
|
|
228
237
|
skill: config.name,
|
|
229
238
|
description: config.description,
|
|
230
239
|
tool: "scrape",
|
|
@@ -240,7 +249,7 @@ async function runMonitorWebsocket(config, url, input) {
|
|
|
240
249
|
const { acquirePage } = await import("../stealth/chrome-profile.js");
|
|
241
250
|
const { resolveProxy } = await import("../stealth/proxy.js");
|
|
242
251
|
if (!(await isPlaywrightAvailable())) {
|
|
243
|
-
return
|
|
252
|
+
return toolResult({
|
|
244
253
|
error: "rebrowser-playwright is required for WebSocket monitoring. Install with: npm i rebrowser-playwright",
|
|
245
254
|
});
|
|
246
255
|
}
|
|
@@ -291,7 +300,7 @@ async function runMonitorWebsocket(config, url, input) {
|
|
|
291
300
|
});
|
|
292
301
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
293
302
|
await page.waitForTimeout(durationSeconds * 1000);
|
|
294
|
-
return
|
|
303
|
+
return toolResult({
|
|
295
304
|
skill: config.name,
|
|
296
305
|
description: config.description,
|
|
297
306
|
tool: "monitor_websocket",
|
|
@@ -307,13 +316,1005 @@ async function runMonitorWebsocket(config, url, input) {
|
|
|
307
316
|
await handle.cleanup();
|
|
308
317
|
}
|
|
309
318
|
}
|
|
319
|
+
// Brave Search helper
|
|
320
|
+
async function braveSearch(query, count = 10) {
|
|
321
|
+
const apiKey = process.env.BRAVE_API_KEY;
|
|
322
|
+
if (!apiKey)
|
|
323
|
+
return null;
|
|
324
|
+
const { issueRequest } = await import("../brave-api/index.js");
|
|
325
|
+
try {
|
|
326
|
+
return await issueRequest(apiKey, "/web/search", { q: query, count });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// YouTube tool helper — direct in-process call
|
|
333
|
+
async function ytExecute(action, params) {
|
|
334
|
+
const yt = await import("./youtube.js");
|
|
335
|
+
const result = await yt.execute({ action, limit: 10, sort: "relevance", ...params });
|
|
336
|
+
try {
|
|
337
|
+
return JSON.parse(result.content[0].text || "{}");
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Instagram tool helper — direct in-process call
|
|
344
|
+
async function igExecute(action, params) {
|
|
345
|
+
const ig = await import("./instagram.js");
|
|
346
|
+
const result = await ig.execute({ action, limit: 1, sort: "engagement", ...params });
|
|
347
|
+
try {
|
|
348
|
+
return JSON.parse(result.content[0].text || "{}");
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Parse IG handle from YouTube description
|
|
355
|
+
function parseIgHandles(description) {
|
|
356
|
+
if (!description)
|
|
357
|
+
return [];
|
|
358
|
+
const handles = [];
|
|
359
|
+
// Look for patterns like "instagram: @handle", "ig: @handle", "insta: @handle"
|
|
360
|
+
const patterns = [
|
|
361
|
+
/(?:instagram|ig|insta)[:\s]*@?([\w.]{3,30})/gi,
|
|
362
|
+
/instagram\.com\/([\w.]{3,30})/gi,
|
|
363
|
+
];
|
|
364
|
+
for (const pattern of patterns) {
|
|
365
|
+
let match;
|
|
366
|
+
while ((match = pattern.exec(description)) !== null) {
|
|
367
|
+
const h = match[1].toLowerCase();
|
|
368
|
+
if (!handles.includes(h) && h !== "com" && h !== "www")
|
|
369
|
+
handles.push(h);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return handles;
|
|
373
|
+
}
|
|
374
|
+
// Parse followers from Brave snippet — uses inline compact number parsing
|
|
375
|
+
function parseFollowersFromSnippet(snippet) {
|
|
376
|
+
if (!snippet)
|
|
377
|
+
return undefined;
|
|
378
|
+
// Match patterns like "1.2M Followers", "842K followers"
|
|
379
|
+
const match = snippet.match(/([\d,.]+)\s*([KMB])?\s*[Ff]ollowers/i);
|
|
380
|
+
if (!match)
|
|
381
|
+
return undefined;
|
|
382
|
+
const num = parseFloat(match[1].replace(/,/g, ""));
|
|
383
|
+
if (isNaN(num))
|
|
384
|
+
return undefined;
|
|
385
|
+
const suffix = match[2]?.toUpperCase();
|
|
386
|
+
const mult = { K: 1_000, M: 1_000_000, B: 1_000_000_000 };
|
|
387
|
+
return Math.round(num * (suffix ? (mult[suffix] || 1) : 1));
|
|
388
|
+
}
|
|
389
|
+
// IG API call with rate limiting
|
|
390
|
+
async function igApiCall(endpoint, igCallsUsed, igMaxCalls) {
|
|
391
|
+
if (igCallsUsed.count >= igMaxCalls)
|
|
392
|
+
return null;
|
|
393
|
+
const sessionId = process.env.IG_SESSION_ID;
|
|
394
|
+
const csrfToken = process.env.IG_CSRF_TOKEN;
|
|
395
|
+
const dsUserId = process.env.IG_DS_USER_ID;
|
|
396
|
+
if (!sessionId)
|
|
397
|
+
return null;
|
|
398
|
+
igCallsUsed.count++;
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch(`https://www.instagram.com/${endpoint}`, {
|
|
401
|
+
headers: {
|
|
402
|
+
"Cookie": `sessionid=${sessionId}; csrftoken=${csrfToken || ""}; ds_user_id=${dsUserId || ""}`,
|
|
403
|
+
"X-CSRFToken": csrfToken || "",
|
|
404
|
+
"X-IG-App-ID": "936619743392459",
|
|
405
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
406
|
+
},
|
|
407
|
+
signal: AbortSignal.timeout(10_000),
|
|
408
|
+
});
|
|
409
|
+
if (!res.ok)
|
|
410
|
+
return null;
|
|
411
|
+
return await res.json();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Calculate engagement rate
|
|
418
|
+
function calcEngagement(avgViews, avgLikes, subscribers) {
|
|
419
|
+
if (!subscribers || subscribers === 0)
|
|
420
|
+
return 0;
|
|
421
|
+
if (avgLikes && avgViews)
|
|
422
|
+
return ((avgLikes / avgViews) * 100);
|
|
423
|
+
if (avgLikes)
|
|
424
|
+
return ((avgLikes / subscribers) * 100);
|
|
425
|
+
if (avgViews)
|
|
426
|
+
return ((avgViews / subscribers) * 100);
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
// Scoring weights per category
|
|
430
|
+
const REACH_WEIGHTS = { audience_size: 25, engagement_rate: 20, niche_relevance: 15, multi_platform: 15, consistency: 10, frequency: 8, contact: 5, collab_signals: 2 };
|
|
431
|
+
const CONVERSION_WEIGHTS = { engagement_rate: 30, niche_relevance: 25, consistency: 15, contact: 10, collab_signals: 8, frequency: 5, audience_size: 5, multi_platform: 2 };
|
|
432
|
+
const PARTNERSHIP_WEIGHTS = { niche_relevance: 20, engagement_rate: 20, contact: 15, collab_signals: 15, consistency: 12, frequency: 8, audience_size: 5, multi_platform: 5 };
|
|
433
|
+
function scoreCriterion(profile, criterion) {
|
|
434
|
+
switch (criterion) {
|
|
435
|
+
case "audience_size": {
|
|
436
|
+
const total = (profile.subscribers || 0) + (profile.ig_followers || 0);
|
|
437
|
+
if (total >= 100_000)
|
|
438
|
+
return 10; // macro — lower score (harder to partner)
|
|
439
|
+
if (total >= 10_000)
|
|
440
|
+
return 25; // micro — sweet spot
|
|
441
|
+
if (total >= 1_000)
|
|
442
|
+
return 15; // nano — good for niche
|
|
443
|
+
return 5;
|
|
444
|
+
}
|
|
445
|
+
case "engagement_rate": {
|
|
446
|
+
const er = profile.engagement_rate || 0;
|
|
447
|
+
if (er > 8)
|
|
448
|
+
return 20;
|
|
449
|
+
if (er > 5)
|
|
450
|
+
return 16;
|
|
451
|
+
if (er > 3)
|
|
452
|
+
return 12;
|
|
453
|
+
if (er > 1)
|
|
454
|
+
return 8;
|
|
455
|
+
return 4;
|
|
456
|
+
}
|
|
457
|
+
case "niche_relevance":
|
|
458
|
+
return Math.round((profile.niche_match_pct || 0) / 100 * 15);
|
|
459
|
+
case "multi_platform": {
|
|
460
|
+
if (profile.platform_count >= 3)
|
|
461
|
+
return 15;
|
|
462
|
+
if (profile.platform_count >= 2)
|
|
463
|
+
return 10;
|
|
464
|
+
return 5;
|
|
465
|
+
}
|
|
466
|
+
case "consistency":
|
|
467
|
+
return Math.round((profile.niche_match_pct || 50) / 100 * 10);
|
|
468
|
+
case "frequency": {
|
|
469
|
+
const freq = profile.posting_frequency;
|
|
470
|
+
if (freq === "weekly")
|
|
471
|
+
return 8;
|
|
472
|
+
if (freq === "biweekly")
|
|
473
|
+
return 5;
|
|
474
|
+
if (freq === "monthly")
|
|
475
|
+
return 3;
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
case "contact": {
|
|
479
|
+
if (profile.email)
|
|
480
|
+
return 5;
|
|
481
|
+
if (profile.website)
|
|
482
|
+
return 3;
|
|
483
|
+
if (profile.has_business_contact)
|
|
484
|
+
return 2;
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
case "collab_signals": {
|
|
488
|
+
let s = 0;
|
|
489
|
+
if (profile.has_collab_signals)
|
|
490
|
+
s += 1;
|
|
491
|
+
if (profile.has_business_contact)
|
|
492
|
+
s += 1;
|
|
493
|
+
return s;
|
|
494
|
+
}
|
|
495
|
+
default: return 0;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function calculateScores(profile) {
|
|
499
|
+
const calc = (weights) => {
|
|
500
|
+
let score = 0;
|
|
501
|
+
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
|
|
502
|
+
for (const [criterion, weight] of Object.entries(weights)) {
|
|
503
|
+
const raw = scoreCriterion(profile, criterion);
|
|
504
|
+
// Normalize: raw is scored out of the max for that criterion, scale to weight
|
|
505
|
+
const maxRaw = criterion === "audience_size" ? 25 : criterion === "engagement_rate" ? 20 : criterion === "niche_relevance" ? 15 : criterion === "multi_platform" ? 15 : criterion === "consistency" ? 10 : criterion === "frequency" ? 8 : criterion === "contact" ? 5 : 2;
|
|
506
|
+
score += (raw / maxRaw) * weight;
|
|
507
|
+
}
|
|
508
|
+
return Math.round((score / totalWeight) * 100);
|
|
509
|
+
};
|
|
510
|
+
return {
|
|
511
|
+
reach: calc(REACH_WEIGHTS),
|
|
512
|
+
conversion: calc(CONVERSION_WEIGHTS),
|
|
513
|
+
partnership: calc(PARTNERSHIP_WEIGHTS),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function classifyTier(scores, threshold) {
|
|
517
|
+
const above = [scores.reach, scores.conversion, scores.partnership].filter(s => s >= threshold).length;
|
|
518
|
+
if (above >= 3)
|
|
519
|
+
return "GOLDEN";
|
|
520
|
+
if (above >= 2)
|
|
521
|
+
return "SILVER";
|
|
522
|
+
if (above >= 1)
|
|
523
|
+
return "BRONZE";
|
|
524
|
+
return "UNRANKED";
|
|
525
|
+
}
|
|
526
|
+
// Estimate posting frequency from recent video dates
|
|
527
|
+
function estimateFrequency(videos) {
|
|
528
|
+
if (!videos.length)
|
|
529
|
+
return "inactive";
|
|
530
|
+
// Simple heuristic based on last 3 videos' published text
|
|
531
|
+
const hasRecent = videos.some(v => {
|
|
532
|
+
const p = v.published?.toLowerCase() || "";
|
|
533
|
+
return p.includes("day") || p.includes("hour") || p.includes("minute");
|
|
534
|
+
});
|
|
535
|
+
if (hasRecent && videos.length >= 3)
|
|
536
|
+
return "weekly";
|
|
537
|
+
const hasWeekly = videos.some(v => {
|
|
538
|
+
const p = v.published?.toLowerCase() || "";
|
|
539
|
+
return p.includes("week");
|
|
540
|
+
});
|
|
541
|
+
if (hasWeekly)
|
|
542
|
+
return "biweekly";
|
|
543
|
+
return "monthly";
|
|
544
|
+
}
|
|
545
|
+
// Calculate niche depth from video titles
|
|
546
|
+
function calcNicheDepth(videoTitles, nicheKeywords) {
|
|
547
|
+
if (!videoTitles.length || !nicheKeywords.length)
|
|
548
|
+
return "GENERALIST";
|
|
549
|
+
const count = Math.min(videoTitles.length, 5);
|
|
550
|
+
let matching = 0;
|
|
551
|
+
for (let i = 0; i < count; i++) {
|
|
552
|
+
const lower = videoTitles[i].toLowerCase();
|
|
553
|
+
if (nicheKeywords.some(kw => lower.includes(kw.toLowerCase())))
|
|
554
|
+
matching++;
|
|
555
|
+
}
|
|
556
|
+
if (matching >= 4 || (count <= 3 && matching === count))
|
|
557
|
+
return "SPECIALIST";
|
|
558
|
+
if (matching >= Math.ceil(count * 0.5))
|
|
559
|
+
return "MIXED";
|
|
560
|
+
return "GENERALIST";
|
|
561
|
+
}
|
|
562
|
+
// Calculate view consistency (0-100) — low coefficient of variation = high consistency
|
|
563
|
+
function calcViewConsistency(views) {
|
|
564
|
+
if (views.length < 2)
|
|
565
|
+
return 50;
|
|
566
|
+
const mean = views.reduce((a, b) => a + b, 0) / views.length;
|
|
567
|
+
if (mean === 0)
|
|
568
|
+
return 0;
|
|
569
|
+
const variance = views.reduce((s, v) => s + (v - mean) ** 2, 0) / views.length;
|
|
570
|
+
const cv = Math.sqrt(variance) / mean; // coefficient of variation
|
|
571
|
+
// cv=0 → 100 (perfectly consistent), cv>=2 → 0 (wildly inconsistent)
|
|
572
|
+
return Math.round(Math.max(0, Math.min(100, (1 - cv / 2) * 100)));
|
|
573
|
+
}
|
|
574
|
+
// Composite content quality score (0-100)
|
|
575
|
+
function calcContentScore(profile) {
|
|
576
|
+
let score = 0;
|
|
577
|
+
// Likes per view (25 pts) — higher = more engaged audience
|
|
578
|
+
const lpv = profile.likes_per_view || 0;
|
|
579
|
+
if (lpv >= 0.08)
|
|
580
|
+
score += 25;
|
|
581
|
+
else if (lpv >= 0.05)
|
|
582
|
+
score += 20;
|
|
583
|
+
else if (lpv >= 0.03)
|
|
584
|
+
score += 15;
|
|
585
|
+
else if (lpv >= 0.01)
|
|
586
|
+
score += 8;
|
|
587
|
+
// Views per sub (25 pts) — higher = loyal audience
|
|
588
|
+
const vps = profile.views_per_sub || 0;
|
|
589
|
+
if (vps >= 0.5)
|
|
590
|
+
score += 25;
|
|
591
|
+
else if (vps >= 0.3)
|
|
592
|
+
score += 20;
|
|
593
|
+
else if (vps >= 0.15)
|
|
594
|
+
score += 15;
|
|
595
|
+
else if (vps >= 0.05)
|
|
596
|
+
score += 8;
|
|
597
|
+
// Niche depth (30 pts)
|
|
598
|
+
if (profile.niche_depth === "SPECIALIST")
|
|
599
|
+
score += 30;
|
|
600
|
+
else if (profile.niche_depth === "MIXED")
|
|
601
|
+
score += 15;
|
|
602
|
+
// View consistency (20 pts)
|
|
603
|
+
score += Math.round((profile.view_consistency || 0) / 100 * 20);
|
|
604
|
+
return score;
|
|
605
|
+
}
|
|
606
|
+
// Calculate niche match % from descriptions/titles
|
|
607
|
+
function calcNicheMatch(texts, nicheKeywords) {
|
|
608
|
+
if (!texts.length || !nicheKeywords.length)
|
|
609
|
+
return 50;
|
|
610
|
+
const lowerTexts = texts.map(t => t.toLowerCase()).join(" ");
|
|
611
|
+
let matches = 0;
|
|
612
|
+
for (const kw of nicheKeywords) {
|
|
613
|
+
if (lowerTexts.includes(kw.toLowerCase()))
|
|
614
|
+
matches++;
|
|
615
|
+
}
|
|
616
|
+
return Math.round((matches / nicheKeywords.length) * 100);
|
|
617
|
+
}
|
|
618
|
+
// Extract contact info from description
|
|
619
|
+
function extractContactInfo(desc) {
|
|
620
|
+
const emailMatch = desc.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
|
|
621
|
+
const websiteMatch = desc.match(/https?:\/\/(?!(?:youtube|instagram|twitter|facebook|tiktok)\.com)[^\s"'<>]+/i);
|
|
622
|
+
const hasBusiness = /business|collab|partner|sponsor|inquir/i.test(desc);
|
|
623
|
+
const hasCollab = /collab|partner|sponsor|brand|work with/i.test(desc);
|
|
624
|
+
return {
|
|
625
|
+
email: emailMatch?.[0],
|
|
626
|
+
website: websiteMatch?.[0],
|
|
627
|
+
hasBusiness,
|
|
628
|
+
hasCollab,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
// Format output
|
|
632
|
+
function formatInfluencerOutput(influencers, format, meta) {
|
|
633
|
+
// Sort by tier then by highest avg score
|
|
634
|
+
const tierOrder = { GOLDEN: 0, SILVER: 1, BRONZE: 2, UNRANKED: 3 };
|
|
635
|
+
influencers.sort((a, b) => {
|
|
636
|
+
const td = tierOrder[a.tier] - tierOrder[b.tier];
|
|
637
|
+
if (td !== 0)
|
|
638
|
+
return td;
|
|
639
|
+
const avgA = (a.scores.reach + a.scores.conversion + a.scores.partnership) / 3;
|
|
640
|
+
const avgB = (b.scores.reach + b.scores.conversion + b.scores.partnership) / 3;
|
|
641
|
+
return avgB - avgA;
|
|
642
|
+
});
|
|
643
|
+
if (format === "csv") {
|
|
644
|
+
const header = "handle,name,tier,reach,conversion,partnership,subscribers,ig_followers,engagement_rate,niche_depth,views_per_sub,likes_per_view,view_consistency,youtube_url,instagram_url,email";
|
|
645
|
+
const rows = influencers.map(i => [i.handle, i.name, i.tier, i.scores.reach, i.scores.conversion, i.scores.partnership,
|
|
646
|
+
i.subscribers || "", i.ig_followers || "", i.engagement_rate?.toFixed(1) || "",
|
|
647
|
+
i.niche_depth || "", i.views_per_sub ?? "", i.likes_per_view ?? "", i.view_consistency ?? "",
|
|
648
|
+
i.youtube_url || "", i.instagram_url || "", i.email || ""].join(","));
|
|
649
|
+
return header + "\n" + rows.join("\n");
|
|
650
|
+
}
|
|
651
|
+
if (format === "markdown") {
|
|
652
|
+
const tierBadge = { GOLDEN: "🥇", SILVER: "🥈", BRONZE: "🥉", UNRANKED: "⬜" };
|
|
653
|
+
let md = `# Influencer Discovery: ${meta.niche}\n\n`;
|
|
654
|
+
md += `**Workflow**: ${meta.workflow} | **Threshold**: ${meta.threshold} | **Found**: ${influencers.length}\n\n`;
|
|
655
|
+
md += `| Tier | Handle | Subscribers | IG Followers | Engagement | Niche | V/Sub | L/View | Consist | Reach | Conv | Partner | Contact |\n`;
|
|
656
|
+
md += `|------|--------|-------------|-------------|-----------|-------|-------|--------|---------|-------|------|---------|--------|\n`;
|
|
657
|
+
for (const i of influencers) {
|
|
658
|
+
const subs = i.subscribers ? formatNum(i.subscribers) : "-";
|
|
659
|
+
const igf = i.ig_followers ? formatNum(i.ig_followers) : "-";
|
|
660
|
+
const er = i.engagement_rate ? `${i.engagement_rate.toFixed(1)}%` : "-";
|
|
661
|
+
const nd = i.niche_depth || "-";
|
|
662
|
+
const vps = i.views_per_sub !== undefined ? i.views_per_sub.toFixed(2) : "-";
|
|
663
|
+
const lpv = i.likes_per_view !== undefined ? i.likes_per_view.toFixed(3) : "-";
|
|
664
|
+
const vc = i.view_consistency !== undefined ? String(i.view_consistency) : "-";
|
|
665
|
+
const contact = i.email ? "📧" : i.website ? "🌐" : i.has_business_contact ? "💼" : "-";
|
|
666
|
+
md += `| ${tierBadge[i.tier]} ${i.tier} | ${i.handle} | ${subs} | ${igf} | ${er} | ${nd} | ${vps} | ${lpv} | ${vc} | ${i.scores.reach} | ${i.scores.conversion} | ${i.scores.partnership} | ${contact} |\n`;
|
|
667
|
+
}
|
|
668
|
+
return md;
|
|
669
|
+
}
|
|
670
|
+
// JSON (default)
|
|
671
|
+
return {
|
|
672
|
+
workflow: meta.workflow,
|
|
673
|
+
niche: meta.niche,
|
|
674
|
+
threshold: meta.threshold,
|
|
675
|
+
total_found: influencers.length,
|
|
676
|
+
tiers: {
|
|
677
|
+
golden: influencers.filter(i => i.tier === "GOLDEN").length,
|
|
678
|
+
silver: influencers.filter(i => i.tier === "SILVER").length,
|
|
679
|
+
bronze: influencers.filter(i => i.tier === "BRONZE").length,
|
|
680
|
+
unranked: influencers.filter(i => i.tier === "UNRANKED").length,
|
|
681
|
+
},
|
|
682
|
+
influencers,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function formatNum(n) {
|
|
686
|
+
if (n >= 1_000_000)
|
|
687
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
688
|
+
if (n >= 1_000)
|
|
689
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
690
|
+
return String(n);
|
|
691
|
+
}
|
|
692
|
+
// --- Workflow: niche_discovery ---
|
|
693
|
+
async function runNicheDiscovery(config, input) {
|
|
694
|
+
const { parseCompactNumber } = await import("../social/parsers.js");
|
|
695
|
+
const niche = input.niche || config.niche;
|
|
696
|
+
const location = input.location || "";
|
|
697
|
+
const nicheKeywords = niche.split(/[\s,]+/).filter(Boolean);
|
|
698
|
+
// Step 1: YouTube search with 3 queries
|
|
699
|
+
const queries = [
|
|
700
|
+
`${niche} vlog`,
|
|
701
|
+
`${niche} guide`,
|
|
702
|
+
location ? `${location} ${niche}` : `best ${niche}`,
|
|
703
|
+
];
|
|
704
|
+
const searchResults = [];
|
|
705
|
+
for (const q of queries) {
|
|
706
|
+
const data = await ytExecute("search", { query: q });
|
|
707
|
+
if (data?.results) {
|
|
708
|
+
for (const v of data.results) {
|
|
709
|
+
searchResults.push({ author: v.author, author_url: v.author_url });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Step 2: Deduplicate by author_url
|
|
714
|
+
const uniqueCreators = new Map();
|
|
715
|
+
for (const r of searchResults) {
|
|
716
|
+
if (r.author_url && !uniqueCreators.has(r.author_url)) {
|
|
717
|
+
uniqueCreators.set(r.author_url, r.author);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Take top 10
|
|
721
|
+
const creatorEntries = Array.from(uniqueCreators.entries()).slice(0, 10);
|
|
722
|
+
const igCallsUsed = { count: 0 };
|
|
723
|
+
const igMaxCalls = input.threshold !== undefined ? config.ig_max_calls ?? 15 : config.ig_max_calls ?? 15;
|
|
724
|
+
const influencers = [];
|
|
725
|
+
for (const [channelUrl, authorName] of creatorEntries) {
|
|
726
|
+
// Step 3: Get channel details
|
|
727
|
+
const channelHandle = channelUrl.replace("https://www.youtube.com", "");
|
|
728
|
+
const channel = await ytExecute("channel", { channel_url: channelUrl });
|
|
729
|
+
// Step 4: Get recent videos
|
|
730
|
+
const recentSearch = await ytExecute("search", { query: `${authorName} ${niche}` });
|
|
731
|
+
const recentVideos = (recentSearch?.results || []).slice(0, 3);
|
|
732
|
+
// Calculate engagement from recent videos
|
|
733
|
+
const videoDetails = [];
|
|
734
|
+
for (const v of recentVideos) {
|
|
735
|
+
if (v.url) {
|
|
736
|
+
const vd = await ytExecute("video", { url: v.url });
|
|
737
|
+
if (vd && !vd.error) {
|
|
738
|
+
videoDetails.push({
|
|
739
|
+
title: vd.title || v.title,
|
|
740
|
+
views: vd.views,
|
|
741
|
+
likes: vd.likes,
|
|
742
|
+
published: vd.published,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const avgViews = videoDetails.length > 0
|
|
748
|
+
? videoDetails.reduce((s, v) => s + (v.views || 0), 0) / videoDetails.length
|
|
749
|
+
: undefined;
|
|
750
|
+
const avgLikes = videoDetails.length > 0
|
|
751
|
+
? videoDetails.reduce((s, v) => s + (v.likes || 0), 0) / videoDetails.length
|
|
752
|
+
: undefined;
|
|
753
|
+
const subscribers = channel?.subscribers;
|
|
754
|
+
const description = channel?.description || "";
|
|
755
|
+
const contactInfo = extractContactInfo(description);
|
|
756
|
+
// Step 5: Parse IG handles from description
|
|
757
|
+
const igHandles = parseIgHandles(description);
|
|
758
|
+
let igFollowers;
|
|
759
|
+
let igUrl;
|
|
760
|
+
// Step 6: Brave Search for IG data
|
|
761
|
+
if (igHandles.length > 0) {
|
|
762
|
+
const braveResult = await braveSearch(`"${igHandles[0]}" instagram`, 3);
|
|
763
|
+
if (braveResult?.web?.results) {
|
|
764
|
+
for (const r of braveResult.web.results) {
|
|
765
|
+
const f = parseFollowersFromSnippet(r.description || "");
|
|
766
|
+
if (f) {
|
|
767
|
+
igFollowers = f;
|
|
768
|
+
igUrl = `https://instagram.com/${igHandles[0]}`;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Step 7: IG API enrichment for top candidates
|
|
774
|
+
if (!igFollowers && process.env.IG_SESSION_ID) {
|
|
775
|
+
const igData = await igApiCall(`api/v1/users/web_profile_info/?username=${igHandles[0]}`, igCallsUsed, config.ig_max_calls ?? 15);
|
|
776
|
+
if (igData?.data?.user) {
|
|
777
|
+
igFollowers = igData.data.user.edge_followed_by?.count;
|
|
778
|
+
igUrl = `https://instagram.com/${igHandles[0]}`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const platformCount = 1 + (igFollowers ? 1 : 0); // YouTube + IG if found
|
|
783
|
+
const engagementRate = calcEngagement(avgViews, avgLikes, subscribers);
|
|
784
|
+
const nicheMatchPct = calcNicheMatch([description, ...videoDetails.map(v => v.title)], nicheKeywords);
|
|
785
|
+
const postingFreq = estimateFrequency(videoDetails);
|
|
786
|
+
const profile = {
|
|
787
|
+
handle: channelHandle || authorName,
|
|
788
|
+
name: channel?.name || authorName,
|
|
789
|
+
youtube_url: channelUrl,
|
|
790
|
+
instagram_url: igUrl,
|
|
791
|
+
subscribers,
|
|
792
|
+
ig_followers: igFollowers,
|
|
793
|
+
description: description.substring(0, 300),
|
|
794
|
+
engagement_rate: Math.round(engagementRate * 10) / 10,
|
|
795
|
+
avg_views: avgViews ? Math.round(avgViews) : undefined,
|
|
796
|
+
avg_likes: avgLikes ? Math.round(avgLikes) : undefined,
|
|
797
|
+
recent_videos: videoDetails,
|
|
798
|
+
email: contactInfo.email,
|
|
799
|
+
website: contactInfo.website,
|
|
800
|
+
has_business_contact: contactInfo.hasBusiness,
|
|
801
|
+
has_collab_signals: contactInfo.hasCollab,
|
|
802
|
+
niche_match_pct: nicheMatchPct,
|
|
803
|
+
posting_frequency: postingFreq,
|
|
804
|
+
platform_count: platformCount,
|
|
805
|
+
scores: { reach: 0, conversion: 0, partnership: 0 },
|
|
806
|
+
tier: "UNRANKED",
|
|
807
|
+
};
|
|
808
|
+
profile.scores = calculateScores(profile);
|
|
809
|
+
profile.tier = classifyTier(profile.scores, input.threshold ?? config.threshold ?? 60);
|
|
810
|
+
influencers.push(profile);
|
|
811
|
+
}
|
|
812
|
+
return influencers;
|
|
813
|
+
}
|
|
814
|
+
// --- Workflow: hashtag_scout ---
|
|
815
|
+
async function runHashtagScout(config, input) {
|
|
816
|
+
const { parseCompactNumber } = await import("../social/parsers.js");
|
|
817
|
+
const niche = input.niche || config.niche;
|
|
818
|
+
const nicheKeywords = niche.split(/[\s,]+/).filter(Boolean);
|
|
819
|
+
const hashtags = input.hashtags || [niche.replace(/\s+/g, "")];
|
|
820
|
+
const igCallsUsed = { count: 0 };
|
|
821
|
+
const igMaxCalls = config.ig_max_calls ?? 15;
|
|
822
|
+
const handles = new Map();
|
|
823
|
+
if (process.env.IG_SESSION_ID) {
|
|
824
|
+
// IG hashtag API
|
|
825
|
+
for (const tag of hashtags) {
|
|
826
|
+
const data = await igApiCall(`api/v1/tags/${tag}/sections/`, igCallsUsed, igMaxCalls);
|
|
827
|
+
if (data?.sections) {
|
|
828
|
+
for (const section of data.sections) {
|
|
829
|
+
const medias = section?.layout_content?.medias || [];
|
|
830
|
+
for (const m of medias) {
|
|
831
|
+
const username = m?.media?.user?.username;
|
|
832
|
+
if (username && !handles.has(username)) {
|
|
833
|
+
handles.set(username, { source: `#${tag}` });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Fallback: Brave Search for hashtag discovery
|
|
841
|
+
if (handles.size === 0) {
|
|
842
|
+
for (const tag of hashtags) {
|
|
843
|
+
const data = await braveSearch(`site:instagram.com #${tag} ${niche}`, 10);
|
|
844
|
+
if (data?.web?.results) {
|
|
845
|
+
for (const r of data.web.results) {
|
|
846
|
+
const urlMatch = r.url?.match(/instagram\.com\/([\w.]+)/);
|
|
847
|
+
if (urlMatch && urlMatch[1] !== "p" && urlMatch[1] !== "explore") {
|
|
848
|
+
handles.set(urlMatch[1], { source: `#${tag}` });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Take top 10
|
|
855
|
+
const topHandles = Array.from(handles.entries()).slice(0, 10);
|
|
856
|
+
const influencers = [];
|
|
857
|
+
for (const [handle, meta] of topHandles) {
|
|
858
|
+
// Brave Search cross-ref for followers
|
|
859
|
+
let igFollowers;
|
|
860
|
+
const braveResult = await braveSearch(`"${handle}" instagram followers`, 3);
|
|
861
|
+
if (braveResult?.web?.results) {
|
|
862
|
+
for (const r of braveResult.web.results) {
|
|
863
|
+
const f = parseFollowersFromSnippet(r.description || "");
|
|
864
|
+
if (f) {
|
|
865
|
+
igFollowers = f;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// IG API enrichment
|
|
871
|
+
if (!igFollowers && process.env.IG_SESSION_ID) {
|
|
872
|
+
const igData = await igApiCall(`api/v1/users/web_profile_info/?username=${handle}`, igCallsUsed, igMaxCalls);
|
|
873
|
+
if (igData?.data?.user) {
|
|
874
|
+
igFollowers = igData.data.user.edge_followed_by?.count;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// YouTube verification
|
|
878
|
+
let ytChannel = null;
|
|
879
|
+
const ytSearch = await ytExecute("search", { query: handle });
|
|
880
|
+
if (ytSearch?.results?.[0]?.author_url) {
|
|
881
|
+
ytChannel = await ytExecute("channel", { channel_url: ytSearch.results[0].author_url });
|
|
882
|
+
}
|
|
883
|
+
const subscribers = ytChannel?.subscribers;
|
|
884
|
+
const description = ytChannel?.description || "";
|
|
885
|
+
const contactInfo = extractContactInfo(description);
|
|
886
|
+
const platformCount = (igFollowers ? 1 : 0) + (subscribers ? 1 : 0) || 1;
|
|
887
|
+
const profile = {
|
|
888
|
+
handle: `@${handle}`,
|
|
889
|
+
name: ytChannel?.name || handle,
|
|
890
|
+
youtube_url: ytChannel?.url,
|
|
891
|
+
instagram_url: `https://instagram.com/${handle}`,
|
|
892
|
+
subscribers,
|
|
893
|
+
ig_followers: igFollowers,
|
|
894
|
+
description: description.substring(0, 300),
|
|
895
|
+
engagement_rate: 0,
|
|
896
|
+
niche_match_pct: calcNicheMatch([description, handle], nicheKeywords),
|
|
897
|
+
posting_frequency: "monthly",
|
|
898
|
+
platform_count: platformCount,
|
|
899
|
+
email: contactInfo.email,
|
|
900
|
+
website: contactInfo.website,
|
|
901
|
+
has_business_contact: contactInfo.hasBusiness,
|
|
902
|
+
has_collab_signals: contactInfo.hasCollab,
|
|
903
|
+
scores: { reach: 0, conversion: 0, partnership: 0 },
|
|
904
|
+
tier: "UNRANKED",
|
|
905
|
+
};
|
|
906
|
+
profile.scores = calculateScores(profile);
|
|
907
|
+
profile.tier = classifyTier(profile.scores, input.threshold ?? config.threshold ?? 60);
|
|
908
|
+
influencers.push(profile);
|
|
909
|
+
}
|
|
910
|
+
return influencers;
|
|
911
|
+
}
|
|
912
|
+
// --- Workflow: competitor_spy ---
|
|
913
|
+
async function runCompetitorSpy(config, input) {
|
|
914
|
+
const { parseCompactNumber } = await import("../social/parsers.js");
|
|
915
|
+
const niche = input.niche || config.niche;
|
|
916
|
+
const nicheKeywords = niche.split(/[\s,]+/).filter(Boolean);
|
|
917
|
+
const competitor = input.competitor || niche;
|
|
918
|
+
// Step 1: Brave Search for sponsored/collab content
|
|
919
|
+
const braveQueries = [
|
|
920
|
+
`"${competitor}" sponsored site:youtube.com`,
|
|
921
|
+
`"${competitor}" collab site:youtube.com`,
|
|
922
|
+
];
|
|
923
|
+
const creatorUrls = new Map();
|
|
924
|
+
for (const q of braveQueries) {
|
|
925
|
+
const data = await braveSearch(q, 10);
|
|
926
|
+
if (data?.web?.results) {
|
|
927
|
+
for (const r of data.web.results) {
|
|
928
|
+
// Extract channel from YouTube video URLs
|
|
929
|
+
if (r.url?.includes("youtube.com/watch")) {
|
|
930
|
+
// Use the title to extract channel name if available
|
|
931
|
+
const channelMatch = r.description?.match(/by\s+([\w\s]+)/i);
|
|
932
|
+
if (channelMatch) {
|
|
933
|
+
creatorUrls.set(r.url, channelMatch[1].trim());
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
creatorUrls.set(r.url, r.title || "Unknown");
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Step 2: YouTube search for reviews/unboxings
|
|
943
|
+
const ytQueries = [`${competitor} review`, `${competitor} unboxing`];
|
|
944
|
+
for (const q of ytQueries) {
|
|
945
|
+
const data = await ytExecute("search", { query: q });
|
|
946
|
+
if (data?.results) {
|
|
947
|
+
for (const v of data.results) {
|
|
948
|
+
if (v.author_url && !creatorUrls.has(v.author_url)) {
|
|
949
|
+
creatorUrls.set(v.author_url, v.author);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Deduplicate by channel URL
|
|
955
|
+
const uniqueChannels = new Map();
|
|
956
|
+
for (const [url, name] of creatorUrls) {
|
|
957
|
+
// If it's a video URL, we need to get the channel from video details
|
|
958
|
+
if (url.includes("youtube.com/watch")) {
|
|
959
|
+
const vd = await ytExecute("video", { url });
|
|
960
|
+
if (vd?.author_url && !uniqueChannels.has(vd.author_url)) {
|
|
961
|
+
uniqueChannels.set(vd.author_url, vd.author || name);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
else if (url.includes("youtube.com/@") || url.includes("youtube.com/c/") || url.includes("youtube.com/channel/")) {
|
|
965
|
+
if (!uniqueChannels.has(url))
|
|
966
|
+
uniqueChannels.set(url, name);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const topCreators = Array.from(uniqueChannels.entries()).slice(0, 10);
|
|
970
|
+
const influencers = [];
|
|
971
|
+
for (const [channelUrl, authorName] of topCreators) {
|
|
972
|
+
const channel = await ytExecute("channel", { channel_url: channelUrl });
|
|
973
|
+
const recentSearch = await ytExecute("search", { query: `${authorName} ${competitor}` });
|
|
974
|
+
const recentVideos = (recentSearch?.results || []).slice(0, 3);
|
|
975
|
+
const videoDetails = [];
|
|
976
|
+
for (const v of recentVideos) {
|
|
977
|
+
if (v.url) {
|
|
978
|
+
const vd = await ytExecute("video", { url: v.url });
|
|
979
|
+
if (vd && !vd.error) {
|
|
980
|
+
videoDetails.push({ title: vd.title || v.title, views: vd.views, likes: vd.likes, published: vd.published });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const avgViews = videoDetails.length > 0
|
|
985
|
+
? videoDetails.reduce((s, v) => s + (v.views || 0), 0) / videoDetails.length
|
|
986
|
+
: undefined;
|
|
987
|
+
const avgLikes = videoDetails.length > 0
|
|
988
|
+
? videoDetails.reduce((s, v) => s + (v.likes || 0), 0) / videoDetails.length
|
|
989
|
+
: undefined;
|
|
990
|
+
const subscribers = channel?.subscribers;
|
|
991
|
+
const description = channel?.description || "";
|
|
992
|
+
const contactInfo = extractContactInfo(description);
|
|
993
|
+
const igHandles = parseIgHandles(description);
|
|
994
|
+
let igFollowers;
|
|
995
|
+
let igUrl;
|
|
996
|
+
if (igHandles.length > 0) {
|
|
997
|
+
const braveResult = await braveSearch(`"${igHandles[0]}" instagram`, 3);
|
|
998
|
+
if (braveResult?.web?.results) {
|
|
999
|
+
for (const r of braveResult.web.results) {
|
|
1000
|
+
const f = parseFollowersFromSnippet(r.description || "");
|
|
1001
|
+
if (f) {
|
|
1002
|
+
igFollowers = f;
|
|
1003
|
+
igUrl = `https://instagram.com/${igHandles[0]}`;
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const platformCount = 1 + (igFollowers ? 1 : 0);
|
|
1010
|
+
const engagementRate = calcEngagement(avgViews, avgLikes, subscribers);
|
|
1011
|
+
const nicheMatchPct = calcNicheMatch([description, ...videoDetails.map(v => v.title)], nicheKeywords);
|
|
1012
|
+
const profile = {
|
|
1013
|
+
handle: channelUrl.replace("https://www.youtube.com", "") || authorName,
|
|
1014
|
+
name: channel?.name || authorName,
|
|
1015
|
+
youtube_url: channelUrl,
|
|
1016
|
+
instagram_url: igUrl,
|
|
1017
|
+
subscribers,
|
|
1018
|
+
ig_followers: igFollowers,
|
|
1019
|
+
description: description.substring(0, 300),
|
|
1020
|
+
engagement_rate: Math.round(engagementRate * 10) / 10,
|
|
1021
|
+
avg_views: avgViews ? Math.round(avgViews) : undefined,
|
|
1022
|
+
avg_likes: avgLikes ? Math.round(avgLikes) : undefined,
|
|
1023
|
+
recent_videos: videoDetails,
|
|
1024
|
+
email: contactInfo.email,
|
|
1025
|
+
website: contactInfo.website,
|
|
1026
|
+
has_business_contact: contactInfo.hasBusiness,
|
|
1027
|
+
has_collab_signals: contactInfo.hasCollab,
|
|
1028
|
+
niche_match_pct: nicheMatchPct,
|
|
1029
|
+
posting_frequency: estimateFrequency(videoDetails),
|
|
1030
|
+
platform_count: platformCount,
|
|
1031
|
+
scores: { reach: 0, conversion: 0, partnership: 0 },
|
|
1032
|
+
tier: "UNRANKED",
|
|
1033
|
+
};
|
|
1034
|
+
profile.scores = calculateScores(profile);
|
|
1035
|
+
profile.tier = classifyTier(profile.scores, input.threshold ?? config.threshold ?? 60);
|
|
1036
|
+
influencers.push(profile);
|
|
1037
|
+
}
|
|
1038
|
+
return influencers;
|
|
1039
|
+
}
|
|
1040
|
+
// --- Workflow: content_scout ---
|
|
1041
|
+
async function runContentScout(config, input) {
|
|
1042
|
+
const niche = input.niche || config.niche;
|
|
1043
|
+
const location = input.location || "";
|
|
1044
|
+
const nicheKeywords = niche.split(/[\s,]+/).filter(Boolean);
|
|
1045
|
+
const minSubs = input.min_subscribers ?? 300;
|
|
1046
|
+
const maxSubs = input.max_subscribers ?? 100_000;
|
|
1047
|
+
const igMaxCalls = config.ig_max_calls ?? 5;
|
|
1048
|
+
// --- Phase 1: DISCOVERY (Brave + YouTube) ---
|
|
1049
|
+
const channelUrls = new Map(); // url → name
|
|
1050
|
+
// Brave queries
|
|
1051
|
+
const braveQueries = [
|
|
1052
|
+
`site:youtube.com "${niche}" vlog|review|guide`,
|
|
1053
|
+
location ? `site:youtube.com "${niche}" "${location}"` : `site:youtube.com "${niche}" tips`,
|
|
1054
|
+
`site:youtube.com "${niche}" collab|partner small creator`,
|
|
1055
|
+
`"underrated channel" "${niche}" youtube`,
|
|
1056
|
+
`site:youtube.com "${niche}" -"million subscribers"`,
|
|
1057
|
+
];
|
|
1058
|
+
const bravePromises = braveQueries.map(q => braveSearch(q, 10));
|
|
1059
|
+
const braveResults = await Promise.all(bravePromises);
|
|
1060
|
+
for (const data of braveResults) {
|
|
1061
|
+
const results = data?.web?.results || [];
|
|
1062
|
+
for (const r of results) {
|
|
1063
|
+
const url = r.url || "";
|
|
1064
|
+
// Extract channel URLs directly
|
|
1065
|
+
const channelMatch = url.match(/youtube\.com\/(@[\w.-]+|c\/[\w.-]+|channel\/[\w-]+)/);
|
|
1066
|
+
if (channelMatch && !channelUrls.has(channelMatch[0])) {
|
|
1067
|
+
channelUrls.set(`https://www.${channelMatch[0]}`, r.title || "");
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
// Video URLs — we'll resolve to channels in phase 2
|
|
1071
|
+
if (url.includes("youtube.com/watch")) {
|
|
1072
|
+
channelUrls.set(url, r.title || "");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// YouTube searches (sort by date for fresh content)
|
|
1077
|
+
const ytQueries = [
|
|
1078
|
+
`${niche} ${location || ""}`.trim(),
|
|
1079
|
+
`${niche} review`,
|
|
1080
|
+
`${niche} vlog 2026`,
|
|
1081
|
+
];
|
|
1082
|
+
for (const q of ytQueries) {
|
|
1083
|
+
const data = await ytExecute("search", { query: q, sort: "date" });
|
|
1084
|
+
if (data?.results) {
|
|
1085
|
+
for (const v of data.results) {
|
|
1086
|
+
if (v.author_url && !channelUrls.has(v.author_url)) {
|
|
1087
|
+
channelUrls.set(v.author_url, v.author || "");
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
// Resolve video URLs to channel URLs
|
|
1093
|
+
const resolvedChannels = new Map();
|
|
1094
|
+
for (const [url, name] of channelUrls) {
|
|
1095
|
+
if (url.includes("youtube.com/watch")) {
|
|
1096
|
+
const vd = await ytExecute("video", { url });
|
|
1097
|
+
if (vd?.author_url && !resolvedChannels.has(vd.author_url)) {
|
|
1098
|
+
resolvedChannels.set(vd.author_url, vd.author || name);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
if (!resolvedChannels.has(url))
|
|
1103
|
+
resolvedChannels.set(url, name);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// --- Phase 2: SIZE GATE ---
|
|
1107
|
+
const qualifiedChannels = [];
|
|
1108
|
+
const channelEntries = Array.from(resolvedChannels.entries()).slice(0, 40);
|
|
1109
|
+
for (const [channelUrl, authorName] of channelEntries) {
|
|
1110
|
+
const channel = await ytExecute("channel", { channel_url: channelUrl });
|
|
1111
|
+
if (!channel || channel.error)
|
|
1112
|
+
continue;
|
|
1113
|
+
const subs = channel.subscribers || 0;
|
|
1114
|
+
if (subs < minSubs || subs > maxSubs)
|
|
1115
|
+
continue;
|
|
1116
|
+
qualifiedChannels.push({
|
|
1117
|
+
url: channelUrl,
|
|
1118
|
+
name: channel.name || authorName,
|
|
1119
|
+
subscribers: subs,
|
|
1120
|
+
description: channel.description || "",
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// --- Phase 3: CONTENT DEPTH ---
|
|
1124
|
+
const influencers = [];
|
|
1125
|
+
for (const ch of qualifiedChannels) {
|
|
1126
|
+
// Get 5 recent videos
|
|
1127
|
+
const recentSearch = await ytExecute("search", { query: ch.name, sort: "date" });
|
|
1128
|
+
const recentResults = (recentSearch?.results || []).slice(0, 5);
|
|
1129
|
+
const videoDetails = [];
|
|
1130
|
+
for (const v of recentResults) {
|
|
1131
|
+
if (v.url) {
|
|
1132
|
+
const vd = await ytExecute("video", { url: v.url });
|
|
1133
|
+
if (vd && !vd.error) {
|
|
1134
|
+
videoDetails.push({
|
|
1135
|
+
title: vd.title || v.title,
|
|
1136
|
+
views: vd.views,
|
|
1137
|
+
likes: vd.likes,
|
|
1138
|
+
published: vd.published,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (videoDetails.length === 0)
|
|
1144
|
+
continue;
|
|
1145
|
+
// Calculate content quality metrics
|
|
1146
|
+
const views = videoDetails.map(v => v.views || 0).filter(v => v > 0);
|
|
1147
|
+
const likes = videoDetails.map(v => v.likes || 0).filter(v => v > 0);
|
|
1148
|
+
const avgViews = views.length > 0 ? views.reduce((a, b) => a + b, 0) / views.length : 0;
|
|
1149
|
+
const avgLikes = likes.length > 0 ? likes.reduce((a, b) => a + b, 0) / likes.length : 0;
|
|
1150
|
+
const viewsPerSub = ch.subscribers > 0 ? avgViews / ch.subscribers : 0;
|
|
1151
|
+
const likesPerView = avgViews > 0 ? avgLikes / avgViews : 0;
|
|
1152
|
+
const viewConsistency = calcViewConsistency(views);
|
|
1153
|
+
const nicheDepth = calcNicheDepth(videoDetails.map(v => v.title), nicheKeywords);
|
|
1154
|
+
// Skip GENERALIST channels
|
|
1155
|
+
if (nicheDepth === "GENERALIST")
|
|
1156
|
+
continue;
|
|
1157
|
+
const contactInfo = extractContactInfo(ch.description);
|
|
1158
|
+
const igHandles = parseIgHandles(ch.description);
|
|
1159
|
+
const engagementRate = calcEngagement(avgViews, avgLikes, ch.subscribers);
|
|
1160
|
+
const nicheMatchPct = calcNicheMatch([ch.description, ...videoDetails.map(v => v.title)], nicheKeywords);
|
|
1161
|
+
// Brave Search for IG followers
|
|
1162
|
+
let igFollowers;
|
|
1163
|
+
let igUrl;
|
|
1164
|
+
if (igHandles.length > 0) {
|
|
1165
|
+
const braveResult = await braveSearch(`"${igHandles[0]}" instagram`, 3);
|
|
1166
|
+
if (braveResult?.web?.results) {
|
|
1167
|
+
for (const r of braveResult.web.results) {
|
|
1168
|
+
const f = parseFollowersFromSnippet(r.description || "");
|
|
1169
|
+
if (f) {
|
|
1170
|
+
igFollowers = f;
|
|
1171
|
+
igUrl = `https://instagram.com/${igHandles[0]}`;
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const platformCount = 1 + (igFollowers ? 1 : 0);
|
|
1178
|
+
const profile = {
|
|
1179
|
+
handle: ch.url.replace("https://www.youtube.com", "") || ch.name,
|
|
1180
|
+
name: ch.name,
|
|
1181
|
+
youtube_url: ch.url,
|
|
1182
|
+
instagram_url: igUrl,
|
|
1183
|
+
subscribers: ch.subscribers,
|
|
1184
|
+
ig_followers: igFollowers,
|
|
1185
|
+
description: ch.description.substring(0, 300),
|
|
1186
|
+
engagement_rate: Math.round(engagementRate * 10) / 10,
|
|
1187
|
+
avg_views: avgViews ? Math.round(avgViews) : undefined,
|
|
1188
|
+
avg_likes: avgLikes ? Math.round(avgLikes) : undefined,
|
|
1189
|
+
recent_videos: videoDetails,
|
|
1190
|
+
email: contactInfo.email,
|
|
1191
|
+
website: contactInfo.website,
|
|
1192
|
+
has_business_contact: contactInfo.hasBusiness,
|
|
1193
|
+
has_collab_signals: contactInfo.hasCollab,
|
|
1194
|
+
niche_match_pct: nicheMatchPct,
|
|
1195
|
+
posting_frequency: estimateFrequency(videoDetails),
|
|
1196
|
+
niche_depth: nicheDepth,
|
|
1197
|
+
views_per_sub: Math.round(viewsPerSub * 1000) / 1000,
|
|
1198
|
+
likes_per_view: Math.round(likesPerView * 1000) / 1000,
|
|
1199
|
+
view_consistency: viewConsistency,
|
|
1200
|
+
platform_count: platformCount,
|
|
1201
|
+
scores: { reach: 0, conversion: 0, partnership: 0 },
|
|
1202
|
+
tier: "UNRANKED",
|
|
1203
|
+
};
|
|
1204
|
+
// Custom scoring for content_scout:
|
|
1205
|
+
// reach ← Content Quality Score
|
|
1206
|
+
// conversion ← Partnership Readiness
|
|
1207
|
+
// partnership ← Reach + Authenticity
|
|
1208
|
+
const contentScore = calcContentScore(profile);
|
|
1209
|
+
// Partnership readiness: email, collab signals, business account, posting frequency
|
|
1210
|
+
let partnershipReady = 0;
|
|
1211
|
+
if (contactInfo.email)
|
|
1212
|
+
partnershipReady += 30;
|
|
1213
|
+
if (contactInfo.hasCollab)
|
|
1214
|
+
partnershipReady += 20;
|
|
1215
|
+
if (contactInfo.hasBusiness)
|
|
1216
|
+
partnershipReady += 15;
|
|
1217
|
+
if (profile.posting_frequency === "weekly")
|
|
1218
|
+
partnershipReady += 20;
|
|
1219
|
+
else if (profile.posting_frequency === "biweekly")
|
|
1220
|
+
partnershipReady += 10;
|
|
1221
|
+
if (igFollowers)
|
|
1222
|
+
partnershipReady += 15;
|
|
1223
|
+
// Reach + Authenticity: audience size, multi-platform, views/subs sanity
|
|
1224
|
+
let reachAuth = 0;
|
|
1225
|
+
if (ch.subscribers >= 10_000)
|
|
1226
|
+
reachAuth += 25;
|
|
1227
|
+
else if (ch.subscribers >= 1_000)
|
|
1228
|
+
reachAuth += 15;
|
|
1229
|
+
else
|
|
1230
|
+
reachAuth += 5;
|
|
1231
|
+
if (platformCount >= 2)
|
|
1232
|
+
reachAuth += 20;
|
|
1233
|
+
if (viewsPerSub >= 0.15)
|
|
1234
|
+
reachAuth += 25;
|
|
1235
|
+
else if (viewsPerSub >= 0.05)
|
|
1236
|
+
reachAuth += 15;
|
|
1237
|
+
reachAuth += Math.round(viewConsistency / 100 * 30);
|
|
1238
|
+
profile.scores = {
|
|
1239
|
+
reach: Math.min(100, contentScore),
|
|
1240
|
+
conversion: Math.min(100, partnershipReady),
|
|
1241
|
+
partnership: Math.min(100, reachAuth),
|
|
1242
|
+
};
|
|
1243
|
+
profile.tier = classifyTier(profile.scores, input.threshold ?? config.threshold ?? 60);
|
|
1244
|
+
influencers.push(profile);
|
|
1245
|
+
}
|
|
1246
|
+
// --- Phase 4: IG ENRICHMENT (top 5 only) ---
|
|
1247
|
+
const sorted = [...influencers].sort((a, b) => {
|
|
1248
|
+
const scoreA = (a.scores.reach + a.scores.conversion + a.scores.partnership) / 3;
|
|
1249
|
+
const scoreB = (b.scores.reach + b.scores.conversion + b.scores.partnership) / 3;
|
|
1250
|
+
return scoreB - scoreA;
|
|
1251
|
+
});
|
|
1252
|
+
let igCallsUsed = 0;
|
|
1253
|
+
for (const profile of sorted) {
|
|
1254
|
+
if (igCallsUsed >= igMaxCalls)
|
|
1255
|
+
break;
|
|
1256
|
+
const igHandle = profile.instagram_url?.replace("https://instagram.com/", "");
|
|
1257
|
+
if (!igHandle)
|
|
1258
|
+
continue;
|
|
1259
|
+
try {
|
|
1260
|
+
const igData = await igExecute("profile", { username: igHandle });
|
|
1261
|
+
if (igData && !igData.error) {
|
|
1262
|
+
if (igData.followers !== undefined)
|
|
1263
|
+
profile.ig_followers = igData.followers;
|
|
1264
|
+
if (igData.engagement_rate !== undefined)
|
|
1265
|
+
profile.ig_engagement_rate = igData.engagement_rate;
|
|
1266
|
+
if (igData.posts_count !== undefined)
|
|
1267
|
+
profile.ig_posts_count = igData.posts_count;
|
|
1268
|
+
if (igData.business_email)
|
|
1269
|
+
profile.email = profile.email || igData.business_email;
|
|
1270
|
+
profile.platform_count = Math.max(profile.platform_count, 2);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
// IG enrichment is bonus — skip on failure
|
|
1275
|
+
}
|
|
1276
|
+
igCallsUsed++;
|
|
1277
|
+
}
|
|
1278
|
+
return influencers;
|
|
1279
|
+
}
|
|
1280
|
+
// --- Main influencer discovery dispatcher ---
|
|
1281
|
+
async function runInfluencerDiscovery(config, input) {
|
|
1282
|
+
let influencers;
|
|
1283
|
+
switch (config.workflow) {
|
|
1284
|
+
case "niche_discovery":
|
|
1285
|
+
influencers = await runNicheDiscovery(config, input);
|
|
1286
|
+
break;
|
|
1287
|
+
case "hashtag_scout":
|
|
1288
|
+
influencers = await runHashtagScout(config, input);
|
|
1289
|
+
break;
|
|
1290
|
+
case "competitor_spy":
|
|
1291
|
+
influencers = await runCompetitorSpy(config, input);
|
|
1292
|
+
break;
|
|
1293
|
+
case "content_scout":
|
|
1294
|
+
influencers = await runContentScout(config, input);
|
|
1295
|
+
break;
|
|
1296
|
+
default:
|
|
1297
|
+
return toolResult({ error: `Unknown influencer discovery workflow: ${config.workflow}` });
|
|
1298
|
+
}
|
|
1299
|
+
const outputFormat = input.output_format ?? config.output_format ?? "json";
|
|
1300
|
+
const threshold = input.threshold ?? config.threshold ?? 60;
|
|
1301
|
+
const data = formatInfluencerOutput(influencers, outputFormat, {
|
|
1302
|
+
workflow: config.workflow,
|
|
1303
|
+
niche: input.niche || config.niche,
|
|
1304
|
+
threshold,
|
|
1305
|
+
});
|
|
1306
|
+
if (typeof data === "string") {
|
|
1307
|
+
return { content: [{ type: "text", text: data }] };
|
|
1308
|
+
}
|
|
1309
|
+
return toolResult(data);
|
|
1310
|
+
}
|
|
310
1311
|
// --- Main execute ---
|
|
311
1312
|
export async function execute(input) {
|
|
312
1313
|
// Load with recipe fallback
|
|
313
1314
|
const config = await manager.loadWithRecipes(input.name);
|
|
314
1315
|
if (!config) {
|
|
315
1316
|
const skills = await manager.listAll();
|
|
316
|
-
return
|
|
1317
|
+
return toolResult({
|
|
317
1318
|
error: `Skill '${input.name}' not found.`,
|
|
318
1319
|
available_skills: skills.map((s) => ({
|
|
319
1320
|
name: s.name,
|
|
@@ -334,8 +1335,10 @@ export async function execute(input) {
|
|
|
334
1335
|
return runScrape(config, url, input);
|
|
335
1336
|
case "monitor_websocket":
|
|
336
1337
|
return runMonitorWebsocket(config, url, input);
|
|
1338
|
+
case "influencer_discovery":
|
|
1339
|
+
return runInfluencerDiscovery(config, input);
|
|
337
1340
|
default:
|
|
338
|
-
return
|
|
1341
|
+
return toolResult({ error: `Unknown skill tool type: ${tool}` });
|
|
339
1342
|
}
|
|
340
1343
|
}
|
|
341
1344
|
//# sourceMappingURL=run-skill.js.map
|