@sellable/mcp 0.1.273 → 0.1.275
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 +14 -3
- package/dist/index-dev.js +0 -0
- package/dist/index.js +0 -0
- package/dist/server.js +17 -0
- package/dist/tools/campaign-ab-test.d.ts +72 -0
- package/dist/tools/campaign-ab-test.js +60 -0
- package/dist/tools/csv-linkedin.d.ts +23 -0
- package/dist/tools/csv-linkedin.js +167 -7
- package/dist/tools/harvest-jobs.d.ts +182 -0
- package/dist/tools/harvest-jobs.js +429 -0
- package/dist/tools/leads.d.ts +6 -0
- package/dist/tools/leads.js +9 -1
- package/dist/tools/prompts.d.ts +3 -3
- package/dist/tools/prompts.js +1 -0
- package/dist/tools/registry.d.ts +105 -2
- package/dist/tools/registry.js +4 -0
- package/package.json +1 -1
- package/skills/create-ab-test/SKILL.md +77 -0
- package/skills/create-campaign/SKILL.md +26 -0
- package/skills/create-campaign/core/providers/prospeo.json +5 -2
- package/skills/generate-messages/SKILL.md +10 -0
- package/skills/providers/prospeo.md +21 -0
- package/skills/research/config.json +0 -9
package/README.md
CHANGED
|
@@ -16,9 +16,10 @@ Each message gets 5+ minutes of Claude attention with deep research - no other t
|
|
|
16
16
|
|
|
17
17
|
### Prompt Source Of Truth
|
|
18
18
|
|
|
19
|
-
There are
|
|
19
|
+
There are five public Sellable entrypoints shared across hosts:
|
|
20
20
|
|
|
21
21
|
- `sellable:create-campaign`
|
|
22
|
+
- `sellable:create-ab-test`
|
|
22
23
|
- `sellable:foundation`
|
|
23
24
|
- `sellable:content`
|
|
24
25
|
- `sellable:create-post`
|
|
@@ -29,6 +30,11 @@ the approval-gated workflow from:
|
|
|
29
30
|
|
|
30
31
|
- `mcp/sellable/skills/create-campaign-v2/SKILL.md`
|
|
31
32
|
|
|
33
|
+
The create-ab-test public wrapper prepares clean A/B split lead lists and
|
|
34
|
+
review-copy campaigns from:
|
|
35
|
+
|
|
36
|
+
- `mcp/sellable/skills/create-ab-test/SKILL.md`
|
|
37
|
+
|
|
32
38
|
The foundation public wrapper loads the core identity/company memory workflow
|
|
33
39
|
from:
|
|
34
40
|
|
|
@@ -148,8 +154,9 @@ The installer does the full local setup:
|
|
|
148
154
|
`mcp__sellable__*` tools into skill sessions
|
|
149
155
|
|
|
150
156
|
After the installer passes, fully quit and reopen Codex Desktop. Start a new
|
|
151
|
-
thread and select `Sellable Create Campaign`, `Sellable
|
|
152
|
-
`Sellable Content`, or `Sellable Create Post`; or invoke
|
|
157
|
+
thread and select `Sellable Create Campaign`, `Sellable Create A/B Test`,
|
|
158
|
+
`Sellable Foundation`, `Sellable Content`, or `Sellable Create Post`; or invoke
|
|
159
|
+
`$sellable:create-campaign`, `$sellable:create-ab-test`,
|
|
153
160
|
`$sellable:foundation`, `$sellable:content`, or `$sellable:create-post`. If the app still says
|
|
154
161
|
`mcp__sellable__*` tools are missing after the installer passes, check that
|
|
155
162
|
`~/.codex/config.toml` contains both `[marketplaces.sellable]` and
|
|
@@ -162,19 +169,23 @@ the Sellable MCP tools for Codex Desktop.
|
|
|
162
169
|
Use these names consistently:
|
|
163
170
|
|
|
164
171
|
- Claude Code command: `/sellable:create-campaign`
|
|
172
|
+
- Claude Code command: `/sellable:create-ab-test`
|
|
165
173
|
- Claude Code command: `/sellable:foundation`
|
|
166
174
|
- Claude Code command: `/sellable:content`
|
|
167
175
|
- Claude Code command: `/sellable:create-post`
|
|
168
176
|
- Codex command: `$sellable:create-campaign`
|
|
177
|
+
- Codex command: `$sellable:create-ab-test`
|
|
169
178
|
- Codex command: `$sellable:foundation`
|
|
170
179
|
- Codex command: `$sellable:content`
|
|
171
180
|
- Codex command: `$sellable:create-post`
|
|
172
181
|
- Codex Desktop plugin: `sellable@sellable`
|
|
173
182
|
- Codex visible skill: `Sellable Create Campaign`
|
|
183
|
+
- Codex visible skill: `Sellable Create A/B Test`
|
|
174
184
|
- Codex visible skill: `Sellable Foundation`
|
|
175
185
|
- Codex visible skill: `Sellable Content`
|
|
176
186
|
- Codex visible skill: `Sellable Create Post`
|
|
177
187
|
- Codex skill frontmatter name: `create-campaign`
|
|
188
|
+
- Codex skill frontmatter name: `create-ab-test`
|
|
178
189
|
- Codex skill frontmatter name: `foundation`
|
|
179
190
|
- Codex skill frontmatter name: `content`
|
|
180
191
|
- Codex skill frontmatter name: `create-post`
|
package/dist/index-dev.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { handleAddColumn, handleCommitBlueprint, } from "./tools/blueprint-commi
|
|
|
7
7
|
import { bootstrapCreateCampaign } from "./tools/bootstrap.js";
|
|
8
8
|
import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRerun, selectCampaignCells, waitForCampaignProcessing, } from "./tools/campaign-processing.js";
|
|
9
9
|
import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
|
|
10
|
+
import { prepareCampaignAbTest } from "./tools/campaign-ab-test.js";
|
|
10
11
|
import { queueCells, updateCell } from "./tools/cells.js";
|
|
11
12
|
import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
|
|
12
13
|
import { calculateLinkedInHookPreviewTool, capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
|
|
@@ -18,6 +19,7 @@ import { copySenderConfigTool, getEngageMemoryTool, migrateFlatConfigsTool, reco
|
|
|
18
19
|
import { getEngageStateTool, setEngageStateTool, } from "./tools/engage-state.js";
|
|
19
20
|
import { bulkEnrichWithProspeo, enrichWithProspeo, getProspeoCredits, } from "./tools/enrichment.js";
|
|
20
21
|
import { getCampaignFramework } from "./tools/framework.js";
|
|
22
|
+
import { confirmHarvestJobCompanies, searchHarvestJobs, } from "./tools/harvest-jobs.js";
|
|
21
23
|
import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
|
|
22
24
|
import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
|
|
23
25
|
import { getCampaignNavigationState } from "./tools/navigation.js";
|
|
@@ -214,6 +216,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
214
216
|
markCampaignContextDirty(result.campaignOfferId, "duplicate_campaign");
|
|
215
217
|
}
|
|
216
218
|
break;
|
|
219
|
+
case "prepare_campaign_ab_test":
|
|
220
|
+
result = await prepareCampaignAbTest(args);
|
|
221
|
+
if (result?.variants?.A?.campaignId) {
|
|
222
|
+
markCampaignContextDirty(result.variants.A.campaignId, "prepare_campaign_ab_test");
|
|
223
|
+
}
|
|
224
|
+
if (result?.variants?.B?.campaignId) {
|
|
225
|
+
markCampaignContextDirty(result.variants.B.campaignId, "prepare_campaign_ab_test");
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
217
228
|
case "update_campaign_brief":
|
|
218
229
|
result = await updateCampaignBrief(args?.campaignId, args?.campaignBrief);
|
|
219
230
|
if (args?.campaignId) {
|
|
@@ -372,6 +383,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
372
383
|
case "confirm_prospeo_company_accounts":
|
|
373
384
|
result = await confirmProspeoCompanyAccounts(args);
|
|
374
385
|
break;
|
|
386
|
+
case "search_harvest_jobs":
|
|
387
|
+
result = await searchHarvestJobs(args);
|
|
388
|
+
break;
|
|
389
|
+
case "confirm_harvest_job_companies":
|
|
390
|
+
result = await confirmHarvestJobCompanies(args);
|
|
391
|
+
break;
|
|
375
392
|
case "load_csv_domains":
|
|
376
393
|
result = await loadCsvDomains(args);
|
|
377
394
|
break;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface PrepareCampaignAbTestInput {
|
|
2
|
+
sourceCampaignId: string;
|
|
3
|
+
variantName: string;
|
|
4
|
+
variantBriefDelta: string;
|
|
5
|
+
variantALabel?: string;
|
|
6
|
+
variantABriefDelta?: string;
|
|
7
|
+
splitStrategy?: "alternating_stable_sort";
|
|
8
|
+
targetCounts?: {
|
|
9
|
+
a?: number;
|
|
10
|
+
b?: number;
|
|
11
|
+
};
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
idempotencyKey?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const campaignAbTestToolDefinitions: {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: string;
|
|
20
|
+
properties: {
|
|
21
|
+
sourceCampaignId: {
|
|
22
|
+
type: string;
|
|
23
|
+
description: string;
|
|
24
|
+
};
|
|
25
|
+
variantName: {
|
|
26
|
+
type: string;
|
|
27
|
+
description: string;
|
|
28
|
+
};
|
|
29
|
+
variantBriefDelta: {
|
|
30
|
+
type: string;
|
|
31
|
+
description: string;
|
|
32
|
+
};
|
|
33
|
+
variantALabel: {
|
|
34
|
+
type: string;
|
|
35
|
+
description: string;
|
|
36
|
+
};
|
|
37
|
+
variantABriefDelta: {
|
|
38
|
+
type: string;
|
|
39
|
+
description: string;
|
|
40
|
+
};
|
|
41
|
+
splitStrategy: {
|
|
42
|
+
type: string;
|
|
43
|
+
enum: string[];
|
|
44
|
+
description: string;
|
|
45
|
+
};
|
|
46
|
+
targetCounts: {
|
|
47
|
+
type: string;
|
|
48
|
+
properties: {
|
|
49
|
+
a: {
|
|
50
|
+
type: string;
|
|
51
|
+
};
|
|
52
|
+
b: {
|
|
53
|
+
type: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
additionalProperties: boolean;
|
|
57
|
+
description: string;
|
|
58
|
+
};
|
|
59
|
+
dryRun: {
|
|
60
|
+
type: string;
|
|
61
|
+
description: string;
|
|
62
|
+
};
|
|
63
|
+
idempotencyKey: {
|
|
64
|
+
type: string;
|
|
65
|
+
description: string;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
required: string[];
|
|
69
|
+
additionalProperties: boolean;
|
|
70
|
+
};
|
|
71
|
+
}[];
|
|
72
|
+
export declare function prepareCampaignAbTest(input: PrepareCampaignAbTestInput): Promise<unknown>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getApi } from "../api.js";
|
|
2
|
+
export const campaignAbTestToolDefinitions = [
|
|
3
|
+
{
|
|
4
|
+
name: "prepare_campaign_ab_test",
|
|
5
|
+
description: "Prepare a campaign A/B test from an existing campaign using its clean source lead list. Creates deterministic A/B split lead lists and review-copy campaigns, updates the variant brief, and never launches either campaign.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
sourceCampaignId: {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "Existing source campaign ID.",
|
|
12
|
+
},
|
|
13
|
+
variantName: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Short label for variant B, e.g. New CTA.",
|
|
16
|
+
},
|
|
17
|
+
variantBriefDelta: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "The specific campaign brief/copy change for variant B.",
|
|
20
|
+
},
|
|
21
|
+
variantALabel: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Optional label for the control variant.",
|
|
24
|
+
},
|
|
25
|
+
variantABriefDelta: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Optional explicit brief change for variant A. Omit for unchanged control.",
|
|
28
|
+
},
|
|
29
|
+
splitStrategy: {
|
|
30
|
+
type: "string",
|
|
31
|
+
enum: ["alternating_stable_sort"],
|
|
32
|
+
description: "Deterministic split strategy. Defaults to alternating_stable_sort.",
|
|
33
|
+
},
|
|
34
|
+
targetCounts: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
a: { type: "number" },
|
|
38
|
+
b: { type: "number" },
|
|
39
|
+
},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
description: "Optional exact split counts. Omit for an even alternating split.",
|
|
42
|
+
},
|
|
43
|
+
dryRun: {
|
|
44
|
+
type: "boolean",
|
|
45
|
+
description: "When true, returns split plan and review names without creating campaigns/lists.",
|
|
46
|
+
},
|
|
47
|
+
idempotencyKey: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Optional stable key for retrying the same A/B preparation safely.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["sourceCampaignId", "variantName", "variantBriefDelta"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
export async function prepareCampaignAbTest(input) {
|
|
58
|
+
const api = getApi();
|
|
59
|
+
return api.post("/api/v3/mcp/campaign-ab-test", input);
|
|
60
|
+
}
|
|
@@ -9,6 +9,18 @@ export type CsvLinkedinLimits = {
|
|
|
9
9
|
maxRows: number;
|
|
10
10
|
maxCarryColumns: number;
|
|
11
11
|
};
|
|
12
|
+
export type CsvLinkedinIgnoredReservedColumn = {
|
|
13
|
+
originalHeader: string;
|
|
14
|
+
canonicalName: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
};
|
|
17
|
+
export type CsvLinkedinContentFingerprint = {
|
|
18
|
+
fileSha256: string;
|
|
19
|
+
sanitizedHeaderSha256: string;
|
|
20
|
+
selectedColumnsSha256: string;
|
|
21
|
+
ignoredReservedColumnsSha256: string;
|
|
22
|
+
rowCount: number;
|
|
23
|
+
};
|
|
12
24
|
export type CsvLinkedinPreview = {
|
|
13
25
|
resolvedFilePath: string;
|
|
14
26
|
suggestedLeadListName: string;
|
|
@@ -26,6 +38,8 @@ export type CsvLinkedinPreview = {
|
|
|
26
38
|
duplicateLinkedInCount: number;
|
|
27
39
|
emptyLinkedInRowCount: number;
|
|
28
40
|
duplicatePolicy: "first_wins";
|
|
41
|
+
ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
|
|
42
|
+
reservedColumnWarning: string | null;
|
|
29
43
|
limits: CsvLinkedinLimits;
|
|
30
44
|
warnings: string[];
|
|
31
45
|
blockingErrors: string[];
|
|
@@ -38,6 +52,8 @@ export type CsvLinkedinConfirmationPayload = {
|
|
|
38
52
|
fileMtimeMs: number;
|
|
39
53
|
linkedInColumn: string;
|
|
40
54
|
selectedColumns: string[];
|
|
55
|
+
ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
|
|
56
|
+
contentFingerprint: CsvLinkedinContentFingerprint;
|
|
41
57
|
};
|
|
42
58
|
export type PreparedLinkedinLeadRow = {
|
|
43
59
|
row: Record<string, string>;
|
|
@@ -76,6 +92,9 @@ type ParsedCsvLinkedinFile = {
|
|
|
76
92
|
headers: string[];
|
|
77
93
|
rows: Array<Record<string, string>>;
|
|
78
94
|
totalRows: number;
|
|
95
|
+
ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
|
|
96
|
+
reservedColumnWarning: string | null;
|
|
97
|
+
fileSha256: string;
|
|
79
98
|
blockingErrors: string[];
|
|
80
99
|
warnings: string[];
|
|
81
100
|
};
|
|
@@ -86,6 +105,10 @@ export type LinkedInValidationResult = {
|
|
|
86
105
|
valid: false;
|
|
87
106
|
reason: string;
|
|
88
107
|
};
|
|
108
|
+
export declare function getReservedWorkflowColumnMatch(header: string): {
|
|
109
|
+
canonicalName: string;
|
|
110
|
+
reason: string;
|
|
111
|
+
} | null;
|
|
89
112
|
export declare function validateAndNormalizeLinkedInUrl(value: string): LinkedInValidationResult;
|
|
90
113
|
export declare function makeLinkedinConfirmationToken(payload: CsvLinkedinConfirmationPayload): string;
|
|
91
114
|
export declare function parseLinkedinConfirmationToken(token: string): CsvLinkedinConfirmationPayload;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parse } from "csv-parse/sync";
|
|
2
|
-
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
3
|
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
|
|
@@ -49,6 +49,86 @@ const CSV_LINKEDIN_LIMITS = {
|
|
|
49
49
|
maxRows: MAX_CSV_LINKEDIN_UPLOAD_ROWS,
|
|
50
50
|
maxCarryColumns: MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS,
|
|
51
51
|
};
|
|
52
|
+
const RESERVED_WORKFLOW_COLUMNS = new Map([
|
|
53
|
+
"DNC Check",
|
|
54
|
+
"Enable ICP Filtering",
|
|
55
|
+
"ICP Score",
|
|
56
|
+
"ICP Alignment Notes",
|
|
57
|
+
"Passes Rubric",
|
|
58
|
+
"Generate Message",
|
|
59
|
+
"Message",
|
|
60
|
+
"Subject",
|
|
61
|
+
"Approved",
|
|
62
|
+
"Enable Scheduling",
|
|
63
|
+
"Scheduled",
|
|
64
|
+
"Scheduled At",
|
|
65
|
+
"Scheduled For",
|
|
66
|
+
"Sent",
|
|
67
|
+
"Sent At",
|
|
68
|
+
"Send Status",
|
|
69
|
+
"Delivery Status",
|
|
70
|
+
"Reply Status",
|
|
71
|
+
"Connection Status",
|
|
72
|
+
"Invite Status",
|
|
73
|
+
"InMail Status",
|
|
74
|
+
"DM Status",
|
|
75
|
+
"Status",
|
|
76
|
+
"Result",
|
|
77
|
+
"Error",
|
|
78
|
+
"Last Error",
|
|
79
|
+
"Generated Message",
|
|
80
|
+
"Message Draft",
|
|
81
|
+
"Message Status",
|
|
82
|
+
"Approval Status",
|
|
83
|
+
"Queued At",
|
|
84
|
+
"Processing Status",
|
|
85
|
+
"Workflow Status",
|
|
86
|
+
"Cell Status",
|
|
87
|
+
].map((name) => [normalizeReservedWorkflowHeaderKey(name), name]));
|
|
88
|
+
function normalizeReservedWorkflowHeaderKey(value) {
|
|
89
|
+
return value
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/\s*(?:\(\d+\)|[._-]\d+|\s+\d+)$/u, "")
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[\s_.-]+/g, " ")
|
|
94
|
+
.replace(/[^a-z0-9 ]+/g, "")
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
export function getReservedWorkflowColumnMatch(header) {
|
|
98
|
+
const key = normalizeReservedWorkflowHeaderKey(header);
|
|
99
|
+
if (!key) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const canonicalName = RESERVED_WORKFLOW_COLUMNS.get(key);
|
|
103
|
+
if (!canonicalName) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
canonicalName,
|
|
108
|
+
reason: "Sellable workflow/output columns are recreated by each campaign table and cannot be imported as lead source fields.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function buildReservedColumnWarning(ignored) {
|
|
112
|
+
if (ignored.length === 0) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const names = Array.from(new Set(ignored.map((column) => column.originalHeader)));
|
|
116
|
+
return `Detected campaign workflow columns and stripped them because Sellable recreates those columns in each campaign table: ${names.join(", ")}.`;
|
|
117
|
+
}
|
|
118
|
+
function hashJson(value) {
|
|
119
|
+
return createHash("sha256")
|
|
120
|
+
.update(JSON.stringify(value))
|
|
121
|
+
.digest("hex");
|
|
122
|
+
}
|
|
123
|
+
function buildContentFingerprint(params) {
|
|
124
|
+
return {
|
|
125
|
+
fileSha256: params.fileSha256,
|
|
126
|
+
sanitizedHeaderSha256: hashJson(params.headers),
|
|
127
|
+
selectedColumnsSha256: hashJson(params.selectedColumns),
|
|
128
|
+
ignoredReservedColumnsSha256: hashJson(params.ignoredReservedColumns),
|
|
129
|
+
rowCount: params.rowCount,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
52
132
|
function normalizeHeaderKey(value) {
|
|
53
133
|
return value
|
|
54
134
|
.trim()
|
|
@@ -61,6 +141,17 @@ function compareStringArrays(left, right) {
|
|
|
61
141
|
}
|
|
62
142
|
return left.every((value, index) => value === right[index]);
|
|
63
143
|
}
|
|
144
|
+
function compareIgnoredReservedColumns(left, right) {
|
|
145
|
+
if (left.length !== right.length) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return left.every((column, index) => {
|
|
149
|
+
const other = right[index];
|
|
150
|
+
return (column.originalHeader === other.originalHeader &&
|
|
151
|
+
column.canonicalName === other.canonicalName &&
|
|
152
|
+
column.reason === other.reason);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
64
155
|
function getLimits() {
|
|
65
156
|
return { ...CSV_LINKEDIN_LIMITS };
|
|
66
157
|
}
|
|
@@ -100,6 +191,30 @@ function buildRowObjects(headers, dataRows) {
|
|
|
100
191
|
return out;
|
|
101
192
|
});
|
|
102
193
|
}
|
|
194
|
+
function stripReservedWorkflowColumns(headers, dataRows) {
|
|
195
|
+
const keepIndexes = [];
|
|
196
|
+
const ignoredReservedColumns = [];
|
|
197
|
+
headers.forEach((header, index) => {
|
|
198
|
+
const match = getReservedWorkflowColumnMatch(header);
|
|
199
|
+
if (!match) {
|
|
200
|
+
keepIndexes.push(index);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
ignoredReservedColumns.push({
|
|
204
|
+
originalHeader: header,
|
|
205
|
+
canonicalName: match.canonicalName,
|
|
206
|
+
reason: match.reason,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
if (ignoredReservedColumns.length === 0) {
|
|
210
|
+
return { headers, dataRows, ignoredReservedColumns };
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
headers: keepIndexes.map((index) => headers[index]),
|
|
214
|
+
dataRows: dataRows.map((row) => keepIndexes.map((index) => String(row[index] ?? ""))),
|
|
215
|
+
ignoredReservedColumns,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
103
218
|
function resolveHeaderSelection(headers, requested, kind) {
|
|
104
219
|
const trimmed = requested.trim();
|
|
105
220
|
if (!trimmed) {
|
|
@@ -516,7 +631,17 @@ export function matchesLinkedinConfirmationToken(token, payload) {
|
|
|
516
631
|
parsed.fileSizeBytes === payload.fileSizeBytes &&
|
|
517
632
|
parsed.fileMtimeMs === payload.fileMtimeMs &&
|
|
518
633
|
parsed.linkedInColumn === payload.linkedInColumn &&
|
|
519
|
-
compareStringArrays(parsed.selectedColumns, payload.selectedColumns)
|
|
634
|
+
compareStringArrays(parsed.selectedColumns, payload.selectedColumns) &&
|
|
635
|
+
compareIgnoredReservedColumns(parsed.ignoredReservedColumns, payload.ignoredReservedColumns) &&
|
|
636
|
+
parsed.contentFingerprint.fileSha256 ===
|
|
637
|
+
payload.contentFingerprint.fileSha256 &&
|
|
638
|
+
parsed.contentFingerprint.sanitizedHeaderSha256 ===
|
|
639
|
+
payload.contentFingerprint.sanitizedHeaderSha256 &&
|
|
640
|
+
parsed.contentFingerprint.selectedColumnsSha256 ===
|
|
641
|
+
payload.contentFingerprint.selectedColumnsSha256 &&
|
|
642
|
+
parsed.contentFingerprint.ignoredReservedColumnsSha256 ===
|
|
643
|
+
payload.contentFingerprint.ignoredReservedColumnsSha256 &&
|
|
644
|
+
parsed.contentFingerprint.rowCount === payload.contentFingerprint.rowCount);
|
|
520
645
|
}
|
|
521
646
|
export function parseCsvLinkedinFile(input) {
|
|
522
647
|
const resolvedFilePath = resolveCsvLinkedinPath(input.filePath, input.workspaceRoot);
|
|
@@ -536,8 +661,9 @@ export function parseCsvLinkedinFile(input) {
|
|
|
536
661
|
blockingErrors.push(`CSV file exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_BYTES} byte limit.`);
|
|
537
662
|
}
|
|
538
663
|
let rawRows;
|
|
664
|
+
let content;
|
|
539
665
|
try {
|
|
540
|
-
|
|
666
|
+
content = readFileSync(resolvedFilePath, "utf8");
|
|
541
667
|
rawRows = parse(content, {
|
|
542
668
|
bom: true,
|
|
543
669
|
columns: false,
|
|
@@ -559,6 +685,9 @@ export function parseCsvLinkedinFile(input) {
|
|
|
559
685
|
headers: [],
|
|
560
686
|
rows: [],
|
|
561
687
|
totalRows: 0,
|
|
688
|
+
ignoredReservedColumns: [],
|
|
689
|
+
reservedColumnWarning: null,
|
|
690
|
+
fileSha256: "",
|
|
562
691
|
blockingErrors,
|
|
563
692
|
warnings,
|
|
564
693
|
};
|
|
@@ -567,6 +696,14 @@ export function parseCsvLinkedinFile(input) {
|
|
|
567
696
|
const dataRows = rawRows
|
|
568
697
|
.slice(1)
|
|
569
698
|
.map((row) => row.map((value) => String(value ?? "")));
|
|
699
|
+
const fileSha256 = createHash("sha256").update(content).digest("hex");
|
|
700
|
+
const stripped = stripReservedWorkflowColumns(headers, dataRows);
|
|
701
|
+
const sanitizedHeaders = stripped.headers;
|
|
702
|
+
const sanitizedDataRows = stripped.dataRows;
|
|
703
|
+
const reservedColumnWarning = buildReservedColumnWarning(stripped.ignoredReservedColumns);
|
|
704
|
+
if (reservedColumnWarning) {
|
|
705
|
+
warnings.push(reservedColumnWarning);
|
|
706
|
+
}
|
|
570
707
|
if (headers.length === 0) {
|
|
571
708
|
blockingErrors.push("CSV file must include at least one header column.");
|
|
572
709
|
}
|
|
@@ -574,7 +711,10 @@ export function parseCsvLinkedinFile(input) {
|
|
|
574
711
|
if (blankHeaderCount > 0) {
|
|
575
712
|
blockingErrors.push("CSV header row contains blank column names.");
|
|
576
713
|
}
|
|
577
|
-
|
|
714
|
+
if (sanitizedHeaders.length === 0 && headers.length > 0) {
|
|
715
|
+
blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; all usable columns were Sellable workflow/output columns.");
|
|
716
|
+
}
|
|
717
|
+
const duplicateHeaders = findDuplicateHeaders(sanitizedHeaders);
|
|
578
718
|
if (duplicateHeaders.length > 0) {
|
|
579
719
|
blockingErrors.push(`CSV header row contains duplicate columns: ${duplicateHeaders.join(", ")}`);
|
|
580
720
|
}
|
|
@@ -585,7 +725,9 @@ export function parseCsvLinkedinFile(input) {
|
|
|
585
725
|
if (dataRows.length > MAX_CSV_LINKEDIN_UPLOAD_ROWS) {
|
|
586
726
|
blockingErrors.push(`CSV contains ${dataRows.length} data rows, which exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_ROWS} row limit.`);
|
|
587
727
|
}
|
|
588
|
-
const rows = blockingErrors.length > 0
|
|
728
|
+
const rows = blockingErrors.length > 0
|
|
729
|
+
? []
|
|
730
|
+
: buildRowObjects(sanitizedHeaders, sanitizedDataRows);
|
|
589
731
|
if (rows.length === 0 && dataRows.length === 0) {
|
|
590
732
|
warnings.push("CSV contains headers but no data rows.");
|
|
591
733
|
}
|
|
@@ -594,9 +736,12 @@ export function parseCsvLinkedinFile(input) {
|
|
|
594
736
|
suggestedLeadListName: buildLeadListNameSuggestion(resolvedFilePath),
|
|
595
737
|
fileSizeBytes: stats.size,
|
|
596
738
|
fileMtimeMs: stats.mtimeMs,
|
|
597
|
-
headers,
|
|
739
|
+
headers: sanitizedHeaders,
|
|
598
740
|
rows,
|
|
599
741
|
totalRows: dataRows.length,
|
|
742
|
+
ignoredReservedColumns: stripped.ignoredReservedColumns,
|
|
743
|
+
reservedColumnWarning,
|
|
744
|
+
fileSha256,
|
|
600
745
|
blockingErrors,
|
|
601
746
|
warnings,
|
|
602
747
|
};
|
|
@@ -752,8 +897,13 @@ export function buildCsvLinkedinPreview(input) {
|
|
|
752
897
|
duplicateCount: 0,
|
|
753
898
|
emptyCount: 0,
|
|
754
899
|
};
|
|
900
|
+
if (!resolvedLinkedInColumn && parsed.ignoredReservedColumns.length > 0) {
|
|
901
|
+
blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; after stripping Sellable workflow/output columns, no LinkedIn profile URL column was available.");
|
|
902
|
+
}
|
|
755
903
|
if (resolvedLinkedInColumn && prepared.rows.length === 0) {
|
|
756
|
-
blockingErrors.push(
|
|
904
|
+
blockingErrors.push(parsed.ignoredReservedColumns.length > 0
|
|
905
|
+
? "CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; no valid LinkedIn profile URLs remained after stripping Sellable workflow/output columns."
|
|
906
|
+
: "CSV did not produce any valid LinkedIn profile URLs after validation.");
|
|
757
907
|
}
|
|
758
908
|
if (prepared.invalidCount > 0) {
|
|
759
909
|
warnings.push(`Skipped ${prepared.invalidCount} invalid LinkedIn URL row(s).`);
|
|
@@ -772,6 +922,14 @@ export function buildCsvLinkedinPreview(input) {
|
|
|
772
922
|
fileMtimeMs: parsed.fileMtimeMs,
|
|
773
923
|
linkedInColumn: resolvedLinkedInColumn,
|
|
774
924
|
selectedColumns,
|
|
925
|
+
ignoredReservedColumns: parsed.ignoredReservedColumns,
|
|
926
|
+
contentFingerprint: buildContentFingerprint({
|
|
927
|
+
fileSha256: parsed.fileSha256,
|
|
928
|
+
headers,
|
|
929
|
+
selectedColumns,
|
|
930
|
+
ignoredReservedColumns: parsed.ignoredReservedColumns,
|
|
931
|
+
rowCount: parsed.totalRows,
|
|
932
|
+
}),
|
|
775
933
|
}
|
|
776
934
|
: null;
|
|
777
935
|
return {
|
|
@@ -792,6 +950,8 @@ export function buildCsvLinkedinPreview(input) {
|
|
|
792
950
|
duplicateLinkedInCount: prepared.duplicateCount,
|
|
793
951
|
emptyLinkedInRowCount: prepared.emptyCount,
|
|
794
952
|
duplicatePolicy: "first_wins",
|
|
953
|
+
ignoredReservedColumns: parsed.ignoredReservedColumns,
|
|
954
|
+
reservedColumnWarning: parsed.reservedColumnWarning,
|
|
795
955
|
limits: getLimits(),
|
|
796
956
|
warnings,
|
|
797
957
|
blockingErrors,
|