@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 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;
@@ -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;
@@ -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 before importing DNC entries.");
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 that should receive the DNC import.`);
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: number | null;
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
- targetFollowerMin: {
81
+ excludePostUrls: {
80
82
  type: string;
83
+ items: {
84
+ type: string;
85
+ };
81
86
  description: string;
82
87
  };
83
- targetFollowerMax: {
88
+ targetFollowerMin: {
84
89
  type: string;
85
90
  description: string;
86
91
  };
87
- excludePostUrls: {
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 the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
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 the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
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 parseFollowerCount(value) {
75
- if (typeof value === "number" && Number.isFinite(value) && value > 0) {
76
- return Math.round(value);
77
- }
78
- if (typeof value !== "string")
79
- return null;
80
- const cleaned = value.trim().toLowerCase().replace(/,/g, "");
81
- if (!cleaned)
82
- return null;
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 null;
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 null;
89
- const suffix = match[2] || "";
90
- const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
91
- return Math.round(base * multiplier);
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 extractFollowerCount(post) {
94
- const candidates = [
95
- post?.author?.followerCount,
96
- post?.author?.followersCount,
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
- if (fit === "below_target_band")
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 reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax) {
146
- const weightedEngagement = engagement.likes + engagement.comments * 4 + engagement.shares * 12;
147
- const fit = followerBandFit(followerCount, targetFollowerMin, targetFollowerMax);
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
- ? Number(((engagement.total / followerCount) * 1000).toFixed(3))
128
+ ? round3((total / followerCount) * 1000)
150
129
  : null;
151
130
  const weightedEngagementPer1kFollowers = followerCount
152
- ? Number(((weightedEngagement / followerCount) * 1000).toFixed(3))
131
+ ? round3((weightedEngagement / followerCount) * 1000)
153
132
  : null;
154
- const reachPenaltyMultiplier = bandMultiplier(fit, followerCount, targetFollowerMax);
155
133
  const reachAdjustedScore = weightedEngagementPer1kFollowers === null
156
- ? null
157
- : Number((weightedEngagementPer1kFollowers * reachPenaltyMultiplier).toFixed(3));
134
+ ? 0
135
+ : round3(weightedEngagementPer1kFollowers * penalty);
158
136
  return {
159
- targetFollowerMin,
160
- targetFollowerMax,
161
- followerBandFit: fit,
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 = normalizeFollowerBound(input.targetFollowerMin);
186
- const targetFollowerMax = normalizeFollowerBound(input.targetFollowerMax);
187
- const useTargetFollowerBand = Boolean(targetFollowerMin || targetFollowerMax);
188
- const collectLimit = useTargetFollowerBand ? 50 : maxPosts;
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 = useTargetFollowerBand && Array.isArray(response?.posts)
177
+ const rawPosts = Array.isArray(response?.posts)
198
178
  ? response.posts
199
179
  : Array.isArray(response?.topPostsForLLM)
200
180
  ? response.topPostsForLLM
201
- : Array.isArray(response?.posts)
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 = extractFollowerCount(p);
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: reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax),
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
  };