salesprompter-cli 0.1.23 → 0.1.25

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
+ }