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.
- package/README.md +32 -75
- package/dist/cli-tui.d.ts +1 -1
- package/dist/cli-tui.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -18
- package/dist/config.js.map +1 -1
- package/dist/formatters.d.ts +2 -2
- package/dist/formatters.js +2 -2
- package/dist/index.js +6 -28
- package/dist/index.js.map +1 -1
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/recipes/influencer-content-scout.json +14 -0
- package/dist/skills/manager.d.ts +1 -1
- package/dist/skills/manager.d.ts.map +1 -1
- package/dist/social/ai-fallback.d.ts +1 -1
- package/dist/social/ai-fallback.d.ts.map +1 -1
- package/dist/social/ai-fallback.js +19 -0
- package/dist/social/ai-fallback.js.map +1 -1
- package/dist/social/types.d.ts +33 -2
- package/dist/social/types.d.ts.map +1 -1
- package/dist/social/types.js +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/instagram.d.ts +51 -0
- package/dist/tools/instagram.d.ts.map +1 -0
- package/dist/tools/instagram.js +462 -0
- package/dist/tools/instagram.js.map +1 -0
- package/dist/tools/interact.d.ts +4 -4
- package/dist/tools/interact.js +1 -1
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/manifest.d.ts +0 -1
- package/dist/tools/manifest.d.ts.map +1 -1
- package/dist/tools/manifest.js +8 -1
- package/dist/tools/manifest.js.map +1 -1
- package/dist/tools/run-skill.d.ts +10 -4
- package/dist/tools/run-skill.d.ts.map +1 -1
- package/dist/tools/run-skill.js +340 -18
- package/dist/tools/run-skill.js.map +1 -1
- package/dist/utils/fetcher.d.ts.map +1 -1
- package/dist/utils/fetcher.js +1 -3
- package/dist/utils/fetcher.js.map +1 -1
- package/package.json +3 -9
package/dist/tools/run-skill.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 +=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1341
|
+
return toolResult({ error: `Unknown skill tool type: ${tool}` });
|
|
1020
1342
|
}
|
|
1021
1343
|
}
|
|
1022
1344
|
//# sourceMappingURL=run-skill.js.map
|