@sellable/mcp 0.1.258 → 0.1.260
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/dist/server.js +4 -1
- package/dist/tools/csv-dnc.d.ts +36 -0
- package/dist/tools/csv-dnc.js +94 -2
- package/dist/tools/engage-discovery.d.ts +21 -18
- package/dist/tools/engage-discovery.js +90 -112
- package/dist/tools/leads.d.ts +381 -21
- package/dist/tools/leads.js +219 -5
- package/dist/tools/registry.d.ts +213 -21
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +6 -0
- package/skills/create-campaign-v2/references/filter-leads.md +2 -0
- package/skills/create-campaign-v2/references/lead-validation-preview.md +2 -0
- package/skills/create-campaign-v2/references/step-13-import-leads.md +3 -1
- package/skills/create-post/SKILL.md +147 -120
- package/skills/create-post/references/gold-standard-post-pack.md +0 -11
- package/skills/create-post/references/hook-research-playbook.md +276 -311
- package/skills/create-post/references/post-file-contract.md +8 -16
- package/skills/create-post/references/post-validation.md +142 -211
- package/skills/create-post/references/premise-development.md +250 -5
- package/skills/find-leads/SKILL.md +6 -0
package/dist/server.js
CHANGED
|
@@ -18,7 +18,7 @@ import { copySenderConfigTool, getEngageMemoryTool, migrateFlatConfigsTool, reco
|
|
|
18
18
|
import { getEngageStateTool, setEngageStateTool, } from "./tools/engage-state.js";
|
|
19
19
|
import { bulkEnrichWithProspeo, enrichWithProspeo, getProspeoCredits, } from "./tools/enrichment.js";
|
|
20
20
|
import { getCampaignFramework } from "./tools/framework.js";
|
|
21
|
-
import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
|
|
21
|
+
import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
|
|
22
22
|
import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
|
|
23
23
|
import { getCampaignNavigationState } from "./tools/navigation.js";
|
|
24
24
|
import { addOnDemandLeads, createOnDemandCampaign, createOnDemandTable, initOnDemandSequence, pauseOnDemandCampaign, startOnDemandCampaign, } from "./tools/one-off.js";
|
|
@@ -375,6 +375,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
375
375
|
case "load_csv_dnc_entries":
|
|
376
376
|
result = await loadCsvDncEntriesTool(args);
|
|
377
377
|
break;
|
|
378
|
+
case "list_dnc_entries":
|
|
379
|
+
result = await listDncEntriesTool(args);
|
|
380
|
+
break;
|
|
378
381
|
case "save_domain_filters":
|
|
379
382
|
result = await saveDomainFilters(args);
|
|
380
383
|
break;
|
package/dist/tools/csv-dnc.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
type DncSourceType = "all" | "manual" | "hubspot";
|
|
1
2
|
type InvalidDncRow = {
|
|
2
3
|
row: number;
|
|
3
4
|
column: string | null;
|
|
@@ -14,6 +15,41 @@ export type LoadCsvDncEntriesInput = {
|
|
|
14
15
|
confirmationToken?: string;
|
|
15
16
|
confirmed?: boolean;
|
|
16
17
|
};
|
|
18
|
+
export type ListDncEntriesInput = {
|
|
19
|
+
page?: number;
|
|
20
|
+
limit?: number;
|
|
21
|
+
search?: string;
|
|
22
|
+
listName?: string;
|
|
23
|
+
sourceType?: DncSourceType;
|
|
24
|
+
includeDeleted?: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function listDncEntries(input?: ListDncEntriesInput): Promise<{
|
|
27
|
+
ok: boolean;
|
|
28
|
+
workspaceId: string;
|
|
29
|
+
workspaceName: string;
|
|
30
|
+
pagination: {
|
|
31
|
+
total: number;
|
|
32
|
+
page: number;
|
|
33
|
+
limit: number;
|
|
34
|
+
totalPages: number;
|
|
35
|
+
};
|
|
36
|
+
listNames: string[];
|
|
37
|
+
entries: {
|
|
38
|
+
id: string | null;
|
|
39
|
+
type: string;
|
|
40
|
+
domain: string | null;
|
|
41
|
+
linkedin: string | null;
|
|
42
|
+
linkedinUsername: string | null;
|
|
43
|
+
name: string | null;
|
|
44
|
+
source: {
|
|
45
|
+
type: string;
|
|
46
|
+
label: string;
|
|
47
|
+
listName: string | null;
|
|
48
|
+
};
|
|
49
|
+
createdAt: string | null;
|
|
50
|
+
}[];
|
|
51
|
+
guidance: string;
|
|
52
|
+
}>;
|
|
17
53
|
export declare function loadCsvDncEntries(input: LoadCsvDncEntriesInput): Promise<{
|
|
18
54
|
guidance: string;
|
|
19
55
|
createdCount: number;
|
package/dist/tools/csv-dnc.js
CHANGED
|
@@ -14,6 +14,7 @@ const CONFIRMATION_TOKEN_VERSION = "csv-dnc-preview-v1";
|
|
|
14
14
|
const confirmationSecret = randomBytes(32).toString("hex");
|
|
15
15
|
const MAX_DNC_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
16
16
|
const MAX_DNC_CANDIDATES = 7500;
|
|
17
|
+
const MAX_DNC_LIST_LIMIT = 100;
|
|
17
18
|
const STRONG_DOMAIN_HEADER_KEYS = new Set([
|
|
18
19
|
"domain",
|
|
19
20
|
"website",
|
|
@@ -325,16 +326,84 @@ async function getVerifiedActiveWorkspace() {
|
|
|
325
326
|
const config = getConfig();
|
|
326
327
|
const activeWorkspaceId = config.activeWorkspaceId || config.workspaceId;
|
|
327
328
|
if (!activeWorkspaceId) {
|
|
328
|
-
throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace
|
|
329
|
+
throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace for the exact workspace whose Sellable DNC list should be read or updated.");
|
|
329
330
|
}
|
|
330
331
|
const api = getApi();
|
|
331
332
|
const { workspaces } = await api.get("/api/v3/workspaces");
|
|
332
333
|
const match = workspaces.find((workspace) => workspace.id === activeWorkspaceId);
|
|
333
334
|
if (!match) {
|
|
334
|
-
throw new Error(`Active workspace ${activeWorkspaceId} is not in the server access list. Run list_workspaces then set_active_workspace for the workspace
|
|
335
|
+
throw new Error(`Active workspace ${activeWorkspaceId} is not in the server access list. Run list_workspaces then set_active_workspace for the exact workspace whose Sellable DNC list should be read or updated.`);
|
|
335
336
|
}
|
|
336
337
|
return match;
|
|
337
338
|
}
|
|
339
|
+
function clampListPositiveInt(value, fallback, max) {
|
|
340
|
+
const parsed = typeof value === "number"
|
|
341
|
+
? value
|
|
342
|
+
: Number.parseInt(String(value ?? ""), 10);
|
|
343
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
344
|
+
return fallback;
|
|
345
|
+
return Math.min(Math.floor(parsed), max);
|
|
346
|
+
}
|
|
347
|
+
function normalizeDncSourceType(value) {
|
|
348
|
+
if (value === undefined || value === null || value === "")
|
|
349
|
+
return "all";
|
|
350
|
+
if (value === "all" || value === "manual" || value === "hubspot") {
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
throw new Error("sourceType must be one of all, manual, or hubspot.");
|
|
354
|
+
}
|
|
355
|
+
function optionalTrimmed(value) {
|
|
356
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
357
|
+
}
|
|
358
|
+
function normalizeCreatedAt(value) {
|
|
359
|
+
if (value instanceof Date)
|
|
360
|
+
return value.toISOString();
|
|
361
|
+
return optionalTrimmed(value);
|
|
362
|
+
}
|
|
363
|
+
function compactDncListEntry(entry) {
|
|
364
|
+
const domain = optionalTrimmed(entry.domain);
|
|
365
|
+
const linkedin = optionalTrimmed(entry.normalizedLinkedinUrl);
|
|
366
|
+
const sourceType = optionalTrimmed(entry.sourceType) ?? "manual";
|
|
367
|
+
return {
|
|
368
|
+
id: optionalTrimmed(entry.id),
|
|
369
|
+
type: domain ? "domain" : "linkedin",
|
|
370
|
+
domain,
|
|
371
|
+
linkedin,
|
|
372
|
+
linkedinUsername: optionalTrimmed(entry.linkedinUsername),
|
|
373
|
+
name: optionalTrimmed(entry.name),
|
|
374
|
+
source: {
|
|
375
|
+
type: sourceType,
|
|
376
|
+
label: optionalTrimmed(entry.sourceLabel) ??
|
|
377
|
+
(sourceType === "hubspot" ? "HubSpot" : "Manual"),
|
|
378
|
+
listName: optionalTrimmed(entry.sourceListName),
|
|
379
|
+
},
|
|
380
|
+
createdAt: normalizeCreatedAt(entry.createdAt),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function buildDncListPath(input) {
|
|
384
|
+
const page = clampListPositiveInt(input.page, 1, Number.MAX_SAFE_INTEGER);
|
|
385
|
+
const limit = clampListPositiveInt(input.limit, 25, MAX_DNC_LIST_LIMIT);
|
|
386
|
+
const sourceType = normalizeDncSourceType(input.sourceType);
|
|
387
|
+
const params = new URLSearchParams({
|
|
388
|
+
page: String(page),
|
|
389
|
+
limit: String(limit),
|
|
390
|
+
});
|
|
391
|
+
const search = optionalTrimmed(input.search);
|
|
392
|
+
const listName = optionalTrimmed(input.listName);
|
|
393
|
+
if (search)
|
|
394
|
+
params.set("search", search);
|
|
395
|
+
if (listName)
|
|
396
|
+
params.set("listName", listName);
|
|
397
|
+
if (sourceType !== "all")
|
|
398
|
+
params.set("sourceType", sourceType);
|
|
399
|
+
if (input.includeDeleted === true)
|
|
400
|
+
params.set("includeDeleted", "true");
|
|
401
|
+
return {
|
|
402
|
+
path: `/api/v3/dnc-list?${params.toString()}`,
|
|
403
|
+
page,
|
|
404
|
+
limit,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
338
407
|
function makeSignature(payload) {
|
|
339
408
|
return createHmac("sha256", confirmationSecret)
|
|
340
409
|
.update(JSON.stringify(payload))
|
|
@@ -556,6 +625,29 @@ async function runDncSpotChecks(entries) {
|
|
|
556
625
|
}
|
|
557
626
|
return checks;
|
|
558
627
|
}
|
|
628
|
+
export async function listDncEntries(input = {}) {
|
|
629
|
+
const workspace = await getVerifiedActiveWorkspace();
|
|
630
|
+
const api = getApi();
|
|
631
|
+
const request = buildDncListPath(input);
|
|
632
|
+
const response = await api.get(request.path);
|
|
633
|
+
const pagination = response.pagination ?? {};
|
|
634
|
+
return {
|
|
635
|
+
ok: true,
|
|
636
|
+
workspaceId: workspace.id,
|
|
637
|
+
workspaceName: workspace.name,
|
|
638
|
+
pagination: {
|
|
639
|
+
total: pagination.total ?? 0,
|
|
640
|
+
page: pagination.page ?? request.page,
|
|
641
|
+
limit: pagination.limit ?? request.limit,
|
|
642
|
+
totalPages: pagination.totalPages ?? 0,
|
|
643
|
+
},
|
|
644
|
+
listNames: Array.isArray(response.listNames) ? response.listNames : [],
|
|
645
|
+
entries: Array.isArray(response.entries)
|
|
646
|
+
? response.entries.map(compactDncListEntry)
|
|
647
|
+
: [],
|
|
648
|
+
guidance: "This is Sellable's workspace DNC list used by DNC Check.",
|
|
649
|
+
};
|
|
650
|
+
}
|
|
559
651
|
export async function loadCsvDncEntries(input) {
|
|
560
652
|
const workspace = await getVerifiedActiveWorkspace();
|
|
561
653
|
const api = getApi();
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
type FollowerBandFit = "in_target_band" | "below_target_band" | "above_target_band" | "unknown";
|
|
2
|
+
type ReachSignals = {
|
|
3
|
+
targetFollowerMin?: number;
|
|
4
|
+
targetFollowerMax?: number;
|
|
5
|
+
followerBandFit: FollowerBandFit;
|
|
6
|
+
engagementPer1kFollowers: number | null;
|
|
7
|
+
weightedEngagementPer1kFollowers: number | null;
|
|
8
|
+
reachPenaltyMultiplier: number;
|
|
9
|
+
reachAdjustedScore: number;
|
|
10
|
+
confidence: "high" | "medium" | "low";
|
|
11
|
+
};
|
|
1
12
|
export type EngagementPost = {
|
|
2
13
|
postId: string;
|
|
3
14
|
url: string;
|
|
@@ -7,7 +18,7 @@ export type EngagementPost = {
|
|
|
7
18
|
name: string;
|
|
8
19
|
headline: string;
|
|
9
20
|
profileUrl: string;
|
|
10
|
-
followerCount
|
|
21
|
+
followerCount?: number;
|
|
11
22
|
};
|
|
12
23
|
engagement: {
|
|
13
24
|
likes: number;
|
|
@@ -15,16 +26,7 @@ export type EngagementPost = {
|
|
|
15
26
|
shares: number;
|
|
16
27
|
total: number;
|
|
17
28
|
};
|
|
18
|
-
reachSignals
|
|
19
|
-
targetFollowerMin: number | null;
|
|
20
|
-
targetFollowerMax: number | null;
|
|
21
|
-
followerBandFit: "in_target_band" | "below_target_band" | "above_target_band" | "unknown";
|
|
22
|
-
weightedEngagement: number;
|
|
23
|
-
engagementPer1kFollowers: number | null;
|
|
24
|
-
weightedEngagementPer1kFollowers: number | null;
|
|
25
|
-
reachPenaltyMultiplier: number;
|
|
26
|
-
reachAdjustedScore: number | null;
|
|
27
|
-
};
|
|
29
|
+
reachSignals?: ReachSignals;
|
|
28
30
|
contentPreview: string;
|
|
29
31
|
};
|
|
30
32
|
export type SearchEngagementPostsInput = {
|
|
@@ -33,9 +35,9 @@ export type SearchEngagementPostsInput = {
|
|
|
33
35
|
maxAgeDays?: number;
|
|
34
36
|
minTotalEngagement?: number;
|
|
35
37
|
maxPosts?: number;
|
|
38
|
+
excludePostUrls?: string[];
|
|
36
39
|
targetFollowerMin?: number;
|
|
37
40
|
targetFollowerMax?: number;
|
|
38
|
-
excludePostUrls?: string[];
|
|
39
41
|
};
|
|
40
42
|
export type SearchEngagementPostsResponse = {
|
|
41
43
|
success: boolean;
|
|
@@ -76,19 +78,19 @@ export declare const engageDiscoveryToolDefinitions: {
|
|
|
76
78
|
type: string;
|
|
77
79
|
description: string;
|
|
78
80
|
};
|
|
79
|
-
|
|
81
|
+
excludePostUrls: {
|
|
80
82
|
type: string;
|
|
83
|
+
items: {
|
|
84
|
+
type: string;
|
|
85
|
+
};
|
|
81
86
|
description: string;
|
|
82
87
|
};
|
|
83
|
-
|
|
88
|
+
targetFollowerMin: {
|
|
84
89
|
type: string;
|
|
85
90
|
description: string;
|
|
86
91
|
};
|
|
87
|
-
|
|
92
|
+
targetFollowerMax: {
|
|
88
93
|
type: string;
|
|
89
|
-
items: {
|
|
90
|
-
type: string;
|
|
91
|
-
};
|
|
92
94
|
description: string;
|
|
93
95
|
};
|
|
94
96
|
};
|
|
@@ -97,3 +99,4 @@ export declare const engageDiscoveryToolDefinitions: {
|
|
|
97
99
|
};
|
|
98
100
|
}[];
|
|
99
101
|
export declare function searchEngagementPosts(input: SearchEngagementPostsInput): Promise<SearchEngagementPostsResponse>;
|
|
102
|
+
export {};
|
|
@@ -27,18 +27,18 @@ export const engageDiscoveryToolDefinitions = [
|
|
|
27
27
|
type: "number",
|
|
28
28
|
description: "Max posts to return after filtering (default 25).",
|
|
29
29
|
},
|
|
30
|
+
excludePostUrls: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: { type: "string" },
|
|
33
|
+
description: "Optional list of post URLs to exclude (e.g. already engaged/scheduled).",
|
|
34
|
+
},
|
|
30
35
|
targetFollowerMin: {
|
|
31
36
|
type: "number",
|
|
32
|
-
description: "Optional lower bound for
|
|
37
|
+
description: "Optional lower bound for creator follower count when reach-normalizing hook/source quality.",
|
|
33
38
|
},
|
|
34
39
|
targetFollowerMax: {
|
|
35
40
|
type: "number",
|
|
36
|
-
description: "Optional upper bound for
|
|
37
|
-
},
|
|
38
|
-
excludePostUrls: {
|
|
39
|
-
type: "array",
|
|
40
|
-
items: { type: "string" },
|
|
41
|
-
description: "Optional list of post URLs to exclude (e.g. already engaged/scheduled).",
|
|
41
|
+
description: "Optional upper bound for creator follower count when reach-normalizing hook/source quality.",
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
required: ["keywords"],
|
|
@@ -71,99 +71,77 @@ function safeNumber(value) {
|
|
|
71
71
|
const n = typeof value === "number" ? value : Number(value);
|
|
72
72
|
return Number.isFinite(n) ? n : 0;
|
|
73
73
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const match = cleaned.match(/(\d+(?:\.\d+)?)\s*([kmb])?/);
|
|
74
|
+
function round3(value) {
|
|
75
|
+
return Number(value.toFixed(3));
|
|
76
|
+
}
|
|
77
|
+
function parseFollowerCount(author) {
|
|
78
|
+
const direct = safeNumber(author?.followerCount);
|
|
79
|
+
if (direct > 0)
|
|
80
|
+
return direct;
|
|
81
|
+
const text = String(author?.followers || author?.followerCountText || "");
|
|
82
|
+
const match = text.match(/([\d,.]+)\s*([kKmM])?/);
|
|
84
83
|
if (!match)
|
|
85
|
-
return
|
|
86
|
-
const base = Number(match[1]);
|
|
84
|
+
return undefined;
|
|
85
|
+
const base = Number(match[1].replace(/,/g, ""));
|
|
87
86
|
if (!Number.isFinite(base) || base <= 0)
|
|
88
|
-
return
|
|
89
|
-
const suffix = match[2]
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
return undefined;
|
|
88
|
+
const suffix = match[2]?.toLowerCase();
|
|
89
|
+
if (suffix === "m")
|
|
90
|
+
return Math.round(base * 1_000_000);
|
|
91
|
+
if (suffix === "k")
|
|
92
|
+
return Math.round(base * 1_000);
|
|
93
|
+
return Math.round(base);
|
|
92
94
|
}
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
post?.author?.followers,
|
|
98
|
-
post?.author?.follower_count,
|
|
99
|
-
post?.author?.linkedinFollowers,
|
|
100
|
-
post?.author?.stats?.followers,
|
|
101
|
-
post?.followerCount,
|
|
102
|
-
post?.followersCount,
|
|
103
|
-
post?.followers,
|
|
104
|
-
post?.authorFollowerCount,
|
|
105
|
-
];
|
|
106
|
-
for (const candidate of candidates) {
|
|
107
|
-
const parsed = parseFollowerCount(candidate);
|
|
108
|
-
if (parsed !== null)
|
|
109
|
-
return parsed;
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
function normalizeFollowerBound(value) {
|
|
114
|
-
const parsed = parseFollowerCount(value);
|
|
115
|
-
return parsed && parsed > 0 ? parsed : null;
|
|
116
|
-
}
|
|
117
|
-
function followerBandFit(followerCount, targetFollowerMin, targetFollowerMax) {
|
|
118
|
-
if (!followerCount)
|
|
119
|
-
return "unknown";
|
|
120
|
-
if (targetFollowerMin && followerCount < targetFollowerMin) {
|
|
121
|
-
return "below_target_band";
|
|
122
|
-
}
|
|
123
|
-
if (targetFollowerMax && followerCount > targetFollowerMax) {
|
|
124
|
-
return "above_target_band";
|
|
125
|
-
}
|
|
126
|
-
if (targetFollowerMin || targetFollowerMax)
|
|
127
|
-
return "in_target_band";
|
|
128
|
-
return "unknown";
|
|
129
|
-
}
|
|
130
|
-
function bandMultiplier(fit, followerCount, targetFollowerMax) {
|
|
131
|
-
if (fit === "in_target_band")
|
|
95
|
+
function reachPenaltyMultiplier(followerCount, targetFollowerMin, targetFollowerMax) {
|
|
96
|
+
if (!followerCount || !targetFollowerMin || !targetFollowerMax)
|
|
97
|
+
return 0.4;
|
|
98
|
+
if (followerCount >= targetFollowerMin && followerCount <= targetFollowerMax) {
|
|
132
99
|
return 1;
|
|
133
|
-
|
|
100
|
+
}
|
|
101
|
+
if (followerCount < targetFollowerMin)
|
|
134
102
|
return 0.75;
|
|
135
|
-
if (fit === "unknown")
|
|
136
|
-
return 0.4;
|
|
137
|
-
if (!followerCount || !targetFollowerMax)
|
|
138
|
-
return 0.45;
|
|
139
103
|
if (followerCount <= targetFollowerMax * 2)
|
|
140
104
|
return 0.65;
|
|
141
105
|
if (followerCount <= targetFollowerMax * 5)
|
|
142
106
|
return 0.35;
|
|
143
107
|
return 0.15;
|
|
144
108
|
}
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
109
|
+
function followerBandFit(followerCount, targetFollowerMin, targetFollowerMax) {
|
|
110
|
+
if (!followerCount || !targetFollowerMin || !targetFollowerMax) {
|
|
111
|
+
return "unknown";
|
|
112
|
+
}
|
|
113
|
+
if (followerCount >= targetFollowerMin && followerCount <= targetFollowerMax) {
|
|
114
|
+
return "in_target_band";
|
|
115
|
+
}
|
|
116
|
+
if (followerCount < targetFollowerMin)
|
|
117
|
+
return "below_target_band";
|
|
118
|
+
return "above_target_band";
|
|
119
|
+
}
|
|
120
|
+
function buildReachSignals(params) {
|
|
121
|
+
const { followerCount, likes, comments, shares, total } = params;
|
|
122
|
+
const hasTarget = Boolean(params.targetFollowerMin && params.targetFollowerMax);
|
|
123
|
+
if (!hasTarget && !followerCount)
|
|
124
|
+
return undefined;
|
|
125
|
+
const weightedEngagement = likes + comments * 4 + shares * 12;
|
|
126
|
+
const penalty = reachPenaltyMultiplier(followerCount, params.targetFollowerMin, params.targetFollowerMax);
|
|
148
127
|
const engagementPer1kFollowers = followerCount
|
|
149
|
-
?
|
|
128
|
+
? round3((total / followerCount) * 1000)
|
|
150
129
|
: null;
|
|
151
130
|
const weightedEngagementPer1kFollowers = followerCount
|
|
152
|
-
?
|
|
131
|
+
? round3((weightedEngagement / followerCount) * 1000)
|
|
153
132
|
: null;
|
|
154
|
-
const reachPenaltyMultiplier = bandMultiplier(fit, followerCount, targetFollowerMax);
|
|
155
133
|
const reachAdjustedScore = weightedEngagementPer1kFollowers === null
|
|
156
|
-
?
|
|
157
|
-
:
|
|
134
|
+
? 0
|
|
135
|
+
: round3(weightedEngagementPer1kFollowers * penalty);
|
|
158
136
|
return {
|
|
159
|
-
targetFollowerMin,
|
|
160
|
-
targetFollowerMax,
|
|
161
|
-
followerBandFit:
|
|
162
|
-
weightedEngagement,
|
|
137
|
+
targetFollowerMin: params.targetFollowerMin,
|
|
138
|
+
targetFollowerMax: params.targetFollowerMax,
|
|
139
|
+
followerBandFit: followerBandFit(followerCount, params.targetFollowerMin, params.targetFollowerMax),
|
|
163
140
|
engagementPer1kFollowers,
|
|
164
141
|
weightedEngagementPer1kFollowers,
|
|
165
|
-
reachPenaltyMultiplier,
|
|
142
|
+
reachPenaltyMultiplier: penalty,
|
|
166
143
|
reachAdjustedScore,
|
|
144
|
+
confidence: followerCount ? (hasTarget ? "high" : "medium") : "low",
|
|
167
145
|
};
|
|
168
146
|
}
|
|
169
147
|
export async function searchEngagementPosts(input) {
|
|
@@ -182,10 +160,12 @@ export async function searchEngagementPosts(input) {
|
|
|
182
160
|
const maxPosts = typeof input.maxPosts === "number" && input.maxPosts > 0
|
|
183
161
|
? Math.min(50, input.maxPosts)
|
|
184
162
|
: 25;
|
|
185
|
-
const targetFollowerMin =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
163
|
+
const targetFollowerMin = typeof input.targetFollowerMin === "number" && input.targetFollowerMin > 0
|
|
164
|
+
? input.targetFollowerMin
|
|
165
|
+
: undefined;
|
|
166
|
+
const targetFollowerMax = typeof input.targetFollowerMax === "number" && input.targetFollowerMax > 0
|
|
167
|
+
? input.targetFollowerMax
|
|
168
|
+
: undefined;
|
|
189
169
|
const exclude = new Set((input.excludePostUrls || [])
|
|
190
170
|
.map((u) => normalizePostUrl(u))
|
|
191
171
|
.filter(Boolean));
|
|
@@ -194,13 +174,11 @@ export async function searchEngagementPosts(input) {
|
|
|
194
174
|
keywords: keywords.map((keyword) => ({ keyword })),
|
|
195
175
|
page,
|
|
196
176
|
});
|
|
197
|
-
const rawPosts =
|
|
177
|
+
const rawPosts = Array.isArray(response?.posts)
|
|
198
178
|
? response.posts
|
|
199
179
|
: Array.isArray(response?.topPostsForLLM)
|
|
200
180
|
? response.topPostsForLLM
|
|
201
|
-
:
|
|
202
|
-
? response.posts
|
|
203
|
-
: [];
|
|
181
|
+
: [];
|
|
204
182
|
const now = Date.now();
|
|
205
183
|
const oldestMs = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
206
184
|
let tooOld = 0;
|
|
@@ -220,7 +198,6 @@ export async function searchEngagementPosts(input) {
|
|
|
220
198
|
const comments = safeNumber(p?.engagement?.comments);
|
|
221
199
|
const shares = safeNumber(p?.engagement?.shares);
|
|
222
200
|
const total = likes + comments + shares;
|
|
223
|
-
const engagement = { likes, comments, shares, total };
|
|
224
201
|
if (total < minTotalEngagement) {
|
|
225
202
|
tooLowEngagement += 1;
|
|
226
203
|
continue;
|
|
@@ -231,7 +208,16 @@ export async function searchEngagementPosts(input) {
|
|
|
231
208
|
tooOld += 1;
|
|
232
209
|
continue;
|
|
233
210
|
}
|
|
234
|
-
const followerCount =
|
|
211
|
+
const followerCount = parseFollowerCount(p?.author);
|
|
212
|
+
const reachSignals = buildReachSignals({
|
|
213
|
+
followerCount,
|
|
214
|
+
likes,
|
|
215
|
+
comments,
|
|
216
|
+
shares,
|
|
217
|
+
total,
|
|
218
|
+
targetFollowerMin,
|
|
219
|
+
targetFollowerMax,
|
|
220
|
+
});
|
|
235
221
|
kept.push({
|
|
236
222
|
postId: String(p?.id || ""),
|
|
237
223
|
url,
|
|
@@ -243,34 +229,26 @@ export async function searchEngagementPosts(input) {
|
|
|
243
229
|
name: String(p?.author?.name || ""),
|
|
244
230
|
headline: String(p?.author?.headline || ""),
|
|
245
231
|
profileUrl: String(p?.author?.profileUrl || ""),
|
|
246
|
-
followerCount,
|
|
232
|
+
...(followerCount ? { followerCount } : {}),
|
|
247
233
|
},
|
|
248
|
-
engagement,
|
|
249
|
-
reachSignals
|
|
234
|
+
engagement: { likes, comments, shares, total },
|
|
235
|
+
...(reachSignals ? { reachSignals } : {}),
|
|
250
236
|
contentPreview: previewText(String(p?.content || ""), 220),
|
|
251
237
|
});
|
|
252
|
-
if (kept.length >= collectLimit)
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
if (useTargetFollowerBand) {
|
|
256
|
-
kept.sort((a, b) => {
|
|
257
|
-
const scoreDelta = (b.reachSignals.reachAdjustedScore ?? -1) -
|
|
258
|
-
(a.reachSignals.reachAdjustedScore ?? -1);
|
|
259
|
-
if (scoreDelta !== 0)
|
|
260
|
-
return scoreDelta;
|
|
261
|
-
return b.engagement.total - a.engagement.total;
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
// Sort by total engagement desc for a predictable shortlist.
|
|
266
|
-
kept.sort((a, b) => b.engagement.total - a.engagement.total);
|
|
267
|
-
}
|
|
268
|
-
if (kept.length > maxPosts) {
|
|
269
|
-
kept.length = maxPosts;
|
|
270
238
|
}
|
|
239
|
+
const hasReachTarget = Boolean(targetFollowerMin && targetFollowerMax);
|
|
240
|
+
kept.sort((a, b) => {
|
|
241
|
+
if (hasReachTarget) {
|
|
242
|
+
const reachDelta = (b.reachSignals?.reachAdjustedScore || 0) -
|
|
243
|
+
(a.reachSignals?.reachAdjustedScore || 0);
|
|
244
|
+
if (reachDelta !== 0)
|
|
245
|
+
return reachDelta;
|
|
246
|
+
}
|
|
247
|
+
return b.engagement.total - a.engagement.total;
|
|
248
|
+
});
|
|
271
249
|
return {
|
|
272
250
|
success: true,
|
|
273
|
-
posts: kept,
|
|
251
|
+
posts: kept.slice(0, maxPosts),
|
|
274
252
|
totalReturned: rawPosts.length,
|
|
275
253
|
filteredOut: { tooOld, excluded, tooLowEngagement },
|
|
276
254
|
};
|