imperium-crawl 2.1.0 → 2.3.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 (88) hide show
  1. package/README.md +59 -89
  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 +2 -0
  18. package/dist/recipes/index.js.map +1 -1
  19. package/dist/recipes/influencer-content-scout.json +14 -0
  20. package/dist/skills/manager.d.ts +1 -1
  21. package/dist/skills/manager.d.ts.map +1 -1
  22. package/dist/social/ai-fallback.d.ts +1 -1
  23. package/dist/social/ai-fallback.d.ts.map +1 -1
  24. package/dist/social/ai-fallback.js +19 -0
  25. package/dist/social/ai-fallback.js.map +1 -1
  26. package/dist/social/types.d.ts +33 -2
  27. package/dist/social/types.d.ts.map +1 -1
  28. package/dist/social/types.js +1 -1
  29. package/dist/stealth/browser-pool.d.ts.map +1 -1
  30. package/dist/stealth/browser-pool.js +12 -6
  31. package/dist/stealth/browser-pool.js.map +1 -1
  32. package/dist/tools/download.d.ts +39 -0
  33. package/dist/tools/download.d.ts.map +1 -0
  34. package/dist/tools/download.js +375 -0
  35. package/dist/tools/download.js.map +1 -0
  36. package/dist/tools/image-search.d.ts.map +1 -1
  37. package/dist/tools/image-search.js +3 -11
  38. package/dist/tools/image-search.js.map +1 -1
  39. package/dist/tools/index.d.ts.map +1 -1
  40. package/dist/tools/index.js +7 -0
  41. package/dist/tools/index.js.map +1 -1
  42. package/dist/tools/instagram.d.ts +51 -0
  43. package/dist/tools/instagram.d.ts.map +1 -0
  44. package/dist/tools/instagram.js +462 -0
  45. package/dist/tools/instagram.js.map +1 -0
  46. package/dist/tools/interact.d.ts +4 -4
  47. package/dist/tools/interact.js +1 -1
  48. package/dist/tools/interact.js.map +1 -1
  49. package/dist/tools/manifest.d.ts +0 -1
  50. package/dist/tools/manifest.d.ts.map +1 -1
  51. package/dist/tools/manifest.js +18 -2
  52. package/dist/tools/manifest.js.map +1 -1
  53. package/dist/tools/news-search.d.ts.map +1 -1
  54. package/dist/tools/news-search.js +3 -11
  55. package/dist/tools/news-search.js.map +1 -1
  56. package/dist/tools/reddit.d.ts.map +1 -1
  57. package/dist/tools/reddit.js +18 -15
  58. package/dist/tools/reddit.js.map +1 -1
  59. package/dist/tools/rss.d.ts +27 -0
  60. package/dist/tools/rss.d.ts.map +1 -0
  61. package/dist/tools/rss.js +123 -0
  62. package/dist/tools/rss.js.map +1 -0
  63. package/dist/tools/run-skill.d.ts +10 -4
  64. package/dist/tools/run-skill.d.ts.map +1 -1
  65. package/dist/tools/run-skill.js +340 -20
  66. package/dist/tools/run-skill.js.map +1 -1
  67. package/dist/tools/search.d.ts.map +1 -1
  68. package/dist/tools/search.js +3 -11
  69. package/dist/tools/search.js.map +1 -1
  70. package/dist/tools/video-search.d.ts.map +1 -1
  71. package/dist/tools/video-search.js +3 -11
  72. package/dist/tools/video-search.js.map +1 -1
  73. package/dist/tools/youtube.d.ts +4 -4
  74. package/dist/tools/youtube.d.ts.map +1 -1
  75. package/dist/tools/youtube.js +102 -21
  76. package/dist/tools/youtube.js.map +1 -1
  77. package/dist/utils/debug.d.ts +2 -0
  78. package/dist/utils/debug.d.ts.map +1 -0
  79. package/dist/utils/debug.js +6 -0
  80. package/dist/utils/debug.js.map +1 -0
  81. package/dist/utils/fetcher.d.ts.map +1 -1
  82. package/dist/utils/fetcher.js +1 -3
  83. package/dist/utils/fetcher.js.map +1 -1
  84. package/dist/utils/tool-response.d.ts +13 -0
  85. package/dist/utils/tool-response.d.ts.map +1 -0
  86. package/dist/utils/tool-response.js +7 -0
  87. package/dist/utils/tool-response.js.map +1 -0
  88. package/package.json +5 -9
@@ -3,6 +3,7 @@ import * as cheerio from "cheerio";
3
3
  import { fetchPage } from "../utils/fetcher.js";
4
4
  import * as manager from "../skills/manager.js";
5
5
  import { MAX_URL_LENGTH, MAX_ITEMS } from "../constants.js";
6
+ import { toolResult } from "../utils/tool-response.js";
6
7
  export const name = "run_skill";
7
8
  export const description = "Run a previously created skill or built-in recipe to extract fresh structured data from its URL. Built-in recipes cover common use cases like HN, GitHub trending, e-commerce, news, SEO, and more.";
8
9
  export const schema = z.object({
@@ -20,11 +21,10 @@ export const schema = z.object({
20
21
  competitor: z.string().max(200).optional().describe("Competitor brand/handle (competitor_spy workflow)"),
21
22
  output_format: z.enum(["json", "markdown", "csv"]).optional().describe("Output format for influencer discovery"),
22
23
  threshold: z.number().min(0).max(100).optional().describe("Tier qualification threshold (default 60)"),
24
+ min_subscribers: z.number().min(0).optional().describe("Min YouTube subscribers (content_scout, default 300)"),
25
+ max_subscribers: z.number().min(0).optional().describe("Max YouTube subscribers (content_scout, default 100000)"),
23
26
  });
24
27
  // --- Helpers ---
25
- function mcpResult(data) {
26
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
27
- }
28
28
  function extractField($, el, selectorRaw) {
29
29
  // Support "selector | attr" syntax for attribute extraction
30
30
  const pipeIdx = selectorRaw.indexOf(" | ");
@@ -75,7 +75,7 @@ async function runExtract(config, url, input) {
75
75
  break;
76
76
  }
77
77
  }
78
- return mcpResult({
78
+ return toolResult({
79
79
  skill: config.name,
80
80
  description: config.description,
81
81
  tool: "extract",
@@ -92,7 +92,7 @@ async function runAiExtract(config, url, input) {
92
92
  const { smartFetch } = await import("../stealth/index.js");
93
93
  const { htmlToMarkdown } = await import("../utils/markdown.js");
94
94
  if (!hasLLMConfigured()) {
95
- return mcpResult({
95
+ return toolResult({
96
96
  error: "LLM not configured",
97
97
  message: "Set the LLM_API_KEY environment variable to enable AI extraction. Run `imperium-crawl setup` for guided configuration.",
98
98
  });
@@ -117,7 +117,7 @@ async function runAiExtract(config, url, input) {
117
117
  }
118
118
  const client = await createLLMClient();
119
119
  const result = await extractWithLLM(client, markdown, config.schema, config.max_tokens ?? 2000);
120
- return mcpResult({
120
+ return toolResult({
121
121
  skill: config.name,
122
122
  description: config.description,
123
123
  tool: "ai_extract",
@@ -138,7 +138,7 @@ async function runReadability(config, url, input) {
138
138
  const reader = new Readability(document, { charThreshold: 50 });
139
139
  const article = reader.parse();
140
140
  if (!article) {
141
- return mcpResult({ error: "Could not extract article content", url: result.url });
141
+ return toolResult({ error: "Could not extract article content", url: result.url });
142
142
  }
143
143
  const format = config.format ?? "markdown";
144
144
  let content;
@@ -153,7 +153,7 @@ async function runReadability(config, url, input) {
153
153
  default:
154
154
  content = htmlToMarkdown(article.content);
155
155
  }
156
- return mcpResult({
156
+ return toolResult({
157
157
  skill: config.name,
158
158
  description: config.description,
159
159
  tool: "readability",
@@ -215,7 +215,7 @@ async function runScrape(config, url, input) {
215
215
  const { data: truncated, truncated: wasTruncated } = truncateJsonData(parsed, input.max_items);
216
216
  data = truncated;
217
217
  format = "json";
218
- return mcpResult({
218
+ return toolResult({
219
219
  skill: config.name,
220
220
  description: config.description,
221
221
  tool: "scrape",
@@ -231,7 +231,7 @@ async function runScrape(config, url, input) {
231
231
  data = htmlToMarkdown(result.html);
232
232
  format = "markdown";
233
233
  }
234
- return mcpResult({
234
+ return toolResult({
235
235
  skill: config.name,
236
236
  description: config.description,
237
237
  tool: "scrape",
@@ -247,7 +247,7 @@ async function runMonitorWebsocket(config, url, input) {
247
247
  const { acquirePage } = await import("../stealth/chrome-profile.js");
248
248
  const { resolveProxy } = await import("../stealth/proxy.js");
249
249
  if (!(await isPlaywrightAvailable())) {
250
- return mcpResult({
250
+ return toolResult({
251
251
  error: "rebrowser-playwright is required for WebSocket monitoring. Install with: npm i rebrowser-playwright",
252
252
  });
253
253
  }
@@ -298,7 +298,7 @@ async function runMonitorWebsocket(config, url, input) {
298
298
  });
299
299
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
300
300
  await page.waitForTimeout(durationSeconds * 1000);
301
- return mcpResult({
301
+ return toolResult({
302
302
  skill: config.name,
303
303
  description: config.description,
304
304
  tool: "monitor_websocket",
@@ -338,6 +338,17 @@ async function ytExecute(action, params) {
338
338
  return null;
339
339
  }
340
340
  }
341
+ // Instagram tool helper — direct in-process call
342
+ async function igExecute(action, params) {
343
+ const ig = await import("./instagram.js");
344
+ const result = await ig.execute({ action, limit: 1, sort: "engagement", ...params });
345
+ try {
346
+ return JSON.parse(result.content[0].text || "{}");
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
341
352
  // Parse IG handle from YouTube description
342
353
  function parseIgHandles(description) {
343
354
  if (!description)
@@ -529,6 +540,67 @@ function estimateFrequency(videos) {
529
540
  return "biweekly";
530
541
  return "monthly";
531
542
  }
543
+ // Calculate niche depth from video titles
544
+ function calcNicheDepth(videoTitles, nicheKeywords) {
545
+ if (!videoTitles.length || !nicheKeywords.length)
546
+ return "GENERALIST";
547
+ const count = Math.min(videoTitles.length, 5);
548
+ let matching = 0;
549
+ for (let i = 0; i < count; i++) {
550
+ const lower = videoTitles[i].toLowerCase();
551
+ if (nicheKeywords.some(kw => lower.includes(kw.toLowerCase())))
552
+ matching++;
553
+ }
554
+ if (matching >= 4 || (count <= 3 && matching === count))
555
+ return "SPECIALIST";
556
+ if (matching >= Math.ceil(count * 0.5))
557
+ return "MIXED";
558
+ return "GENERALIST";
559
+ }
560
+ // Calculate view consistency (0-100) — low coefficient of variation = high consistency
561
+ function calcViewConsistency(views) {
562
+ if (views.length < 2)
563
+ return 50;
564
+ const mean = views.reduce((a, b) => a + b, 0) / views.length;
565
+ if (mean === 0)
566
+ return 0;
567
+ const variance = views.reduce((s, v) => s + (v - mean) ** 2, 0) / views.length;
568
+ const cv = Math.sqrt(variance) / mean; // coefficient of variation
569
+ // cv=0 → 100 (perfectly consistent), cv>=2 → 0 (wildly inconsistent)
570
+ return Math.round(Math.max(0, Math.min(100, (1 - cv / 2) * 100)));
571
+ }
572
+ // Composite content quality score (0-100)
573
+ function calcContentScore(profile) {
574
+ let score = 0;
575
+ // Likes per view (25 pts) — higher = more engaged audience
576
+ const lpv = profile.likes_per_view || 0;
577
+ if (lpv >= 0.08)
578
+ score += 25;
579
+ else if (lpv >= 0.05)
580
+ score += 20;
581
+ else if (lpv >= 0.03)
582
+ score += 15;
583
+ else if (lpv >= 0.01)
584
+ score += 8;
585
+ // Views per sub (25 pts) — higher = loyal audience
586
+ const vps = profile.views_per_sub || 0;
587
+ if (vps >= 0.5)
588
+ score += 25;
589
+ else if (vps >= 0.3)
590
+ score += 20;
591
+ else if (vps >= 0.15)
592
+ score += 15;
593
+ else if (vps >= 0.05)
594
+ score += 8;
595
+ // Niche depth (30 pts)
596
+ if (profile.niche_depth === "SPECIALIST")
597
+ score += 30;
598
+ else if (profile.niche_depth === "MIXED")
599
+ score += 15;
600
+ // View consistency (20 pts)
601
+ score += Math.round((profile.view_consistency || 0) / 100 * 20);
602
+ return score;
603
+ }
532
604
  // Calculate niche match % from descriptions/titles
533
605
  function calcNicheMatch(texts, nicheKeywords) {
534
606
  if (!texts.length || !nicheKeywords.length)
@@ -567,9 +639,10 @@ function formatInfluencerOutput(influencers, format, meta) {
567
639
  return avgB - avgA;
568
640
  });
569
641
  if (format === "csv") {
570
- const header = "handle,name,tier,reach,conversion,partnership,subscribers,ig_followers,engagement_rate,youtube_url,instagram_url,email";
642
+ 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";
571
643
  const rows = influencers.map(i => [i.handle, i.name, i.tier, i.scores.reach, i.scores.conversion, i.scores.partnership,
572
644
  i.subscribers || "", i.ig_followers || "", i.engagement_rate?.toFixed(1) || "",
645
+ i.niche_depth || "", i.views_per_sub ?? "", i.likes_per_view ?? "", i.view_consistency ?? "",
573
646
  i.youtube_url || "", i.instagram_url || "", i.email || ""].join(","));
574
647
  return header + "\n" + rows.join("\n");
575
648
  }
@@ -577,14 +650,18 @@ function formatInfluencerOutput(influencers, format, meta) {
577
650
  const tierBadge = { GOLDEN: "🥇", SILVER: "🥈", BRONZE: "🥉", UNRANKED: "⬜" };
578
651
  let md = `# Influencer Discovery: ${meta.niche}\n\n`;
579
652
  md += `**Workflow**: ${meta.workflow} | **Threshold**: ${meta.threshold} | **Found**: ${influencers.length}\n\n`;
580
- md += `| Tier | Handle | Subscribers | IG Followers | Engagement | Reach | Conv | Partner | Contact |\n`;
581
- md += `|------|--------|-------------|-------------|-----------|-------|------|---------|--------|\n`;
653
+ md += `| Tier | Handle | Subscribers | IG Followers | Engagement | Niche | V/Sub | L/View | Consist | Reach | Conv | Partner | Contact |\n`;
654
+ md += `|------|--------|-------------|-------------|-----------|-------|-------|--------|---------|-------|------|---------|--------|\n`;
582
655
  for (const i of influencers) {
583
656
  const subs = i.subscribers ? formatNum(i.subscribers) : "-";
584
657
  const igf = i.ig_followers ? formatNum(i.ig_followers) : "-";
585
658
  const er = i.engagement_rate ? `${i.engagement_rate.toFixed(1)}%` : "-";
659
+ const nd = i.niche_depth || "-";
660
+ const vps = i.views_per_sub !== undefined ? i.views_per_sub.toFixed(2) : "-";
661
+ const lpv = i.likes_per_view !== undefined ? i.likes_per_view.toFixed(3) : "-";
662
+ const vc = i.view_consistency !== undefined ? String(i.view_consistency) : "-";
586
663
  const contact = i.email ? "📧" : i.website ? "🌐" : i.has_business_contact ? "💼" : "-";
587
- md += `| ${tierBadge[i.tier]} ${i.tier} | ${i.handle} | ${subs} | ${igf} | ${er} | ${i.scores.reach} | ${i.scores.conversion} | ${i.scores.partnership} | ${contact} |\n`;
664
+ 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`;
588
665
  }
589
666
  return md;
590
667
  }
@@ -958,6 +1035,246 @@ async function runCompetitorSpy(config, input) {
958
1035
  }
959
1036
  return influencers;
960
1037
  }
1038
+ // --- Workflow: content_scout ---
1039
+ async function runContentScout(config, input) {
1040
+ const niche = input.niche || config.niche;
1041
+ const location = input.location || "";
1042
+ const nicheKeywords = niche.split(/[\s,]+/).filter(Boolean);
1043
+ const minSubs = input.min_subscribers ?? 300;
1044
+ const maxSubs = input.max_subscribers ?? 100_000;
1045
+ const igMaxCalls = config.ig_max_calls ?? 5;
1046
+ // --- Phase 1: DISCOVERY (Brave + YouTube) ---
1047
+ const channelUrls = new Map(); // url → name
1048
+ // Brave queries
1049
+ const braveQueries = [
1050
+ `site:youtube.com "${niche}" vlog|review|guide`,
1051
+ location ? `site:youtube.com "${niche}" "${location}"` : `site:youtube.com "${niche}" tips`,
1052
+ `site:youtube.com "${niche}" collab|partner small creator`,
1053
+ `"underrated channel" "${niche}" youtube`,
1054
+ `site:youtube.com "${niche}" -"million subscribers"`,
1055
+ ];
1056
+ const bravePromises = braveQueries.map(q => braveSearch(q, 10));
1057
+ const braveResults = await Promise.all(bravePromises);
1058
+ for (const data of braveResults) {
1059
+ const results = data?.web?.results || [];
1060
+ for (const r of results) {
1061
+ const url = r.url || "";
1062
+ // Extract channel URLs directly
1063
+ const channelMatch = url.match(/youtube\.com\/(@[\w.-]+|c\/[\w.-]+|channel\/[\w-]+)/);
1064
+ if (channelMatch && !channelUrls.has(channelMatch[0])) {
1065
+ channelUrls.set(`https://www.${channelMatch[0]}`, r.title || "");
1066
+ continue;
1067
+ }
1068
+ // Video URLs — we'll resolve to channels in phase 2
1069
+ if (url.includes("youtube.com/watch")) {
1070
+ channelUrls.set(url, r.title || "");
1071
+ }
1072
+ }
1073
+ }
1074
+ // YouTube searches (sort by date for fresh content)
1075
+ const ytQueries = [
1076
+ `${niche} ${location || ""}`.trim(),
1077
+ `${niche} review`,
1078
+ `${niche} vlog 2026`,
1079
+ ];
1080
+ for (const q of ytQueries) {
1081
+ const data = await ytExecute("search", { query: q, sort: "date" });
1082
+ if (data?.results) {
1083
+ for (const v of data.results) {
1084
+ if (v.author_url && !channelUrls.has(v.author_url)) {
1085
+ channelUrls.set(v.author_url, v.author || "");
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ // Resolve video URLs to channel URLs
1091
+ const resolvedChannels = new Map();
1092
+ for (const [url, name] of channelUrls) {
1093
+ if (url.includes("youtube.com/watch")) {
1094
+ const vd = await ytExecute("video", { url });
1095
+ if (vd?.author_url && !resolvedChannels.has(vd.author_url)) {
1096
+ resolvedChannels.set(vd.author_url, vd.author || name);
1097
+ }
1098
+ }
1099
+ else {
1100
+ if (!resolvedChannels.has(url))
1101
+ resolvedChannels.set(url, name);
1102
+ }
1103
+ }
1104
+ // --- Phase 2: SIZE GATE ---
1105
+ const qualifiedChannels = [];
1106
+ const channelEntries = Array.from(resolvedChannels.entries()).slice(0, 40);
1107
+ for (const [channelUrl, authorName] of channelEntries) {
1108
+ const channel = await ytExecute("channel", { channel_url: channelUrl });
1109
+ if (!channel || channel.error)
1110
+ continue;
1111
+ const subs = channel.subscribers || 0;
1112
+ if (subs < minSubs || subs > maxSubs)
1113
+ continue;
1114
+ qualifiedChannels.push({
1115
+ url: channelUrl,
1116
+ name: channel.name || authorName,
1117
+ subscribers: subs,
1118
+ description: channel.description || "",
1119
+ });
1120
+ }
1121
+ // --- Phase 3: CONTENT DEPTH ---
1122
+ const influencers = [];
1123
+ for (const ch of qualifiedChannels) {
1124
+ // Get 5 recent videos
1125
+ const recentSearch = await ytExecute("search", { query: ch.name, sort: "date" });
1126
+ const recentResults = (recentSearch?.results || []).slice(0, 5);
1127
+ const videoDetails = [];
1128
+ for (const v of recentResults) {
1129
+ if (v.url) {
1130
+ const vd = await ytExecute("video", { url: v.url });
1131
+ if (vd && !vd.error) {
1132
+ videoDetails.push({
1133
+ title: vd.title || v.title,
1134
+ views: vd.views,
1135
+ likes: vd.likes,
1136
+ published: vd.published,
1137
+ });
1138
+ }
1139
+ }
1140
+ }
1141
+ if (videoDetails.length === 0)
1142
+ continue;
1143
+ // Calculate content quality metrics
1144
+ const views = videoDetails.map(v => v.views || 0).filter(v => v > 0);
1145
+ const likes = videoDetails.map(v => v.likes || 0).filter(v => v > 0);
1146
+ const avgViews = views.length > 0 ? views.reduce((a, b) => a + b, 0) / views.length : 0;
1147
+ const avgLikes = likes.length > 0 ? likes.reduce((a, b) => a + b, 0) / likes.length : 0;
1148
+ const viewsPerSub = ch.subscribers > 0 ? avgViews / ch.subscribers : 0;
1149
+ const likesPerView = avgViews > 0 ? avgLikes / avgViews : 0;
1150
+ const viewConsistency = calcViewConsistency(views);
1151
+ const nicheDepth = calcNicheDepth(videoDetails.map(v => v.title), nicheKeywords);
1152
+ // Skip GENERALIST channels
1153
+ if (nicheDepth === "GENERALIST")
1154
+ continue;
1155
+ const contactInfo = extractContactInfo(ch.description);
1156
+ const igHandles = parseIgHandles(ch.description);
1157
+ const engagementRate = calcEngagement(avgViews, avgLikes, ch.subscribers);
1158
+ const nicheMatchPct = calcNicheMatch([ch.description, ...videoDetails.map(v => v.title)], nicheKeywords);
1159
+ // Brave Search for IG followers
1160
+ let igFollowers;
1161
+ let igUrl;
1162
+ if (igHandles.length > 0) {
1163
+ const braveResult = await braveSearch(`"${igHandles[0]}" instagram`, 3);
1164
+ if (braveResult?.web?.results) {
1165
+ for (const r of braveResult.web.results) {
1166
+ const f = parseFollowersFromSnippet(r.description || "");
1167
+ if (f) {
1168
+ igFollowers = f;
1169
+ igUrl = `https://instagram.com/${igHandles[0]}`;
1170
+ break;
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+ const platformCount = 1 + (igFollowers ? 1 : 0);
1176
+ const profile = {
1177
+ handle: ch.url.replace("https://www.youtube.com", "") || ch.name,
1178
+ name: ch.name,
1179
+ youtube_url: ch.url,
1180
+ instagram_url: igUrl,
1181
+ subscribers: ch.subscribers,
1182
+ ig_followers: igFollowers,
1183
+ description: ch.description.substring(0, 300),
1184
+ engagement_rate: Math.round(engagementRate * 10) / 10,
1185
+ avg_views: avgViews ? Math.round(avgViews) : undefined,
1186
+ avg_likes: avgLikes ? Math.round(avgLikes) : undefined,
1187
+ recent_videos: videoDetails,
1188
+ email: contactInfo.email,
1189
+ website: contactInfo.website,
1190
+ has_business_contact: contactInfo.hasBusiness,
1191
+ has_collab_signals: contactInfo.hasCollab,
1192
+ niche_match_pct: nicheMatchPct,
1193
+ posting_frequency: estimateFrequency(videoDetails),
1194
+ niche_depth: nicheDepth,
1195
+ views_per_sub: Math.round(viewsPerSub * 1000) / 1000,
1196
+ likes_per_view: Math.round(likesPerView * 1000) / 1000,
1197
+ view_consistency: viewConsistency,
1198
+ platform_count: platformCount,
1199
+ scores: { reach: 0, conversion: 0, partnership: 0 },
1200
+ tier: "UNRANKED",
1201
+ };
1202
+ // Custom scoring for content_scout:
1203
+ // reach ← Content Quality Score
1204
+ // conversion ← Partnership Readiness
1205
+ // partnership ← Reach + Authenticity
1206
+ const contentScore = calcContentScore(profile);
1207
+ // Partnership readiness: email, collab signals, business account, posting frequency
1208
+ let partnershipReady = 0;
1209
+ if (contactInfo.email)
1210
+ partnershipReady += 30;
1211
+ if (contactInfo.hasCollab)
1212
+ partnershipReady += 20;
1213
+ if (contactInfo.hasBusiness)
1214
+ partnershipReady += 15;
1215
+ if (profile.posting_frequency === "weekly")
1216
+ partnershipReady += 20;
1217
+ else if (profile.posting_frequency === "biweekly")
1218
+ partnershipReady += 10;
1219
+ if (igFollowers)
1220
+ partnershipReady += 15;
1221
+ // Reach + Authenticity: audience size, multi-platform, views/subs sanity
1222
+ let reachAuth = 0;
1223
+ if (ch.subscribers >= 10_000)
1224
+ reachAuth += 25;
1225
+ else if (ch.subscribers >= 1_000)
1226
+ reachAuth += 15;
1227
+ else
1228
+ reachAuth += 5;
1229
+ if (platformCount >= 2)
1230
+ reachAuth += 20;
1231
+ if (viewsPerSub >= 0.15)
1232
+ reachAuth += 25;
1233
+ else if (viewsPerSub >= 0.05)
1234
+ reachAuth += 15;
1235
+ reachAuth += Math.round(viewConsistency / 100 * 30);
1236
+ profile.scores = {
1237
+ reach: Math.min(100, contentScore),
1238
+ conversion: Math.min(100, partnershipReady),
1239
+ partnership: Math.min(100, reachAuth),
1240
+ };
1241
+ profile.tier = classifyTier(profile.scores, input.threshold ?? config.threshold ?? 60);
1242
+ influencers.push(profile);
1243
+ }
1244
+ // --- Phase 4: IG ENRICHMENT (top 5 only) ---
1245
+ const sorted = [...influencers].sort((a, b) => {
1246
+ const scoreA = (a.scores.reach + a.scores.conversion + a.scores.partnership) / 3;
1247
+ const scoreB = (b.scores.reach + b.scores.conversion + b.scores.partnership) / 3;
1248
+ return scoreB - scoreA;
1249
+ });
1250
+ let igCallsUsed = 0;
1251
+ for (const profile of sorted) {
1252
+ if (igCallsUsed >= igMaxCalls)
1253
+ break;
1254
+ const igHandle = profile.instagram_url?.replace("https://instagram.com/", "");
1255
+ if (!igHandle)
1256
+ continue;
1257
+ try {
1258
+ const igData = await igExecute("profile", { username: igHandle });
1259
+ if (igData && !igData.error) {
1260
+ if (igData.followers !== undefined)
1261
+ profile.ig_followers = igData.followers;
1262
+ if (igData.engagement_rate !== undefined)
1263
+ profile.ig_engagement_rate = igData.engagement_rate;
1264
+ if (igData.posts_count !== undefined)
1265
+ profile.ig_posts_count = igData.posts_count;
1266
+ if (igData.business_email)
1267
+ profile.email = profile.email || igData.business_email;
1268
+ profile.platform_count = Math.max(profile.platform_count, 2);
1269
+ }
1270
+ }
1271
+ catch {
1272
+ // IG enrichment is bonus — skip on failure
1273
+ }
1274
+ igCallsUsed++;
1275
+ }
1276
+ return influencers;
1277
+ }
961
1278
  // --- Main influencer discovery dispatcher ---
962
1279
  async function runInfluencerDiscovery(config, input) {
963
1280
  let influencers;
@@ -971,8 +1288,11 @@ async function runInfluencerDiscovery(config, input) {
971
1288
  case "competitor_spy":
972
1289
  influencers = await runCompetitorSpy(config, input);
973
1290
  break;
1291
+ case "content_scout":
1292
+ influencers = await runContentScout(config, input);
1293
+ break;
974
1294
  default:
975
- return mcpResult({ error: `Unknown influencer discovery workflow: ${config.workflow}` });
1295
+ return toolResult({ error: `Unknown influencer discovery workflow: ${config.workflow}` });
976
1296
  }
977
1297
  const outputFormat = input.output_format ?? config.output_format ?? "json";
978
1298
  const threshold = input.threshold ?? config.threshold ?? 60;
@@ -984,7 +1304,7 @@ async function runInfluencerDiscovery(config, input) {
984
1304
  if (typeof data === "string") {
985
1305
  return { content: [{ type: "text", text: data }] };
986
1306
  }
987
- return mcpResult(data);
1307
+ return toolResult(data);
988
1308
  }
989
1309
  // --- Main execute ---
990
1310
  export async function execute(input) {
@@ -992,7 +1312,7 @@ export async function execute(input) {
992
1312
  const config = await manager.loadWithRecipes(input.name);
993
1313
  if (!config) {
994
1314
  const skills = await manager.listAll();
995
- return mcpResult({
1315
+ return toolResult({
996
1316
  error: `Skill '${input.name}' not found.`,
997
1317
  available_skills: skills.map((s) => ({
998
1318
  name: s.name,
@@ -1016,7 +1336,7 @@ export async function execute(input) {
1016
1336
  case "influencer_discovery":
1017
1337
  return runInfluencerDiscovery(config, input);
1018
1338
  default:
1019
- return mcpResult({ error: `Unknown skill tool type: ${tool}` });
1339
+ return toolResult({ error: `Unknown skill tool type: ${tool}` });
1020
1340
  }
1021
1341
  }
1022
1342
  //# sourceMappingURL=run-skill.js.map