imperium-crawl 2.1.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 (48) hide show
  1. package/README.md +32 -75
  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/formatters.d.ts +2 -2
  11. package/dist/formatters.js +2 -2
  12. package/dist/index.js +6 -28
  13. package/dist/index.js.map +1 -1
  14. package/dist/recipes/index.d.ts.map +1 -1
  15. package/dist/recipes/index.js +2 -0
  16. package/dist/recipes/index.js.map +1 -1
  17. package/dist/recipes/influencer-content-scout.json +14 -0
  18. package/dist/skills/manager.d.ts +1 -1
  19. package/dist/skills/manager.d.ts.map +1 -1
  20. package/dist/social/ai-fallback.d.ts +1 -1
  21. package/dist/social/ai-fallback.d.ts.map +1 -1
  22. package/dist/social/ai-fallback.js +19 -0
  23. package/dist/social/ai-fallback.js.map +1 -1
  24. package/dist/social/types.d.ts +33 -2
  25. package/dist/social/types.d.ts.map +1 -1
  26. package/dist/social/types.js +1 -1
  27. package/dist/tools/index.d.ts.map +1 -1
  28. package/dist/tools/index.js +2 -0
  29. package/dist/tools/index.js.map +1 -1
  30. package/dist/tools/instagram.d.ts +51 -0
  31. package/dist/tools/instagram.d.ts.map +1 -0
  32. package/dist/tools/instagram.js +462 -0
  33. package/dist/tools/instagram.js.map +1 -0
  34. package/dist/tools/interact.d.ts +4 -4
  35. package/dist/tools/interact.js +1 -1
  36. package/dist/tools/interact.js.map +1 -1
  37. package/dist/tools/manifest.d.ts +0 -1
  38. package/dist/tools/manifest.d.ts.map +1 -1
  39. package/dist/tools/manifest.js +8 -1
  40. package/dist/tools/manifest.js.map +1 -1
  41. package/dist/tools/run-skill.d.ts +10 -4
  42. package/dist/tools/run-skill.d.ts.map +1 -1
  43. package/dist/tools/run-skill.js +340 -18
  44. package/dist/tools/run-skill.js.map +1 -1
  45. package/dist/utils/fetcher.d.ts.map +1 -1
  46. package/dist/utils/fetcher.js +1 -3
  47. package/dist/utils/fetcher.js.map +1 -1
  48. package/package.json +3 -9
@@ -20,9 +20,11 @@ export const schema = z.object({
20
20
  competitor: z.string().max(200).optional().describe("Competitor brand/handle (competitor_spy workflow)"),
21
21
  output_format: z.enum(["json", "markdown", "csv"]).optional().describe("Output format for influencer discovery"),
22
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)"),
23
25
  });
24
26
  // --- Helpers ---
25
- function mcpResult(data) {
27
+ function toolResult(data) {
26
28
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
27
29
  }
28
30
  function extractField($, el, selectorRaw) {
@@ -75,7 +77,7 @@ async function runExtract(config, url, input) {
75
77
  break;
76
78
  }
77
79
  }
78
- return mcpResult({
80
+ return toolResult({
79
81
  skill: config.name,
80
82
  description: config.description,
81
83
  tool: "extract",
@@ -92,7 +94,7 @@ async function runAiExtract(config, url, input) {
92
94
  const { smartFetch } = await import("../stealth/index.js");
93
95
  const { htmlToMarkdown } = await import("../utils/markdown.js");
94
96
  if (!hasLLMConfigured()) {
95
- return mcpResult({
97
+ return toolResult({
96
98
  error: "LLM not configured",
97
99
  message: "Set the LLM_API_KEY environment variable to enable AI extraction. Run `imperium-crawl setup` for guided configuration.",
98
100
  });
@@ -117,7 +119,7 @@ async function runAiExtract(config, url, input) {
117
119
  }
118
120
  const client = await createLLMClient();
119
121
  const result = await extractWithLLM(client, markdown, config.schema, config.max_tokens ?? 2000);
120
- return mcpResult({
122
+ return toolResult({
121
123
  skill: config.name,
122
124
  description: config.description,
123
125
  tool: "ai_extract",
@@ -138,7 +140,7 @@ async function runReadability(config, url, input) {
138
140
  const reader = new Readability(document, { charThreshold: 50 });
139
141
  const article = reader.parse();
140
142
  if (!article) {
141
- return mcpResult({ error: "Could not extract article content", url: result.url });
143
+ return toolResult({ error: "Could not extract article content", url: result.url });
142
144
  }
143
145
  const format = config.format ?? "markdown";
144
146
  let content;
@@ -153,7 +155,7 @@ async function runReadability(config, url, input) {
153
155
  default:
154
156
  content = htmlToMarkdown(article.content);
155
157
  }
156
- return mcpResult({
158
+ return toolResult({
157
159
  skill: config.name,
158
160
  description: config.description,
159
161
  tool: "readability",
@@ -215,7 +217,7 @@ async function runScrape(config, url, input) {
215
217
  const { data: truncated, truncated: wasTruncated } = truncateJsonData(parsed, input.max_items);
216
218
  data = truncated;
217
219
  format = "json";
218
- return mcpResult({
220
+ return toolResult({
219
221
  skill: config.name,
220
222
  description: config.description,
221
223
  tool: "scrape",
@@ -231,7 +233,7 @@ async function runScrape(config, url, input) {
231
233
  data = htmlToMarkdown(result.html);
232
234
  format = "markdown";
233
235
  }
234
- return mcpResult({
236
+ return toolResult({
235
237
  skill: config.name,
236
238
  description: config.description,
237
239
  tool: "scrape",
@@ -247,7 +249,7 @@ async function runMonitorWebsocket(config, url, input) {
247
249
  const { acquirePage } = await import("../stealth/chrome-profile.js");
248
250
  const { resolveProxy } = await import("../stealth/proxy.js");
249
251
  if (!(await isPlaywrightAvailable())) {
250
- return mcpResult({
252
+ return toolResult({
251
253
  error: "rebrowser-playwright is required for WebSocket monitoring. Install with: npm i rebrowser-playwright",
252
254
  });
253
255
  }
@@ -298,7 +300,7 @@ async function runMonitorWebsocket(config, url, input) {
298
300
  });
299
301
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
300
302
  await page.waitForTimeout(durationSeconds * 1000);
301
- return mcpResult({
303
+ return toolResult({
302
304
  skill: config.name,
303
305
  description: config.description,
304
306
  tool: "monitor_websocket",
@@ -338,6 +340,17 @@ async function ytExecute(action, params) {
338
340
  return null;
339
341
  }
340
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
+ }
341
354
  // Parse IG handle from YouTube description
342
355
  function parseIgHandles(description) {
343
356
  if (!description)
@@ -529,6 +542,67 @@ function estimateFrequency(videos) {
529
542
  return "biweekly";
530
543
  return "monthly";
531
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
+ }
532
606
  // Calculate niche match % from descriptions/titles
533
607
  function calcNicheMatch(texts, nicheKeywords) {
534
608
  if (!texts.length || !nicheKeywords.length)
@@ -567,9 +641,10 @@ function formatInfluencerOutput(influencers, format, meta) {
567
641
  return avgB - avgA;
568
642
  });
569
643
  if (format === "csv") {
570
- const header = "handle,name,tier,reach,conversion,partnership,subscribers,ig_followers,engagement_rate,youtube_url,instagram_url,email";
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";
571
645
  const rows = influencers.map(i => [i.handle, i.name, i.tier, i.scores.reach, i.scores.conversion, i.scores.partnership,
572
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 ?? "",
573
648
  i.youtube_url || "", i.instagram_url || "", i.email || ""].join(","));
574
649
  return header + "\n" + rows.join("\n");
575
650
  }
@@ -577,14 +652,18 @@ function formatInfluencerOutput(influencers, format, meta) {
577
652
  const tierBadge = { GOLDEN: "🥇", SILVER: "🥈", BRONZE: "🥉", UNRANKED: "⬜" };
578
653
  let md = `# Influencer Discovery: ${meta.niche}\n\n`;
579
654
  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`;
655
+ md += `| Tier | Handle | Subscribers | IG Followers | Engagement | Niche | V/Sub | L/View | Consist | Reach | Conv | Partner | Contact |\n`;
656
+ md += `|------|--------|-------------|-------------|-----------|-------|-------|--------|---------|-------|------|---------|--------|\n`;
582
657
  for (const i of influencers) {
583
658
  const subs = i.subscribers ? formatNum(i.subscribers) : "-";
584
659
  const igf = i.ig_followers ? formatNum(i.ig_followers) : "-";
585
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) : "-";
586
665
  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`;
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`;
588
667
  }
589
668
  return md;
590
669
  }
@@ -958,6 +1037,246 @@ async function runCompetitorSpy(config, input) {
958
1037
  }
959
1038
  return influencers;
960
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
+ }
961
1280
  // --- Main influencer discovery dispatcher ---
962
1281
  async function runInfluencerDiscovery(config, input) {
963
1282
  let influencers;
@@ -971,8 +1290,11 @@ async function runInfluencerDiscovery(config, input) {
971
1290
  case "competitor_spy":
972
1291
  influencers = await runCompetitorSpy(config, input);
973
1292
  break;
1293
+ case "content_scout":
1294
+ influencers = await runContentScout(config, input);
1295
+ break;
974
1296
  default:
975
- return mcpResult({ error: `Unknown influencer discovery workflow: ${config.workflow}` });
1297
+ return toolResult({ error: `Unknown influencer discovery workflow: ${config.workflow}` });
976
1298
  }
977
1299
  const outputFormat = input.output_format ?? config.output_format ?? "json";
978
1300
  const threshold = input.threshold ?? config.threshold ?? 60;
@@ -984,7 +1306,7 @@ async function runInfluencerDiscovery(config, input) {
984
1306
  if (typeof data === "string") {
985
1307
  return { content: [{ type: "text", text: data }] };
986
1308
  }
987
- return mcpResult(data);
1309
+ return toolResult(data);
988
1310
  }
989
1311
  // --- Main execute ---
990
1312
  export async function execute(input) {
@@ -992,7 +1314,7 @@ export async function execute(input) {
992
1314
  const config = await manager.loadWithRecipes(input.name);
993
1315
  if (!config) {
994
1316
  const skills = await manager.listAll();
995
- return mcpResult({
1317
+ return toolResult({
996
1318
  error: `Skill '${input.name}' not found.`,
997
1319
  available_skills: skills.map((s) => ({
998
1320
  name: s.name,
@@ -1016,7 +1338,7 @@ export async function execute(input) {
1016
1338
  case "influencer_discovery":
1017
1339
  return runInfluencerDiscovery(config, input);
1018
1340
  default:
1019
- return mcpResult({ error: `Unknown skill tool type: ${tool}` });
1341
+ return toolResult({ error: `Unknown skill tool type: ${tool}` });
1020
1342
  }
1021
1343
  }
1022
1344
  //# sourceMappingURL=run-skill.js.map