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.
- package/CHANGELOG.md +447 -0
- package/CODE_OF_CONDUCT.md +35 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +14 -2
- package/RESEARCH.md +346 -0
- package/SECURITY.md +35 -0
- package/dist/affiliate-copy.js +49 -0
- package/dist/auth.js +453 -0
- package/dist/bigquery.js +199 -0
- package/dist/chrome-browser.js +231 -0
- package/dist/cli.js +19652 -0
- package/dist/company-identity-review.js +78 -0
- package/dist/company-leads.js +422 -0
- package/dist/company-recovery.js +84 -0
- package/dist/deel-outreach.js +469 -0
- package/dist/deel-salesnav.js +368 -0
- package/dist/direct-path.js +326 -0
- package/dist/domain.js +53 -0
- package/dist/domainfinder.js +764 -0
- package/dist/engine.js +216 -0
- package/dist/historical-queries.js +189 -0
- package/dist/hunter-emailfinder.js +252 -0
- package/dist/icp-templates.js +171 -0
- package/dist/indiecrm/commands.js +108 -0
- package/dist/indiecrm-cli.js +2 -104
- package/dist/instantly.js +136 -0
- package/dist/io.js +21 -0
- package/dist/leadlists-funnel.js +148 -0
- package/dist/linkedin-companies.js +562 -0
- package/dist/linkedin-product-details.js +1203 -0
- package/dist/linkedin-product-search.js +1081 -0
- package/dist/linkedin-products.js +786 -0
- package/dist/linkedin-session-contracts.js +3 -0
- package/dist/linkedin-session.js +846 -0
- package/dist/providers.js +1 -0
- package/dist/research-browser-preference.js +37 -0
- package/dist/sales-navigator.js +1231 -0
- package/dist/salesnav-backfill.js +710 -0
- package/dist/sample-data.js +34 -0
- package/dist/session-recovery.js +62 -0
- package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
- package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
- package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
- package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
- 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
|
+
}
|