indiecrm-cli 0.1.0 → 0.2.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/CODE_OF_CONDUCT.md +35 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/README.md +14 -2
  5. package/RESEARCH.md +346 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/affiliate-copy.js +49 -0
  8. package/dist/auth.js +453 -0
  9. package/dist/bigquery.js +199 -0
  10. package/dist/chrome-browser.js +231 -0
  11. package/dist/cli.js +19652 -0
  12. package/dist/company-identity-review.js +78 -0
  13. package/dist/company-leads.js +422 -0
  14. package/dist/company-recovery.js +84 -0
  15. package/dist/deel-outreach.js +469 -0
  16. package/dist/deel-salesnav.js +368 -0
  17. package/dist/direct-path.js +326 -0
  18. package/dist/domain.js +53 -0
  19. package/dist/domainfinder.js +764 -0
  20. package/dist/engine.js +216 -0
  21. package/dist/historical-queries.js +189 -0
  22. package/dist/hunter-emailfinder.js +252 -0
  23. package/dist/icp-templates.js +171 -0
  24. package/dist/indiecrm/commands.js +108 -0
  25. package/dist/indiecrm-cli.js +2 -104
  26. package/dist/instantly.js +136 -0
  27. package/dist/io.js +21 -0
  28. package/dist/leadlists-funnel.js +148 -0
  29. package/dist/linkedin-companies.js +562 -0
  30. package/dist/linkedin-product-details.js +1203 -0
  31. package/dist/linkedin-product-search.js +1081 -0
  32. package/dist/linkedin-products.js +786 -0
  33. package/dist/linkedin-session-contracts.js +3 -0
  34. package/dist/linkedin-session.js +846 -0
  35. package/dist/providers.js +1 -0
  36. package/dist/research-browser-preference.js +37 -0
  37. package/dist/sales-navigator.js +1231 -0
  38. package/dist/salesnav-backfill.js +710 -0
  39. package/dist/sample-data.js +34 -0
  40. package/dist/session-recovery.js +62 -0
  41. package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
  42. package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
  43. package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
  44. package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
  45. package/package.json +73 -14
package/dist/engine.js ADDED
@@ -0,0 +1,216 @@
1
+ import { z } from "zod";
2
+ import { SAMPLE_COMPANIES, SAMPLE_CONTACTS, SAMPLE_SIGNALS, SAMPLE_TECH } from "./sample-data.js";
3
+ function pickByIndex(items, index) {
4
+ const item = items[index % items.length];
5
+ if (item === undefined) {
6
+ throw new Error("sample data invariant violated");
7
+ }
8
+ return item;
9
+ }
10
+ function normalizeCompanySize(employeeCount) {
11
+ if (employeeCount < 50) {
12
+ return "1-49";
13
+ }
14
+ if (employeeCount < 200) {
15
+ return "50-199";
16
+ }
17
+ if (employeeCount < 500) {
18
+ return "200-499";
19
+ }
20
+ return "500+";
21
+ }
22
+ function deriveCompanyNameFromDomain(domain) {
23
+ const normalizedDomain = domain
24
+ .toLowerCase()
25
+ .replace(/^https?:\/\//, "")
26
+ .replace(/^www\./, "")
27
+ .split("/")[0];
28
+ const hostname = normalizedDomain.split(".")[0] ?? normalizedDomain;
29
+ return hostname
30
+ .split(/[-_]/)
31
+ .filter((part) => part.length > 0)
32
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
33
+ .join(" ");
34
+ }
35
+ function normalizeDomain(domain) {
36
+ return domain
37
+ .toLowerCase()
38
+ .replace(/^https?:\/\//, "")
39
+ .replace(/^www\./, "")
40
+ .split("/")[0] ?? domain;
41
+ }
42
+ function buildTargetCompany(target, fallback, icp) {
43
+ const domain = target.companyDomain?.trim().toLowerCase();
44
+ if (domain === undefined) {
45
+ return {
46
+ ...fallback,
47
+ keywords: [fallback.industry.toLowerCase(), fallback.region.toLowerCase()],
48
+ sources: ["sample-company-dataset"]
49
+ };
50
+ }
51
+ return {
52
+ companyName: target.companyName?.trim() || deriveCompanyNameFromDomain(domain),
53
+ domain,
54
+ industry: icp.industries[0] ?? fallback.industry,
55
+ region: icp.regions[0] ?? fallback.region,
56
+ employeeCount: fallback.employeeCount,
57
+ keywords: [domain.split(".")[0] ?? domain, ...(icp.industries.length > 0 ? [icp.industries[0] ?? ""] : [])].filter((value) => value.length > 0),
58
+ sources: ["domain-input", "icp-overrides"]
59
+ };
60
+ }
61
+ export class HeuristicCompanyProvider {
62
+ name = "heuristic-company";
63
+ mode = "fallback";
64
+ async resolveCompany(target, icp) {
65
+ const domain = target.companyDomain?.trim();
66
+ if (domain !== undefined && domain.length === 0) {
67
+ throw new Error("company domain cannot be empty");
68
+ }
69
+ const fallbackCompany = SAMPLE_COMPANIES.find((company) => company.domain === normalizeDomain(domain ?? "")) ?? SAMPLE_COMPANIES[0];
70
+ if (fallbackCompany === undefined) {
71
+ throw new Error("sample company invariant violated");
72
+ }
73
+ return buildTargetCompany({
74
+ ...target,
75
+ companyDomain: domain ? normalizeDomain(domain) : undefined
76
+ }, fallbackCompany, icp);
77
+ }
78
+ }
79
+ export class HeuristicPeopleSearchProvider {
80
+ name = "heuristic-people-search";
81
+ mode = "fallback";
82
+ async findPeople(account, icp, count) {
83
+ return Array.from({ length: count }, (_, index) => {
84
+ const contact = pickByIndex(SAMPLE_CONTACTS, index + 1);
85
+ const signals = [pickByIndex(SAMPLE_SIGNALS, index), pickByIndex(SAMPLE_SIGNALS, index + 2)];
86
+ const industry = icp.industries[index % Math.max(icp.industries.length, 1)] ?? account.industry;
87
+ const region = icp.regions[index % Math.max(icp.regions.length, 1)] ?? account.region;
88
+ const title = icp.titles[index % Math.max(icp.titles.length, 1)] ?? contact.title;
89
+ return {
90
+ companyName: account.companyName,
91
+ domain: account.domain,
92
+ industry,
93
+ region,
94
+ employeeCount: account.employeeCount,
95
+ contactName: contact.contactName,
96
+ title,
97
+ email: `${contact.contactName.toLowerCase().replaceAll(" ", ".")}@${account.domain}`,
98
+ source: account.sources.includes("domain-input") ? "heuristic-target-account" : "heuristic-seed",
99
+ signals
100
+ };
101
+ });
102
+ }
103
+ }
104
+ export class AccountLeadProvider {
105
+ companyProvider;
106
+ peopleSearchProvider;
107
+ constructor(companyProvider, peopleSearchProvider) {
108
+ this.companyProvider = companyProvider;
109
+ this.peopleSearchProvider = peopleSearchProvider;
110
+ }
111
+ async generateLeads(icp, count, target = {}) {
112
+ const account = await this.companyProvider.resolveCompany(target, icp);
113
+ const leads = await this.peopleSearchProvider.findPeople(account, icp, count);
114
+ const warnings = [];
115
+ if (this.companyProvider.mode === "fallback" || this.peopleSearchProvider.mode === "fallback") {
116
+ warnings.push("Using fallback providers. Leads are modeled contacts until a real company and people data provider is configured.");
117
+ }
118
+ return {
119
+ provider: `${this.companyProvider.name}+${this.peopleSearchProvider.name}`,
120
+ mode: this.companyProvider.mode === "real" && this.peopleSearchProvider.mode === "real" ? "real" : "fallback",
121
+ account,
122
+ leads,
123
+ warnings
124
+ };
125
+ }
126
+ }
127
+ export class HeuristicEnrichmentProvider {
128
+ async enrichLeads(leads) {
129
+ return leads.map((lead, index) => ({
130
+ ...lead,
131
+ techStack: [pickByIndex(SAMPLE_TECH, index), pickByIndex(SAMPLE_TECH, index + 3)],
132
+ crmFit: lead.employeeCount > 200 ? "high" : lead.employeeCount > 100 ? "medium" : "low",
133
+ outreachFit: lead.signals.some((signal) => signal.includes("outbound")) ? "high" : "medium",
134
+ buyingStage: lead.signals.some((signal) => signal.includes("funding")) ? "active-evaluation" : "solution-aware",
135
+ notes: [
136
+ `${lead.companyName} matches the ${lead.industry} segment.`,
137
+ `${lead.contactName} is likely close to revenue tooling decisions.`
138
+ ]
139
+ }));
140
+ }
141
+ }
142
+ export class HeuristicScoringProvider {
143
+ async scoreLeads(icp, leads) {
144
+ return leads.map((lead) => {
145
+ const rationale = [];
146
+ let score = 40;
147
+ if (icp.industries.includes(lead.industry)) {
148
+ score += 20;
149
+ rationale.push("Industry matches ICP.");
150
+ }
151
+ if (icp.regions.includes(lead.region)) {
152
+ score += 10;
153
+ rationale.push("Region matches ICP.");
154
+ }
155
+ const normalizedSize = normalizeCompanySize(lead.employeeCount);
156
+ if (icp.companySizes.includes(normalizedSize)) {
157
+ score += 10;
158
+ rationale.push("Company size matches ICP.");
159
+ }
160
+ if (icp.titles.includes(lead.title)) {
161
+ score += 10;
162
+ rationale.push("Contact title matches ICP.");
163
+ }
164
+ const requiredMatches = icp.requiredSignals.filter((signal) => lead.signals.includes(signal));
165
+ score += Math.min(requiredMatches.length * 5, 10);
166
+ if (requiredMatches.length > 0) {
167
+ rationale.push(`Matched ${requiredMatches.length} required buying signals.`);
168
+ }
169
+ const excludedMatches = icp.excludedSignals.filter((signal) => lead.signals.includes(signal));
170
+ score -= excludedMatches.length * 15;
171
+ if (excludedMatches.length > 0) {
172
+ rationale.push(`Matched ${excludedMatches.length} excluded signals.`);
173
+ }
174
+ if (lead.crmFit === "high") {
175
+ score += 5;
176
+ rationale.push("Strong CRM fit.");
177
+ }
178
+ if (lead.outreachFit === "high") {
179
+ score += 5;
180
+ rationale.push("Strong outreach fit.");
181
+ }
182
+ const clampedScore = z.number().int().min(0).max(100).parse(score);
183
+ const grade = clampedScore >= 85 ? "A" : clampedScore >= 70 ? "B" : clampedScore >= 55 ? "C" : "D";
184
+ return {
185
+ ...lead,
186
+ score: clampedScore,
187
+ grade,
188
+ rationale
189
+ };
190
+ });
191
+ }
192
+ }
193
+ export class DryRunSyncProvider {
194
+ async sync(target, leads, _options) {
195
+ return {
196
+ target,
197
+ synced: leads.length,
198
+ dryRun: true,
199
+ provider: "dry-run"
200
+ };
201
+ }
202
+ }
203
+ export class RoutedSyncProvider {
204
+ fallbackProvider;
205
+ instantlyProvider;
206
+ constructor(fallbackProvider, instantlyProvider) {
207
+ this.fallbackProvider = fallbackProvider;
208
+ this.instantlyProvider = instantlyProvider;
209
+ }
210
+ async sync(target, leads, options) {
211
+ if (target === "instantly") {
212
+ return this.instantlyProvider.sync(target, leads, options);
213
+ }
214
+ return this.fallbackProvider.sync(target, leads, options);
215
+ }
216
+ }
@@ -0,0 +1,189 @@
1
+ function decodeQuery(rawQuery) {
2
+ return decodeURIComponent(rawQuery);
3
+ }
4
+ function detectQueryKind(decodedQuery) {
5
+ if (decodedQuery.includes("/sales/search/people")) {
6
+ return "sales-people";
7
+ }
8
+ if (decodedQuery.includes("/sales/search/company")) {
9
+ return "sales-company";
10
+ }
11
+ if (decodedQuery.includes("/search/results/people/")) {
12
+ return "people";
13
+ }
14
+ return "other";
15
+ }
16
+ function normalizeText(text) {
17
+ return text.replaceAll("%20", " ").replaceAll("%2C", ",").replaceAll("%2B", "+").replaceAll("%2F", "/").trim();
18
+ }
19
+ function extractStructuredValues(decodedQuery, filterType) {
20
+ const values = [];
21
+ const seen = new Set();
22
+ function pushValue(value) {
23
+ const key = `${value.text}|${value.selection}`;
24
+ if (!seen.has(key)) {
25
+ seen.add(key);
26
+ values.push(value);
27
+ }
28
+ }
29
+ for (const match of decodedQuery.matchAll(new RegExp(`type:${filterType},values:List\\((.*?)\\)\\)`, "g"))) {
30
+ const block = match[1] ?? "";
31
+ for (const fullMatch of block.matchAll(/id:([^,\)]+),text:([^,\)]+),selectionType:([A-Z]+)/g)) {
32
+ pushValue({
33
+ id: fullMatch[1] ?? null,
34
+ text: normalizeText(fullMatch[2] ?? ""),
35
+ selection: fullMatch[3] ?? ""
36
+ });
37
+ }
38
+ for (const fullMatch of block.matchAll(/text:([^,\)]+),selectionType:([A-Z]+)/g)) {
39
+ pushValue({
40
+ id: null,
41
+ text: normalizeText(fullMatch[1] ?? ""),
42
+ selection: fullMatch[2] ?? ""
43
+ });
44
+ }
45
+ }
46
+ return values;
47
+ }
48
+ function extractDepartmentHeadcount(decodedQuery) {
49
+ return Array.from(decodedQuery.matchAll(/type:DEPARTMENT_HEADCOUNT,rangeValue:\(min:([^,\)]+),max:([^\)]+)\),selectedSubFilter:([^,\)]+)/g), (match) => ({
50
+ departmentId: match[3] ?? "",
51
+ min: match[1] ?? "",
52
+ max: match[2] ?? ""
53
+ }));
54
+ }
55
+ export function parseHistoricalQuery(rawQuery) {
56
+ const decodedQuery = decodeQuery(rawQuery);
57
+ const filterTypes = Array.from(new Set(Array.from(decodedQuery.matchAll(/type:([A-Z_]+)/g), (match) => match[1] ?? ""))).filter((value) => value.length > 0);
58
+ return {
59
+ queryKind: detectQueryKind(decodedQuery),
60
+ rawQuery,
61
+ decodedQuery,
62
+ filterTypes,
63
+ functionFilters: extractStructuredValues(decodedQuery, "FUNCTION"),
64
+ titleFilters: extractStructuredValues(decodedQuery, "CURRENT_TITLE"),
65
+ seniorityFilters: extractStructuredValues(decodedQuery, "SENIORITY_LEVEL"),
66
+ regionFilters: extractStructuredValues(decodedQuery, "REGION"),
67
+ headquartersFilters: extractStructuredValues(decodedQuery, "COMPANY_HEADQUARTERS"),
68
+ headcountFilters: extractStructuredValues(decodedQuery, "COMPANY_HEADCOUNT"),
69
+ departmentHeadcountFilters: extractDepartmentHeadcount(decodedQuery)
70
+ };
71
+ }
72
+ function includesConfiguredFunction(parsed, functionTexts) {
73
+ if (functionTexts.length === 0) {
74
+ return true;
75
+ }
76
+ const normalizedNeedles = functionTexts.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0);
77
+ return parsed.functionFilters.some((filter) => filter.selection === "INCLUDED" && normalizedNeedles.includes(filter.text.toLowerCase()));
78
+ }
79
+ export function analyzeHistoricalQueries(rows, options = {}) {
80
+ const selectedRows = rows.filter((row) => {
81
+ const parsed = parseHistoricalQuery(row.query);
82
+ const searchKindOk = options.searchKind === undefined || options.searchKind === "all" ? true : parsed.queryKind === options.searchKind;
83
+ const functionOk = includesConfiguredFunction(parsed, options.includeFunctionText ?? []);
84
+ return searchKindOk && functionOk;
85
+ });
86
+ const filterTypeCounts = new Map();
87
+ const functionCounts = new Map();
88
+ const titleCounts = new Map();
89
+ const seniorityCounts = new Map();
90
+ const regionCounts = new Map();
91
+ const headquartersCounts = new Map();
92
+ const headcountCounts = new Map();
93
+ const departmentHeadcountCounts = new Map();
94
+ const queryKinds = {
95
+ people: 0,
96
+ "sales-people": 0,
97
+ "sales-company": 0,
98
+ other: 0
99
+ };
100
+ for (const row of selectedRows) {
101
+ const parsed = parseHistoricalQuery(row.query);
102
+ queryKinds[parsed.queryKind] += row.freq;
103
+ for (const filterType of parsed.filterTypes) {
104
+ filterTypeCounts.set(filterType, (filterTypeCounts.get(filterType) ?? 0) + row.freq);
105
+ }
106
+ for (const filter of parsed.functionFilters) {
107
+ const key = `${filter.text}|${filter.selection}`;
108
+ functionCounts.set(key, (functionCounts.get(key) ?? 0) + row.freq);
109
+ }
110
+ for (const filter of parsed.titleFilters) {
111
+ const key = `${filter.text}|${filter.selection}`;
112
+ titleCounts.set(key, (titleCounts.get(key) ?? 0) + row.freq);
113
+ }
114
+ for (const filter of parsed.seniorityFilters) {
115
+ const key = `${filter.text}|${filter.selection}`;
116
+ seniorityCounts.set(key, (seniorityCounts.get(key) ?? 0) + row.freq);
117
+ }
118
+ for (const filter of parsed.regionFilters) {
119
+ const key = `${filter.text}|${filter.selection}`;
120
+ regionCounts.set(key, (regionCounts.get(key) ?? 0) + row.freq);
121
+ }
122
+ for (const filter of parsed.headquartersFilters) {
123
+ const key = `${filter.text}|${filter.selection}`;
124
+ headquartersCounts.set(key, (headquartersCounts.get(key) ?? 0) + row.freq);
125
+ }
126
+ for (const filter of parsed.headcountFilters) {
127
+ const key = `${filter.text}|${filter.selection}`;
128
+ headcountCounts.set(key, (headcountCounts.get(key) ?? 0) + row.freq);
129
+ }
130
+ for (const filter of parsed.departmentHeadcountFilters) {
131
+ const key = `${filter.departmentId}|${filter.min}|${filter.max}`;
132
+ departmentHeadcountCounts.set(key, (departmentHeadcountCounts.get(key) ?? 0) + row.freq);
133
+ }
134
+ }
135
+ function topPairs(map) {
136
+ return Array.from(map.entries())
137
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
138
+ .map(([key, count]) => ({ key, count }));
139
+ }
140
+ return {
141
+ sourceTables: Array.from(new Set(selectedRows.map((row) => row.sourceTable))),
142
+ distinctQueryRowsAnalyzed: selectedRows.length,
143
+ weightedQueryVolume: selectedRows.reduce((sum, row) => sum + row.freq, 0),
144
+ queryKinds,
145
+ filterTypeCountsTop: topPairs(filterTypeCounts).slice(0, 30).map(({ key, count }) => ({ filterType: key, count })),
146
+ functionValuesTop: topPairs(functionCounts)
147
+ .slice(0, 40)
148
+ .map(({ key, count }) => {
149
+ const [text, selection] = key.split("|");
150
+ return { text: text ?? "", selection: selection ?? "", count };
151
+ }),
152
+ titleValuesTop: topPairs(titleCounts)
153
+ .slice(0, 60)
154
+ .map(({ key, count }) => {
155
+ const [text, selection] = key.split("|");
156
+ return { text: text ?? "", selection: selection ?? "", count };
157
+ }),
158
+ seniorityValuesTop: topPairs(seniorityCounts)
159
+ .slice(0, 40)
160
+ .map(({ key, count }) => {
161
+ const [text, selection] = key.split("|");
162
+ return { text: text ?? "", selection: selection ?? "", count };
163
+ }),
164
+ regionValuesTop: topPairs(regionCounts)
165
+ .slice(0, 40)
166
+ .map(({ key, count }) => {
167
+ const [text, selection] = key.split("|");
168
+ return { text: text ?? "", selection: selection ?? "", count };
169
+ }),
170
+ headquartersValuesTop: topPairs(headquartersCounts)
171
+ .slice(0, 40)
172
+ .map(({ key, count }) => {
173
+ const [text, selection] = key.split("|");
174
+ return { text: text ?? "", selection: selection ?? "", count };
175
+ }),
176
+ headcountValuesTop: topPairs(headcountCounts)
177
+ .slice(0, 40)
178
+ .map(({ key, count }) => {
179
+ const [text, selection] = key.split("|");
180
+ return { text: text ?? "", selection: selection ?? "", count };
181
+ }),
182
+ departmentHeadcountTop: topPairs(departmentHeadcountCounts)
183
+ .slice(0, 20)
184
+ .map(({ key, count }) => {
185
+ const [departmentId, min, max] = key.split("|");
186
+ return { departmentId: departmentId ?? "", min: min ?? "", max: max ?? "", count };
187
+ })
188
+ };
189
+ }
@@ -0,0 +1,252 @@
1
+ import { z } from "zod";
2
+ const nullableNumber = z.union([z.string(), z.number()]).transform((value) => Number(value)).nullable().optional()
3
+ .transform((value) => (value == null || Number.isNaN(value) ? null : value));
4
+ const stringId = z.union([z.string(), z.number()]).transform((value) => String(value).trim()).refine((value) => value.length > 0);
5
+ export const hunterEmailfinderQueueRowSchema = z.object({
6
+ clientId: stringId,
7
+ contactId: stringId,
8
+ companyId: stringId,
9
+ firstName_cleaned: z.string().trim().min(1),
10
+ lastName_cleaned: z.string().trim().min(1),
11
+ domain: z.string().trim().min(1),
12
+ companyScore: nullableNumber,
13
+ seniorityId: nullableNumber
14
+ });
15
+ export const hunterEmailfinderQueueRowArraySchema = z.array(hunterEmailfinderQueueRowSchema);
16
+ export const directEmailEnrichmentInputRowArraySchema = z.array(z.object({
17
+ clientId: z.string().nullable(),
18
+ companyName: z.string().trim(),
19
+ fullName: z.string().trim()
20
+ }));
21
+ function detectLooseDelimiter(value) {
22
+ if (value.includes("\t")) {
23
+ return "\t";
24
+ }
25
+ if (value.includes(";")) {
26
+ return ";";
27
+ }
28
+ return ",";
29
+ }
30
+ function splitLooseDelimitedLine(value, delimiter) {
31
+ if (delimiter === "\t") {
32
+ return value.split("\t");
33
+ }
34
+ const parts = [];
35
+ let current = "";
36
+ let inQuotes = false;
37
+ for (let index = 0; index < value.length; index += 1) {
38
+ const char = value[index] ?? "";
39
+ if (char === "\"") {
40
+ inQuotes = !inQuotes;
41
+ continue;
42
+ }
43
+ if (char === delimiter && !inQuotes) {
44
+ parts.push(current);
45
+ current = "";
46
+ continue;
47
+ }
48
+ current += char;
49
+ }
50
+ parts.push(current);
51
+ return parts;
52
+ }
53
+ export function parseDirectEmailEnrichmentInput(content) {
54
+ const trimmed = content.trim();
55
+ if (!trimmed) {
56
+ return [];
57
+ }
58
+ if (trimmed.startsWith("[")) {
59
+ const parsed = z.array(z.object({
60
+ clientId: z.union([z.string(), z.number()]).nullish(),
61
+ companyName: z.string().nullish(),
62
+ fullName: z.string().nullish()
63
+ })).parse(JSON.parse(trimmed));
64
+ return parsed.map((row) => ({
65
+ clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
66
+ companyName: row.companyName?.trim() ?? "",
67
+ fullName: row.fullName?.trim() ?? ""
68
+ })).filter((row) => row.companyName.length > 0 || row.fullName.length > 0);
69
+ }
70
+ const lines = trimmed
71
+ .split(/\r?\n/)
72
+ .map((line) => line.trim())
73
+ .filter((line) => line.length > 0);
74
+ if (lines.length === 0) {
75
+ return [];
76
+ }
77
+ const delimiter = detectLooseDelimiter(lines[0] ?? "");
78
+ const headerValues = splitLooseDelimitedLine(lines[0] ?? "", delimiter).map((value) => value.trim().toLowerCase());
79
+ const hasHeader = headerValues.includes("fullname") ||
80
+ headerValues.includes("full_name") ||
81
+ headerValues.includes("companyname") ||
82
+ headerValues.includes("company_name");
83
+ if (hasHeader) {
84
+ const companyNameIndex = headerValues.findIndex((value) => ["companyname", "company_name"].includes(value));
85
+ const fullNameIndex = headerValues.findIndex((value) => ["fullname", "full_name", "contact_name", "name"].includes(value));
86
+ const clientIdIndex = headerValues.findIndex((value) => ["clientid", "client_id"].includes(value));
87
+ return lines.slice(1)
88
+ .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
89
+ .map((columns) => ({
90
+ clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
91
+ companyName: companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "",
92
+ fullName: fullNameIndex >= 0 ? columns[fullNameIndex] || "" : ""
93
+ }))
94
+ .filter((row) => row.companyName.length > 0 || row.fullName.length > 0);
95
+ }
96
+ return lines
97
+ .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
98
+ .map((columns) => {
99
+ if (columns.length >= 3) {
100
+ return {
101
+ clientId: columns[0] || null,
102
+ fullName: columns[1] || "",
103
+ companyName: columns[2] || ""
104
+ };
105
+ }
106
+ return {
107
+ clientId: null,
108
+ companyName: columns[0] || "",
109
+ fullName: columns[1] || ""
110
+ };
111
+ })
112
+ .filter((row) => row.companyName.length > 0 || row.fullName.length > 0);
113
+ }
114
+ export function resolveDirectEmailEnrichmentClientId(inputRows, clientIdOption) {
115
+ const explicitClientId = clientIdOption == null || String(clientIdOption).trim().length === 0
116
+ ? null
117
+ : z.coerce.number().int().positive().parse(clientIdOption);
118
+ const rowClientIds = Array.from(new Set(inputRows
119
+ .map((row) => row.clientId?.trim() ?? "")
120
+ .filter((value) => value.length > 0)));
121
+ if (explicitClientId != null && rowClientIds.length > 0) {
122
+ const mismatched = rowClientIds.some((value) => Number(value) !== explicitClientId);
123
+ if (mismatched) {
124
+ throw new Error("Input rows contain multiple or mismatched clientId values. Pass one consistent --client-id or align the input.");
125
+ }
126
+ }
127
+ if (explicitClientId != null) {
128
+ return explicitClientId;
129
+ }
130
+ if (rowClientIds.length === 1) {
131
+ return z.coerce.number().int().positive().parse(rowClientIds[0]);
132
+ }
133
+ if (rowClientIds.length > 1) {
134
+ throw new Error("Input rows contain multiple clientId values. Pass --client-id to choose one batch client.");
135
+ }
136
+ return Math.max(1, Math.trunc(Date.now() / 1000));
137
+ }
138
+ export function buildHunterEmailfinderQueueSql(clientId, limit) {
139
+ return [
140
+ "SELECT",
141
+ " clientId,",
142
+ " contactId,",
143
+ " companyId,",
144
+ " firstName_cleaned,",
145
+ " lastName_cleaned,",
146
+ " domain,",
147
+ " companyScore,",
148
+ " seniorityId",
149
+ "FROM `icpidentifier.SalesPrompter.hunter_emailFinder_input`",
150
+ `WHERE clientId = ${clientId}`,
151
+ "ORDER BY companyScore DESC NULLS LAST, seniorityId DESC NULLS LAST, contactId",
152
+ `LIMIT ${limit}`
153
+ ].join("\n");
154
+ }
155
+ export function buildHunterEmailfinderTriggerPayload(params) {
156
+ return {
157
+ action: "run_hunter_emailfinder",
158
+ workflow_target: "hunter_emailFinder",
159
+ app_source: "salesprompter_cli",
160
+ integration: "hunter",
161
+ integration_type: "hunter",
162
+ trigger_source: "cli_hunter_emailfinder_run_bq",
163
+ trace_id: params.traceId,
164
+ queue_source: "icpidentifier.SalesPrompter.hunter_emailFinder_input",
165
+ output_table: "icpidentifier.SalesPrompter.hunter_emailFinder_output",
166
+ queue_client_id: params.clientId,
167
+ batch_count: params.queueRows.length,
168
+ inputs: params.queueRows,
169
+ payload: {
170
+ trace_id: params.traceId,
171
+ queue_source: "icpidentifier.SalesPrompter.hunter_emailFinder_input",
172
+ output_table: "icpidentifier.SalesPrompter.hunter_emailFinder_output",
173
+ clientId: params.clientId,
174
+ inputs: params.queueRows
175
+ }
176
+ };
177
+ }
178
+ export function readHunterEmailfinderConfig(env = process.env, endpointUrlOverride) {
179
+ const endpointId = env.PIPEDREAM_HUNTER_EMAILFINDER_ENDPOINT_ID?.trim() || "";
180
+ const endpointUrl = endpointUrlOverride?.trim() ||
181
+ env.SALESPROMPTER_HUNTER_EMAILFINDER_ENDPOINT_URL?.trim() ||
182
+ env.HUNTER_EMAILFINDER_ENDPOINT_URL?.trim() ||
183
+ (endpointId ? `https://${endpointId}.m.pipedream.net` : "");
184
+ if (!endpointUrl) {
185
+ throw new Error("Missing email enrichment endpoint. Set --endpoint-url, SALESPROMPTER_HUNTER_EMAILFINDER_ENDPOINT_URL, HUNTER_EMAILFINDER_ENDPOINT_URL, or PIPEDREAM_HUNTER_EMAILFINDER_ENDPOINT_ID.");
186
+ }
187
+ return {
188
+ endpointUrl,
189
+ secret: env.PIPEDREAM_SECRET_KEY?.trim() || "",
190
+ clientId: env.PIPEDREAM_CLIENT_ID?.trim() || "",
191
+ projectId: env.PIPEDREAM_PROJECT_ID?.trim() || "",
192
+ projectEnvironment: env.PIPEDREAM_PROJECT_ENVIRONMENT?.trim() || ""
193
+ };
194
+ }
195
+ export async function triggerHunterEmailfinderWorkflow(params) {
196
+ const endpoint = new URL(params.config.endpointUrl);
197
+ if (params.config.secret) {
198
+ endpoint.searchParams.set("secret", params.config.secret);
199
+ }
200
+ const headers = {
201
+ "Content-Type": "application/json",
202
+ "x-pd-external-user-id": params.externalUserId
203
+ };
204
+ if (params.config.secret) {
205
+ headers.Authorization = `Bearer ${params.config.secret}`;
206
+ headers["x-pd-secret"] = params.config.secret;
207
+ headers["x-secret-key"] = params.config.secret;
208
+ }
209
+ if (params.config.clientId) {
210
+ headers["X-Client-ID"] = params.config.clientId;
211
+ }
212
+ if (params.config.projectId) {
213
+ headers["X-Project-ID"] = params.config.projectId;
214
+ }
215
+ if (params.config.projectEnvironment) {
216
+ headers["X-Environment"] = params.config.projectEnvironment;
217
+ headers["x-pd-environment"] = params.config.projectEnvironment;
218
+ }
219
+ const controller = new AbortController();
220
+ const timeout = setTimeout(() => controller.abort(), params.timeoutMs);
221
+ try {
222
+ const response = await fetch(endpoint, {
223
+ method: "POST",
224
+ headers,
225
+ body: JSON.stringify(params.payload),
226
+ signal: controller.signal
227
+ });
228
+ const bodyText = await response.text();
229
+ let parsedBody = null;
230
+ try {
231
+ parsedBody = bodyText ? JSON.parse(bodyText) : null;
232
+ }
233
+ catch {
234
+ parsedBody = bodyText;
235
+ }
236
+ return {
237
+ endpoint: endpoint.toString(),
238
+ bodyText,
239
+ parsedBody,
240
+ response
241
+ };
242
+ }
243
+ catch (error) {
244
+ if (error.name === "AbortError") {
245
+ throw new Error(`Email enrichment workflow timed out after ${params.timeoutMs}ms.`);
246
+ }
247
+ throw error;
248
+ }
249
+ finally {
250
+ clearTimeout(timeout);
251
+ }
252
+ }