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.
Files changed (75) hide show
  1. package/README.md +41 -77
  2. package/dist/cli-tui.d.ts +1 -1
  3. package/dist/cli-tui.js +1 -1
  4. package/dist/cli.js +1 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config.d.ts +0 -11
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +0 -18
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.d.ts +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/formatters.d.ts +2 -2
  13. package/dist/formatters.js +2 -2
  14. package/dist/index.js +6 -28
  15. package/dist/index.js.map +1 -1
  16. package/dist/recipes/index.d.ts.map +1 -1
  17. package/dist/recipes/index.js +8 -0
  18. package/dist/recipes/index.js.map +1 -1
  19. package/dist/recipes/influencer-competitor-spy.json +14 -0
  20. package/dist/recipes/influencer-content-scout.json +14 -0
  21. package/dist/recipes/influencer-hashtag-scout.json +14 -0
  22. package/dist/recipes/influencer-niche-discovery.json +14 -0
  23. package/dist/skills/manager.d.ts +11 -2
  24. package/dist/skills/manager.d.ts.map +1 -1
  25. package/dist/skills/manager.js.map +1 -1
  26. package/dist/social/ai-fallback.d.ts +22 -0
  27. package/dist/social/ai-fallback.d.ts.map +1 -0
  28. package/dist/social/ai-fallback.js +156 -0
  29. package/dist/social/ai-fallback.js.map +1 -0
  30. package/dist/social/parsers.d.ts +28 -0
  31. package/dist/social/parsers.d.ts.map +1 -0
  32. package/dist/social/parsers.js +146 -0
  33. package/dist/social/parsers.js.map +1 -0
  34. package/dist/social/types.d.ts +86 -0
  35. package/dist/social/types.d.ts.map +1 -0
  36. package/dist/social/types.js +5 -0
  37. package/dist/social/types.js.map +1 -0
  38. package/dist/social/whisper.d.ts +29 -0
  39. package/dist/social/whisper.d.ts.map +1 -0
  40. package/dist/social/whisper.js +88 -0
  41. package/dist/social/whisper.js.map +1 -0
  42. package/dist/tools/index.d.ts.map +1 -1
  43. package/dist/tools/index.js +7 -0
  44. package/dist/tools/index.js.map +1 -1
  45. package/dist/tools/instagram.d.ts +51 -0
  46. package/dist/tools/instagram.d.ts.map +1 -0
  47. package/dist/tools/instagram.js +462 -0
  48. package/dist/tools/instagram.js.map +1 -0
  49. package/dist/tools/interact.d.ts +4 -4
  50. package/dist/tools/interact.js +1 -1
  51. package/dist/tools/interact.js.map +1 -1
  52. package/dist/tools/manifest.d.ts +0 -1
  53. package/dist/tools/manifest.d.ts.map +1 -1
  54. package/dist/tools/manifest.js +17 -1
  55. package/dist/tools/manifest.js.map +1 -1
  56. package/dist/tools/reddit.d.ts +36 -0
  57. package/dist/tools/reddit.d.ts.map +1 -0
  58. package/dist/tools/reddit.js +190 -0
  59. package/dist/tools/reddit.js.map +1 -0
  60. package/dist/tools/run-skill.d.ts +24 -0
  61. package/dist/tools/run-skill.d.ts.map +1 -1
  62. package/dist/tools/run-skill.js +1015 -12
  63. package/dist/tools/run-skill.js.map +1 -1
  64. package/dist/tools/tiktok.d.ts +30 -0
  65. package/dist/tools/tiktok.d.ts.map +1 -0
  66. package/dist/tools/tiktok.js +246 -0
  67. package/dist/tools/tiktok.js.map +1 -0
  68. package/dist/tools/youtube.d.ts +33 -0
  69. package/dist/tools/youtube.d.ts.map +1 -0
  70. package/dist/tools/youtube.js +489 -0
  71. package/dist/tools/youtube.js.map +1 -0
  72. package/dist/utils/fetcher.d.ts.map +1 -1
  73. package/dist/utils/fetcher.js +1 -3
  74. package/dist/utils/fetcher.js.map +1 -1
  75. package/package.json +3 -9
@@ -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 mcpResult(data) {
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 mcpResult({
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 mcpResult({
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 mcpResult({
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 mcpResult({ error: "Could not extract article content", url: result.url });
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 mcpResult({
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 mcpResult({
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 mcpResult({
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 mcpResult({
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 mcpResult({
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 mcpResult({
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 mcpResult({ error: `Unknown skill tool type: ${tool}` });
1341
+ return toolResult({ error: `Unknown skill tool type: ${tool}` });
339
1342
  }
340
1343
  }
341
1344
  //# sourceMappingURL=run-skill.js.map