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.
- package/README.md +59 -89
- package/dist/cli-tui.d.ts +1 -1
- package/dist/cli-tui.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -18
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/formatters.d.ts +2 -2
- package/dist/formatters.js +2 -2
- package/dist/index.js +6 -28
- package/dist/index.js.map +1 -1
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +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/stealth/browser-pool.d.ts.map +1 -1
- package/dist/stealth/browser-pool.js +12 -6
- package/dist/stealth/browser-pool.js.map +1 -1
- package/dist/tools/download.d.ts +39 -0
- package/dist/tools/download.d.ts.map +1 -0
- package/dist/tools/download.js +375 -0
- package/dist/tools/download.js.map +1 -0
- package/dist/tools/image-search.d.ts.map +1 -1
- package/dist/tools/image-search.js +3 -11
- package/dist/tools/image-search.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/instagram.d.ts +51 -0
- package/dist/tools/instagram.d.ts.map +1 -0
- package/dist/tools/instagram.js +462 -0
- package/dist/tools/instagram.js.map +1 -0
- package/dist/tools/interact.d.ts +4 -4
- package/dist/tools/interact.js +1 -1
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/manifest.d.ts +0 -1
- package/dist/tools/manifest.d.ts.map +1 -1
- package/dist/tools/manifest.js +18 -2
- package/dist/tools/manifest.js.map +1 -1
- package/dist/tools/news-search.d.ts.map +1 -1
- package/dist/tools/news-search.js +3 -11
- package/dist/tools/news-search.js.map +1 -1
- package/dist/tools/reddit.d.ts.map +1 -1
- package/dist/tools/reddit.js +18 -15
- package/dist/tools/reddit.js.map +1 -1
- package/dist/tools/rss.d.ts +27 -0
- package/dist/tools/rss.d.ts.map +1 -0
- package/dist/tools/rss.js +123 -0
- package/dist/tools/rss.js.map +1 -0
- 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 -20
- package/dist/tools/run-skill.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +3 -11
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/video-search.d.ts.map +1 -1
- package/dist/tools/video-search.js +3 -11
- package/dist/tools/video-search.js.map +1 -1
- package/dist/tools/youtube.d.ts +4 -4
- package/dist/tools/youtube.d.ts.map +1 -1
- package/dist/tools/youtube.js +102 -21
- package/dist/tools/youtube.js.map +1 -1
- package/dist/utils/debug.d.ts +2 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +6 -0
- package/dist/utils/debug.js.map +1 -0
- package/dist/utils/fetcher.d.ts.map +1 -1
- package/dist/utils/fetcher.js +1 -3
- package/dist/utils/fetcher.js.map +1 -1
- package/dist/utils/tool-response.d.ts +13 -0
- package/dist/utils/tool-response.d.ts.map +1 -0
- package/dist/utils/tool-response.js +7 -0
- package/dist/utils/tool-response.js.map +1 -0
- package/package.json +5 -9
package/dist/tools/run-skill.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 +=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1339
|
+
return toolResult({ error: `Unknown skill tool type: ${tool}` });
|
|
1020
1340
|
}
|
|
1021
1341
|
}
|
|
1022
1342
|
//# sourceMappingURL=run-skill.js.map
|