@sellable/mcp 0.1.219 → 0.1.220

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.
@@ -41,6 +41,9 @@ const RESERVED_TARGET_COLUMNS = new Set([
41
41
  "Open Link",
42
42
  "Enrich Prospect",
43
43
  ]);
44
+ const COLUMN_MAPPING_SAMPLE_SIZE = 50;
45
+ const MIN_COLUMN_CONFIDENCE = 0.65;
46
+ const MIN_DOMAIN_COLUMN_CONFIDENCE = 0.8;
44
47
  const CSV_LINKEDIN_LIMITS = {
45
48
  maxBytes: MAX_CSV_LINKEDIN_UPLOAD_BYTES,
46
49
  maxRows: MAX_CSV_LINKEDIN_UPLOAD_ROWS,
@@ -187,38 +190,166 @@ function findHeader(csvHeaders, normalizedHeaders, candidates) {
187
190
  }
188
191
  return undefined;
189
192
  }
190
- function getSuggestedStandardMappings(headers, linkedInColumn) {
193
+ function getSampleValues(rows, header) {
194
+ return rows
195
+ .slice(0, COLUMN_MAPPING_SAMPLE_SIZE)
196
+ .map((row) => String(row[header] ?? "").trim())
197
+ .filter(Boolean);
198
+ }
199
+ function getValueMatchStats(rows, header, predicate) {
200
+ const values = getSampleValues(rows, header);
201
+ const matchCount = values.filter(predicate).length;
202
+ return {
203
+ nonEmptyCount: values.length,
204
+ matchCount,
205
+ confidence: values.length > 0 ? matchCount / values.length : 0,
206
+ };
207
+ }
208
+ function hasConfidentValues(rows, header, predicate, options = {}) {
209
+ if (rows.length === 0) {
210
+ return true;
211
+ }
212
+ const stats = getValueMatchStats(rows, header, predicate);
213
+ if (stats.nonEmptyCount === 0) {
214
+ return options.allowEmpty ?? false;
215
+ }
216
+ const minMatches = Math.min(options.minMatches ?? 2, stats.nonEmptyCount);
217
+ const minConfidence = options.minConfidence ?? MIN_COLUMN_CONFIDENCE;
218
+ return stats.matchCount >= minMatches && stats.confidence >= minConfidence;
219
+ }
220
+ function findConfidentHeader(csvHeaders, normalizedHeaders, candidates, rows, predicate, options) {
221
+ const header = findHeader(csvHeaders, normalizedHeaders, candidates);
222
+ if (!header) {
223
+ return undefined;
224
+ }
225
+ return hasConfidentValues(rows, header, predicate, options)
226
+ ? header
227
+ : undefined;
228
+ }
229
+ function findBestHeaderByContent(headers, rows, predicate, options = {}) {
230
+ if (rows.length === 0) {
231
+ return undefined;
232
+ }
233
+ const candidates = headers
234
+ .map((header) => ({
235
+ header,
236
+ ...getValueMatchStats(rows, header, predicate),
237
+ }))
238
+ .filter((entry) => {
239
+ const minMatches = Math.min(options.minMatches ?? 2, entry.nonEmptyCount);
240
+ const minConfidence = options.minConfidence ?? MIN_COLUMN_CONFIDENCE;
241
+ return (entry.nonEmptyCount > 0 &&
242
+ entry.matchCount >= minMatches &&
243
+ entry.confidence >= minConfidence);
244
+ })
245
+ .sort((left, right) => {
246
+ if (right.confidence !== left.confidence) {
247
+ return right.confidence - left.confidence;
248
+ }
249
+ return right.matchCount - left.matchCount;
250
+ });
251
+ if (candidates.length === 0) {
252
+ return undefined;
253
+ }
254
+ const [best, second] = candidates;
255
+ if (second &&
256
+ second.confidence === best.confidence &&
257
+ second.matchCount === best.matchCount) {
258
+ return undefined;
259
+ }
260
+ return best.header;
261
+ }
262
+ function looksLikePersonName(value) {
263
+ const trimmed = value.trim();
264
+ if (trimmed.length < 2 || trimmed.length > 120) {
265
+ return false;
266
+ }
267
+ if (/[0-9@/:]/.test(trimmed) || sanitizeCompanyWebsite(trimmed)) {
268
+ return false;
269
+ }
270
+ const words = trimmed.split(/\s+/).filter(Boolean);
271
+ if (words.length === 0 || words.length > 5) {
272
+ return false;
273
+ }
274
+ if (/\b(inc|llc|ltd|corp|company|technologies|technology|security|systems|labs|software|group|studio|studios|ventures|capital|partners|agency|solutions|platform|cloud|data)\b/i.test(trimmed)) {
275
+ return false;
276
+ }
277
+ return words.every((word) => /\p{L}/u.test(word));
278
+ }
279
+ function looksLikeJobTitle(value) {
280
+ const trimmed = value.trim();
281
+ if (trimmed.length < 2 || trimmed.length > 160) {
282
+ return false;
283
+ }
284
+ if (/@|https?:\/\//i.test(trimmed) || sanitizeCompanyWebsite(trimmed)) {
285
+ return false;
286
+ }
287
+ const words = trimmed.split(/\s+/).filter(Boolean);
288
+ return words.length > 0 && words.length <= 12 && /\p{L}/u.test(trimmed);
289
+ }
290
+ function looksLikeLocation(value) {
291
+ const trimmed = value.trim();
292
+ if (trimmed.length < 2 || trimmed.length > 120) {
293
+ return false;
294
+ }
295
+ if (/[0-9@/]/.test(trimmed) || sanitizeCompanyWebsite(trimmed)) {
296
+ return false;
297
+ }
298
+ return /\p{L}/u.test(trimmed);
299
+ }
300
+ function looksLikeCompanyWebsite(value) {
301
+ return sanitizeCompanyWebsite(value) !== null;
302
+ }
303
+ function isWebsiteContentFallbackHeader(header) {
304
+ const key = normalizeHeaderKey(header);
305
+ if (key.includes("linkedin") ||
306
+ key.includes("profile") ||
307
+ key.includes("email")) {
308
+ return false;
309
+ }
310
+ const normalized = normalizeHeaderForLookup(header);
311
+ return (normalized.includes("company") ||
312
+ normalized.includes("website") ||
313
+ normalized.includes("domain") ||
314
+ normalized.includes("url") ||
315
+ normalized.includes("site"));
316
+ }
317
+ function getSuggestedStandardMappings(headers, linkedInColumn, rows) {
191
318
  const filteredHeaders = headers.filter((header) => header !== linkedInColumn);
192
319
  const normalizedHeaders = filteredHeaders.map(normalizeHeaderForLookup);
193
320
  const mappings = {};
194
- const directNameHeader = findHeader(filteredHeaders, normalizedHeaders, [
195
- "name",
196
- "full name",
197
- "contact name",
198
- ]);
321
+ const directNameHeader = findConfidentHeader(filteredHeaders, normalizedHeaders, ["name", "full name", "contact name", "contact full name"], rows, looksLikePersonName, { allowEmpty: true }) ??
322
+ findConfidentHeader(filteredHeaders, normalizedHeaders, [
323
+ "person",
324
+ "person name",
325
+ "prospect",
326
+ "prospect name",
327
+ "lead",
328
+ "lead name",
329
+ "recipient",
330
+ "recipient name",
331
+ ], rows, looksLikePersonName);
199
332
  if (directNameHeader) {
200
333
  mappings["Name"] = directNameHeader;
201
334
  }
202
335
  else {
203
336
  const firstNameIndex = normalizedHeaders.findIndex((header) => header === "first name");
204
337
  const lastNameIndex = normalizedHeaders.findIndex((header) => header === "last name");
205
- if (firstNameIndex !== -1 && lastNameIndex !== -1) {
338
+ if (firstNameIndex !== -1 &&
339
+ lastNameIndex !== -1 &&
340
+ hasConfidentValues(rows, filteredHeaders[firstNameIndex], looksLikePersonName, { allowEmpty: true }) &&
341
+ hasConfidentValues(rows, filteredHeaders[lastNameIndex], looksLikePersonName, { allowEmpty: true })) {
206
342
  mappings["Name"] = [
207
343
  filteredHeaders[firstNameIndex],
208
344
  filteredHeaders[lastNameIndex],
209
345
  ];
210
346
  }
211
347
  }
212
- const titleHeader = findHeader(filteredHeaders, normalizedHeaders, [
213
- "title",
214
- "job title",
215
- "position",
216
- "role",
217
- ]);
348
+ const titleHeader = findConfidentHeader(filteredHeaders, normalizedHeaders, ["title", "job title", "position", "role"], rows, looksLikeJobTitle, { allowEmpty: true });
218
349
  if (titleHeader) {
219
350
  mappings["Title"] = titleHeader;
220
351
  }
221
- const companyWebsiteHeader = findHeader(filteredHeaders, normalizedHeaders, [
352
+ const companyWebsiteHeader = findConfidentHeader(filteredHeaders, normalizedHeaders, [
222
353
  "company website",
223
354
  "website",
224
355
  "domain",
@@ -226,16 +357,12 @@ function getSuggestedStandardMappings(headers, linkedInColumn) {
226
357
  "website url",
227
358
  "company url",
228
359
  "organization website",
229
- ]);
360
+ ], rows, looksLikeCompanyWebsite, { allowEmpty: true, minConfidence: MIN_DOMAIN_COLUMN_CONFIDENCE }) ??
361
+ findBestHeaderByContent(filteredHeaders.filter(isWebsiteContentFallbackHeader), rows, looksLikeCompanyWebsite, { minConfidence: MIN_DOMAIN_COLUMN_CONFIDENCE, minMatches: 3 });
230
362
  if (companyWebsiteHeader) {
231
363
  mappings["Company Website"] = companyWebsiteHeader;
232
364
  }
233
- const locationHeader = findHeader(filteredHeaders, normalizedHeaders, [
234
- "location",
235
- "city",
236
- "geography",
237
- "region",
238
- ]);
365
+ const locationHeader = findConfidentHeader(filteredHeaders, normalizedHeaders, ["location", "city", "geography", "region"], rows, looksLikeLocation, { allowEmpty: true });
239
366
  if (locationHeader) {
240
367
  mappings["Location"] = locationHeader;
241
368
  }
@@ -595,7 +722,7 @@ export function buildCsvLinkedinPreview(input) {
595
722
  }
596
723
  }
597
724
  const candidateLinkedInColumns = getCandidateLinkedInColumns(headers, parsed.rows);
598
- const standardMappings = getSuggestedStandardMappings(headers, resolvedLinkedInColumn);
725
+ const standardMappings = getSuggestedStandardMappings(headers, resolvedLinkedInColumn, parsed.rows);
599
726
  let selectedColumns = getSuggestedCarryoverColumns(headers, resolvedLinkedInColumn, standardMappings);
600
727
  if (input.selectedColumns) {
601
728
  if (input.selectedColumns.length > MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS) {
@@ -176,6 +176,42 @@ export declare const linkedinToolDefinitions: ({
176
176
  required: string[];
177
177
  };
178
178
  })[];
179
+ interface RawLinkedInPost {
180
+ id?: string;
181
+ entityId?: string;
182
+ activityId?: string;
183
+ author?: {
184
+ authorName?: string;
185
+ authorPublicIdentifier?: string;
186
+ };
187
+ text?: string;
188
+ content?: string;
189
+ activityDate?: string;
190
+ postedAt?: string | {
191
+ date?: string;
192
+ timestamp?: number;
193
+ postedAgoText?: string;
194
+ };
195
+ reactionsCount?: number;
196
+ commentsCount?: number;
197
+ engagement?: {
198
+ likes?: number;
199
+ comments?: number;
200
+ shares?: number;
201
+ };
202
+ activityUrl?: string;
203
+ linkedinUrl?: string;
204
+ shareLinkedinUrl?: string;
205
+ socialContent?: {
206
+ shareUrl?: string;
207
+ };
208
+ header?: {
209
+ linkedinUrl?: string;
210
+ };
211
+ isRepublishedPost?: boolean;
212
+ repostedBy?: unknown;
213
+ repostedAt?: unknown;
214
+ }
179
215
  interface SerializedPost {
180
216
  text: string;
181
217
  date: string;
@@ -184,6 +220,7 @@ interface SerializedPost {
184
220
  url: string;
185
221
  isRepost: boolean;
186
222
  }
223
+ export declare function serializeLinkedInPosts(rawPosts: RawLinkedInPost[] | undefined, context: string): SerializedPost[];
187
224
  export declare function fetchLinkedInPosts(linkedinUrl: string, limit?: number): Promise<{
188
225
  posts: SerializedPost[];
189
226
  count: number;
@@ -137,6 +137,51 @@ export const linkedinToolDefinitions = [
137
137
  },
138
138
  },
139
139
  ];
140
+ function normalizePostDate(post) {
141
+ if (post.activityDate)
142
+ return post.activityDate;
143
+ if (typeof post.postedAt === "string")
144
+ return post.postedAt;
145
+ if (post.postedAt?.date)
146
+ return post.postedAt.date;
147
+ if (typeof post.postedAt?.timestamp === "number") {
148
+ return new Date(post.postedAt.timestamp).toISOString();
149
+ }
150
+ return "";
151
+ }
152
+ function normalizePostUrl(post) {
153
+ return (post.activityUrl ||
154
+ post.linkedinUrl ||
155
+ post.shareLinkedinUrl ||
156
+ post.socialContent?.shareUrl ||
157
+ post.header?.linkedinUrl ||
158
+ "");
159
+ }
160
+ function serializeLinkedInPost(post) {
161
+ return {
162
+ text: post.text || post.content || "",
163
+ date: normalizePostDate(post),
164
+ reactions: post.reactionsCount ?? post.engagement?.likes ?? 0,
165
+ comments: post.commentsCount ?? post.engagement?.comments ?? 0,
166
+ url: normalizePostUrl(post),
167
+ isRepost: Boolean(post.isRepublishedPost || post.repostedBy || post.repostedAt),
168
+ };
169
+ }
170
+ function isUsableSerializedPost(post) {
171
+ return Boolean(post.text.trim() ||
172
+ post.date ||
173
+ post.url ||
174
+ post.reactions > 0 ||
175
+ post.comments > 0);
176
+ }
177
+ export function serializeLinkedInPosts(rawPosts, context) {
178
+ const raw = rawPosts || [];
179
+ const posts = raw.map(serializeLinkedInPost).filter(isUsableSerializedPost);
180
+ if (raw.length > 0 && posts.length === 0) {
181
+ throw new Error(`${context} returned ${raw.length} post placeholder row(s), but none contained usable text, date, URL, or engagement data.`);
182
+ }
183
+ return posts;
184
+ }
140
185
  export async function fetchLinkedInPosts(linkedinUrl, limit = 25) {
141
186
  const api = getApi();
142
187
  const normalizedLinkedinUrl = normalizeLinkedInProfileInput(linkedinUrl);
@@ -145,15 +190,7 @@ export async function fetchLinkedInPosts(linkedinUrl, limit = 25) {
145
190
  limit: String(limit),
146
191
  });
147
192
  const response = await api.get(`/api/v1/scrape/linkedin/user-posts?${params.toString()}`);
148
- // Serialize to compact format: just text and key metadata
149
- const posts = (response.posts || []).map((post) => ({
150
- text: post.text || "",
151
- date: post.activityDate || "",
152
- reactions: post.reactionsCount || 0,
153
- comments: post.commentsCount || 0,
154
- url: post.activityUrl || "",
155
- isRepost: post.isRepublishedPost || false,
156
- }));
193
+ const posts = serializeLinkedInPosts(response.posts, "LinkedIn user posts fetch");
157
194
  return {
158
195
  posts,
159
196
  count: posts.length,
@@ -198,14 +235,7 @@ export async function fetchCompanyPosts(companyUrl, limit = 25, sortBy = "recent
198
235
  sortBy,
199
236
  });
200
237
  const response = await api.get(`/api/v1/scrape/linkedin/company-posts?${params.toString()}`);
201
- const posts = (response.posts || []).map((post) => ({
202
- text: post.text || "",
203
- date: post.activityDate || "",
204
- reactions: post.reactionsCount || 0,
205
- comments: post.commentsCount || 0,
206
- url: post.activityUrl || "",
207
- isRepost: post.isRepublishedPost || false,
208
- }));
238
+ const posts = serializeLinkedInPosts(response.posts, "LinkedIn company posts fetch");
209
239
  return {
210
240
  posts,
211
241
  count: posts.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.219",
3
+ "version": "0.1.220",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -1,9 +0,0 @@
1
- {
2
- "parallelMode": "wide",
3
- "agentCount": 6,
4
- "maxToolCallsPerAgent": 2,
5
- "senderMaxAgents": 2,
6
- "senderMaxToolCallsPerAgent": 3,
7
- "progressMode": true,
8
- "debugMode": true
9
- }