salesprompter-cli 0.1.22 → 0.1.24

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.
@@ -0,0 +1,291 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ function escapeSqlString(value) {
5
+ return value.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
6
+ }
7
+ function sqlStringLiteral(value) {
8
+ if (value == null) {
9
+ return "NULL";
10
+ }
11
+ return `'${escapeSqlString(value)}'`;
12
+ }
13
+ function sqlNumberLiteral(value) {
14
+ if (value == null || Number.isNaN(value)) {
15
+ return "NULL";
16
+ }
17
+ return String(value);
18
+ }
19
+ function sqlBooleanLiteral(value) {
20
+ if (value == null) {
21
+ return "NULL";
22
+ }
23
+ return value ? "TRUE" : "FALSE";
24
+ }
25
+ const DEFAULT_HUNTER_INPUT_TABLE = "icpidentifier.SalesPrompter.hunter_emailFinder_input";
26
+ const DEFAULT_HUNTER_OUTPUT_TABLE = "icpidentifier.SalesPrompter.hunter_emailFinder_output";
27
+ const DEFAULT_HUNTER_API_BASE_URL = "https://api.hunter.io/v2";
28
+ const DEFAULT_HUNTER_RATE_LIMIT_PER_MINUTE = 300;
29
+ const DEFAULT_HUNTER_REQUEST_TIMEOUT_MS = 30_000;
30
+ const DEFAULT_HUNTER_LOCATION = "europe-west3";
31
+ const ENV_FILE_PATHS = [
32
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env.local"),
33
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env"),
34
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.local"),
35
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.vercel.local")
36
+ ];
37
+ const envFileCache = new Map();
38
+ export class HunterRateLimitError extends Error {
39
+ constructor(message = "Hunter rate limit exceeded") {
40
+ super(message);
41
+ this.name = "HunterRateLimitError";
42
+ }
43
+ }
44
+ function parseDotEnvFile(filePath) {
45
+ if (envFileCache.has(filePath)) {
46
+ return envFileCache.get(filePath);
47
+ }
48
+ const parsed = new Map();
49
+ if (existsSync(filePath)) {
50
+ const content = readFileSync(filePath, "utf8");
51
+ for (const line of content.split(/\r?\n/)) {
52
+ const trimmed = line.trim();
53
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
54
+ continue;
55
+ }
56
+ const separatorIndex = trimmed.indexOf("=");
57
+ if (separatorIndex <= 0) {
58
+ continue;
59
+ }
60
+ const key = trimmed.slice(0, separatorIndex).trim();
61
+ let value = trimmed.slice(separatorIndex + 1).trim();
62
+ if ((value.startsWith('"') && value.endsWith('"')) ||
63
+ (value.startsWith("'") && value.endsWith("'"))) {
64
+ value = value.slice(1, -1);
65
+ }
66
+ if (key.length > 0 && value.length > 0) {
67
+ parsed.set(key, value);
68
+ }
69
+ }
70
+ }
71
+ envFileCache.set(filePath, parsed);
72
+ return parsed;
73
+ }
74
+ function resolveConfiguredEnvValue(env, key) {
75
+ const directValue = env[key]?.trim() || "";
76
+ if (directValue.length > 0) {
77
+ return directValue;
78
+ }
79
+ if (env !== process.env) {
80
+ return null;
81
+ }
82
+ for (const filePath of ENV_FILE_PATHS) {
83
+ const parsed = parseDotEnvFile(filePath);
84
+ const value = parsed.get(key)?.trim() || "";
85
+ if (value.length > 0) {
86
+ return value;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ function requireTrimmedString(row, field) {
92
+ const value = String(row[field] ?? "").trim();
93
+ if (value.length === 0) {
94
+ throw new Error(`Hunter email input row missing required field: ${field}`);
95
+ }
96
+ return value;
97
+ }
98
+ function parseNullableInt(value) {
99
+ if (value == null || value === "") {
100
+ return null;
101
+ }
102
+ const parsed = Number(value);
103
+ if (!Number.isInteger(parsed)) {
104
+ throw new Error(`Expected integer-compatible value, received ${String(value)}`);
105
+ }
106
+ return parsed;
107
+ }
108
+ export function resolveHunterApiKey(env = process.env) {
109
+ return resolveConfiguredEnvValue(env, "HUNTER_API_KEY");
110
+ }
111
+ export function getHunterLocation(env = process.env) {
112
+ return resolveConfiguredEnvValue(env, "HUNTER_BIGQUERY_LOCATION") ?? DEFAULT_HUNTER_LOCATION;
113
+ }
114
+ export function buildHunterEmailFinderInputSql(options) {
115
+ const trimmedQuery = options.sqlQuery?.trim() || "";
116
+ if (trimmedQuery.length > 0) {
117
+ if (options.clientId == null) {
118
+ return trimmedQuery;
119
+ }
120
+ const upper = trimmedQuery.toUpperCase();
121
+ return `${trimmedQuery}\n${upper.includes(" WHERE ") ? "AND" : "WHERE"} clientId = ${options.clientId}`;
122
+ }
123
+ const table = options.table ?? DEFAULT_HUNTER_INPUT_TABLE;
124
+ const where = options.clientId == null ? "" : `\nWHERE clientId = ${options.clientId}`;
125
+ return [
126
+ "SELECT",
127
+ " clientId,",
128
+ " contactId,",
129
+ " companyId,",
130
+ " firstName_cleaned,",
131
+ " lastName_cleaned,",
132
+ " domain",
133
+ `FROM \`${table}\``,
134
+ `${where}`,
135
+ "ORDER BY clientId, contactId",
136
+ `LIMIT ${options.limit}`
137
+ ]
138
+ .filter((line) => line.length > 0)
139
+ .join("\n");
140
+ }
141
+ export function normalizeHunterEmailInputRows(rows) {
142
+ return rows.map((row) => ({
143
+ clientId: parseNullableInt(row.clientId) ?? 0,
144
+ contactId: parseNullableInt(row.contactId) ?? 0,
145
+ companyId: parseNullableInt(row.companyId),
146
+ firstName: requireTrimmedString(row, "firstName_cleaned"),
147
+ lastName: requireTrimmedString(row, "lastName_cleaned"),
148
+ domain: requireTrimmedString(row, "domain")
149
+ }));
150
+ }
151
+ function normalizeHunterErrorCode(payload) {
152
+ const errors = Array.isArray(payload?.errors) ? payload.errors : [];
153
+ const firstError = (errors[0] ?? null);
154
+ const code = String(firstError?.code ?? payload?.code ?? "").trim();
155
+ if (code.length > 0) {
156
+ return code.toLowerCase();
157
+ }
158
+ const message = String(firstError?.details ?? payload?.message ?? "").trim();
159
+ return message.length > 0 ? message.toLowerCase().replace(/\s+/g, "_") : null;
160
+ }
161
+ export async function lookupHunterEmail(input, options) {
162
+ const fetchImpl = options.fetchImpl ?? fetch;
163
+ const baseUrl = options.baseUrl ?? DEFAULT_HUNTER_API_BASE_URL;
164
+ const timeoutMs = options.timeoutMs ?? DEFAULT_HUNTER_REQUEST_TIMEOUT_MS;
165
+ const controller = new AbortController();
166
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
167
+ const ts = new Date().toISOString();
168
+ try {
169
+ const url = new URL("/email-finder", baseUrl);
170
+ url.searchParams.set("api_key", options.apiKey);
171
+ url.searchParams.set("first_name", input.firstName);
172
+ url.searchParams.set("last_name", input.lastName);
173
+ url.searchParams.set("domain", input.domain);
174
+ url.searchParams.set("max_duration", "20");
175
+ const response = await fetchImpl(url, {
176
+ method: "GET",
177
+ signal: controller.signal
178
+ });
179
+ const payload = (await response.json().catch(() => null));
180
+ if (response.status === 429) {
181
+ throw new HunterRateLimitError();
182
+ }
183
+ if (!response.ok) {
184
+ return {
185
+ clientId: input.clientId,
186
+ contactId: input.contactId,
187
+ companyId: input.companyId,
188
+ firstName: input.firstName,
189
+ lastName: input.lastName,
190
+ domain: input.domain,
191
+ email: null,
192
+ score: null,
193
+ acceptAll: null,
194
+ error: normalizeHunterErrorCode(payload) ?? `http_${response.status}`,
195
+ ts
196
+ };
197
+ }
198
+ const data = (payload?.data ?? null);
199
+ const email = String(data?.email ?? "").trim() || null;
200
+ const scoreValue = data?.score;
201
+ const acceptAllValue = data?.accept_all;
202
+ return {
203
+ clientId: input.clientId,
204
+ contactId: input.contactId,
205
+ companyId: input.companyId,
206
+ firstName: String(data?.first_name ?? input.firstName).trim() || input.firstName,
207
+ lastName: String(data?.last_name ?? input.lastName).trim() || input.lastName,
208
+ domain: String(data?.domain ?? input.domain).trim() || input.domain,
209
+ email,
210
+ score: typeof scoreValue === "number" ? scoreValue : scoreValue == null ? null : Number(scoreValue),
211
+ acceptAll: typeof acceptAllValue === "boolean" ? acceptAllValue : null,
212
+ error: null,
213
+ ts
214
+ };
215
+ }
216
+ catch (error) {
217
+ if (error instanceof HunterRateLimitError) {
218
+ throw error;
219
+ }
220
+ if (error instanceof Error && error.name === "AbortError") {
221
+ return {
222
+ clientId: input.clientId,
223
+ contactId: input.contactId,
224
+ companyId: input.companyId,
225
+ firstName: input.firstName,
226
+ lastName: input.lastName,
227
+ domain: input.domain,
228
+ email: null,
229
+ score: null,
230
+ acceptAll: null,
231
+ error: "timeout",
232
+ ts
233
+ };
234
+ }
235
+ return {
236
+ clientId: input.clientId,
237
+ contactId: input.contactId,
238
+ companyId: input.companyId,
239
+ firstName: input.firstName,
240
+ lastName: input.lastName,
241
+ domain: input.domain,
242
+ email: null,
243
+ score: null,
244
+ acceptAll: null,
245
+ error: error instanceof Error ? error.message : String(error),
246
+ ts
247
+ };
248
+ }
249
+ finally {
250
+ clearTimeout(timeout);
251
+ }
252
+ }
253
+ export function buildHunterEmailFinderWritebackSql(rows, options = {}) {
254
+ if (rows.length === 0) {
255
+ throw new Error("No Hunter email result rows to write");
256
+ }
257
+ const table = options.table ?? DEFAULT_HUNTER_OUTPUT_TABLE;
258
+ const values = rows
259
+ .map((row) => `(${sqlNumberLiteral(row.clientId)}, ${sqlNumberLiteral(row.contactId)}, ${sqlStringLiteral(row.firstName)}, ${sqlStringLiteral(row.lastName)}, ${sqlStringLiteral(row.domain)}, ${sqlStringLiteral(row.email)}, ${sqlNumberLiteral(row.score)}, ${sqlBooleanLiteral(row.acceptAll)}, ${sqlStringLiteral(row.error)}, TIMESTAMP(${sqlStringLiteral(row.ts)}))`)
260
+ .join(",\n ");
261
+ return [
262
+ `INSERT INTO \`${table}\``,
263
+ " (clientId, contactId, firstName, lastName, domain, email, score, accept_all, error, ts)",
264
+ "VALUES",
265
+ ` ${values};`
266
+ ].join("\n");
267
+ }
268
+ export function summarizeHunterEmailResults(rows) {
269
+ return rows.reduce((summary, row) => {
270
+ summary.processed += 1;
271
+ if (row.email) {
272
+ summary.found += 1;
273
+ }
274
+ else if (row.error == null) {
275
+ summary.notFound += 1;
276
+ }
277
+ else {
278
+ summary.failed += 1;
279
+ }
280
+ return summary;
281
+ }, {
282
+ fetched: rows.length,
283
+ processed: 0,
284
+ found: 0,
285
+ notFound: 0,
286
+ failed: 0
287
+ });
288
+ }
289
+ export function getHunterRateLimitDelayMs() {
290
+ return Math.ceil((60 / DEFAULT_HUNTER_RATE_LIMIT_PER_MINUTE) * 1000 + 50);
291
+ }
package/dist/instantly.js CHANGED
@@ -43,7 +43,8 @@ function buildLeadPayload(lead, campaignId) {
43
43
  salesprompter_outreach_fit: lead.outreachFit,
44
44
  salesprompter_signals: lead.signals.join(", "),
45
45
  salesprompter_tech_stack: lead.techStack.join(", "),
46
- salesprompter_rationale: lead.rationale.join(" | ")
46
+ salesprompter_rationale: lead.rationale.join(" | "),
47
+ ...(lead.customVariables ?? {})
47
48
  }
48
49
  };
49
50
  }
@@ -1,11 +1,18 @@
1
- function marketCountrySql(market) {
1
+ function marketCountries(market) {
2
2
  if (market === "dach") {
3
- return '"DE", "AT", "CH"';
3
+ return ["DE", "AT", "CH"];
4
4
  }
5
5
  if (market === "europe") {
6
- return '"DE", "AT", "CH", "NL", "GB", "FR", "SE", "DK"';
6
+ return ["DE", "AT", "CH", "NL", "GB", "FR", "SE", "DK"];
7
+ }
8
+ return [];
9
+ }
10
+ function marketCountrySql(market) {
11
+ const countries = marketCountries(market);
12
+ if (countries.length === 0) {
13
+ return null;
7
14
  }
8
- return '"DE", "AT", "CH", "NL", "GB", "FR", "SE", "DK", "US"';
15
+ return countries.map((country) => `"${country}"`).join(", ");
9
16
  }
10
17
  function marketQueryTerms(market) {
11
18
  if (market === "dach") {
@@ -30,6 +37,16 @@ export function buildLeadlistsFunnelQueries(vendor, market) {
30
37
  const countries = marketCountrySql(market);
31
38
  const queryTerms = marketQueryTerms(market);
32
39
  const marketLikeClause = encodedLikeClause(queryTerms);
40
+ const countryInClause = countries === null ? "TRUE" : `c.countryCode IN (${countries})`;
41
+ const countryWithDomainClause = countries === null ? "c.domain IS NOT NULL" : `c.countryCode IN (${countries}) AND c.domain IS NOT NULL`;
42
+ const companyMarketClause = countries === null ? "TRUE" : `e.countryCode IN (${countries})`;
43
+ const companyUsableDomainClause = countries === null
44
+ ? "e.domain IS NOT NULL AND COALESCE(e.domainBlacklisted, FALSE) = FALSE"
45
+ : `e.countryCode IN (${countries}) AND e.domain IS NOT NULL AND COALESCE(e.domainBlacklisted, FALSE) = FALSE`;
46
+ const companyDomainNotFoundClause = countries === null
47
+ ? "COALESCE(e.company_emailDomainNotFound, FALSE) = TRUE"
48
+ : `e.countryCode IN (${countries}) AND COALESCE(e.company_emailDomainNotFound, FALSE) = TRUE`;
49
+ const downstreamCountryClause = countries === null ? "TRUE" : `UPPER(CAST(company_countryCode AS STRING)) IN (${countries})`;
33
50
  return {
34
51
  queryTerms,
35
52
  stages: [
@@ -73,10 +90,10 @@ SELECT
73
90
  COUNT(*) AS people_with_company_url,
74
91
  COUNTIF(companyIdFromUrl IS NOT NULL) AS people_with_extractable_company_id,
75
92
  COUNTIF(c.companyId IS NOT NULL) AS people_joined_to_company,
76
- COUNTIF(c.countryCode IN (${countries})) AS people_in_market_company,
77
- COUNTIF(c.countryCode IN (${countries}) AND c.domain IS NOT NULL) AS people_in_market_company_with_domain,
78
- COUNTIF(c.countryCode IN (${countries}) AND p.firstName IS NOT NULL) AS people_in_market_with_first_name,
79
- COUNTIF(c.countryCode IN (${countries}) AND p.lastName IS NOT NULL) AS people_in_market_with_last_name
93
+ COUNTIF(${countryInClause}) AS people_in_market_company,
94
+ COUNTIF(${countryWithDomainClause}) AS people_in_market_company_with_domain,
95
+ COUNTIF(${countryInClause} AND p.firstName IS NOT NULL) AS people_in_market_with_first_name,
96
+ COUNTIF(${countryInClause} AND p.lastName IS NOT NULL) AS people_in_market_with_last_name
80
97
  FROM people p
81
98
  LEFT JOIN companies c ON p.companyIdFromUrl = c.companyId`
82
99
  },
@@ -97,10 +114,10 @@ SELECT
97
114
  COUNT(DISTINCT CONCAT(CAST(h.companyId AS STRING),"|",CAST(h.leadListId AS STRING))) AS distinct_company_search_rows,
98
115
  COUNTIF(h.companyUrl IS NOT NULL OR h.regularCompanyUrl IS NOT NULL) AS with_linkedin_company_profile,
99
116
  COUNTIF(e.handle IS NOT NULL) AS with_handle,
100
- COUNTIF(e.countryCode IN (${countries})) AS in_market,
101
- COUNTIF(e.countryCode IN (${countries}) AND e.domain IS NOT NULL) AS with_domain,
102
- COUNTIF(e.countryCode IN (${countries}) AND e.domain IS NOT NULL AND COALESCE(e.domainBlacklisted, FALSE) = FALSE) AS with_usable_domain,
103
- COUNTIF(e.countryCode IN (${countries}) AND COALESCE(e.company_emailDomainNotFound, FALSE) = TRUE) AS domain_not_found
117
+ COUNTIF(${companyMarketClause}) AS in_market,
118
+ COUNTIF(${countries === null ? "e.domain IS NOT NULL" : `e.countryCode IN (${countries}) AND e.domain IS NOT NULL`}) AS with_domain,
119
+ COUNTIF(${companyUsableDomainClause}) AS with_usable_domain,
120
+ COUNTIF(${companyDomainNotFoundClause}) AS domain_not_found
104
121
  FROM hr_company_search h
105
122
  LEFT JOIN enriched e
106
123
  ON CAST(h.companyId AS STRING) = CAST(e.companyId AS STRING)
@@ -118,7 +135,7 @@ LEFT JOIN enriched e
118
135
  COUNTIF(email IS NOT NULL AND COALESCE(email_invalid, FALSE) = FALSE) AS with_valid_email
119
136
  FROM \`icpidentifier.SalesGPT.leadPool_new\`
120
137
  WHERE CAST(functionId AS STRING) = "12"
121
- AND UPPER(CAST(company_countryCode AS STRING)) IN (${countries})`
138
+ AND ${downstreamCountryClause}`
122
139
  }
123
140
  ],
124
141
  notes: [