lacspace-leads 0.2.1 → 1.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/README.md +115 -16
- package/dist/cli.js +680 -126
- package/dist/lib.cjs +577 -99
- package/dist/lib.d.cts +221 -8
- package/dist/lib.d.ts +221 -8
- package/dist/lib.js +554 -94
- package/package.json +7 -2
package/dist/lib.js
CHANGED
|
@@ -15,13 +15,44 @@ var ALL_FIELDS = [
|
|
|
15
15
|
"facebook",
|
|
16
16
|
"instagram",
|
|
17
17
|
"whatsapp",
|
|
18
|
+
"linkedin",
|
|
19
|
+
"twitter",
|
|
20
|
+
"youtube",
|
|
21
|
+
"tiktok",
|
|
22
|
+
"telegram",
|
|
23
|
+
"emailStatus",
|
|
18
24
|
"plusCode",
|
|
19
25
|
"latitude",
|
|
20
26
|
"longitude",
|
|
21
27
|
"hours",
|
|
22
28
|
"mapsUrl"
|
|
23
29
|
];
|
|
24
|
-
var ENRICHED_FIELDS = [
|
|
30
|
+
var ENRICHED_FIELDS = [
|
|
31
|
+
"email",
|
|
32
|
+
"facebook",
|
|
33
|
+
"instagram",
|
|
34
|
+
"whatsapp",
|
|
35
|
+
"linkedin",
|
|
36
|
+
"twitter",
|
|
37
|
+
"youtube",
|
|
38
|
+
"tiktok",
|
|
39
|
+
"telegram",
|
|
40
|
+
"emailStatus"
|
|
41
|
+
];
|
|
42
|
+
var FIELD_PRESETS = {
|
|
43
|
+
/** Just enough to place a business: name, phone, website. */
|
|
44
|
+
minimal: ["name", "phone", "website"],
|
|
45
|
+
/** Cold-outreach columns: who, how to reach them, where. */
|
|
46
|
+
outreach: ["name", "phone", "email", "website", "address"],
|
|
47
|
+
/** Contact-book columns incl. socials (implies enrichment). */
|
|
48
|
+
contact: ["name", "phone", "email", "website", "facebook", "instagram", "whatsapp"],
|
|
49
|
+
/** Map/geo columns for plotting. */
|
|
50
|
+
geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
|
|
51
|
+
/** Everything Maps shows, no website enrichment. */
|
|
52
|
+
full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
|
|
53
|
+
/** Every field, including enriched ones. */
|
|
54
|
+
everything: [...ALL_FIELDS]
|
|
55
|
+
};
|
|
25
56
|
var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
|
|
26
57
|
|
|
27
58
|
// src/query.ts
|
|
@@ -32,8 +63,38 @@ function composeQuery(opts) {
|
|
|
32
63
|
if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
|
|
33
64
|
return where ? `${type} in ${where}` : type;
|
|
34
65
|
}
|
|
35
|
-
function mapsSearchUrl(query) {
|
|
36
|
-
|
|
66
|
+
function mapsSearchUrl(query, opts = {}) {
|
|
67
|
+
const hl = (opts.hl ?? "en").split("-")[0] || "en";
|
|
68
|
+
const params = new URLSearchParams({ hl });
|
|
69
|
+
if (opts.gl) params.set("gl", opts.gl.toLowerCase());
|
|
70
|
+
return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
|
|
71
|
+
}
|
|
72
|
+
function expandQueries(opts) {
|
|
73
|
+
if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
|
|
74
|
+
const split = (s) => {
|
|
75
|
+
const parts = (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
76
|
+
return parts.length ? parts : [void 0];
|
|
77
|
+
};
|
|
78
|
+
const types = split(opts.type);
|
|
79
|
+
const cities = split(opts.city);
|
|
80
|
+
const areas = split(opts.area);
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const type of types) {
|
|
83
|
+
for (const city of cities) {
|
|
84
|
+
for (const area of areas) {
|
|
85
|
+
const entry = {};
|
|
86
|
+
if (type) entry.type = type;
|
|
87
|
+
if (city) entry.city = city;
|
|
88
|
+
if (area) entry.area = area;
|
|
89
|
+
out.push(entry);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function resolvePreset(name) {
|
|
96
|
+
if (!name) return void 0;
|
|
97
|
+
return FIELD_PRESETS[name.trim().toLowerCase()];
|
|
37
98
|
}
|
|
38
99
|
function normalizeFields(input) {
|
|
39
100
|
if (!input) return [...ALL_FIELDS];
|
|
@@ -74,12 +135,23 @@ function extractSocials(html) {
|
|
|
74
135
|
const hrefs = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? "");
|
|
75
136
|
for (const href of hrefs) {
|
|
76
137
|
const u = href.trim();
|
|
77
|
-
if (!
|
|
138
|
+
if (!u) continue;
|
|
139
|
+
if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?|dialog\//i.test(u)) {
|
|
78
140
|
out.facebook = absolutize(u);
|
|
79
|
-
} else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u)) {
|
|
141
|
+
} else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u) && !/\/(share|p)\//i.test(u)) {
|
|
80
142
|
out.instagram = absolutize(u);
|
|
81
143
|
} else if (!out.whatsapp && /(wa\.me\/|api\.whatsapp\.com\/|whatsapp:\/\/)/i.test(u)) {
|
|
82
144
|
out.whatsapp = u.startsWith("http") || u.startsWith("whatsapp") ? u : `https:${u}`;
|
|
145
|
+
} else if (!out.linkedin && /(^|\/\/|\.)linkedin\.com\/(company|in|school)\//i.test(u) && !/sharing|shareArticle/i.test(u)) {
|
|
146
|
+
out.linkedin = absolutize(u);
|
|
147
|
+
} else if (!out.twitter && /(^|\/\/|\.)(twitter|x)\.com\//i.test(u) && !/(intent|share|widgets|hashtag)\b/i.test(u)) {
|
|
148
|
+
out.twitter = absolutize(u);
|
|
149
|
+
} else if (!out.youtube && /(youtube\.com\/(channel|c|user|@)|youtu\.be\/)/i.test(u)) {
|
|
150
|
+
out.youtube = absolutize(u);
|
|
151
|
+
} else if (!out.tiktok && /(^|\/\/|\.)tiktok\.com\/@/i.test(u)) {
|
|
152
|
+
out.tiktok = absolutize(u);
|
|
153
|
+
} else if (!out.telegram && /(t\.me\/|telegram\.me\/)/i.test(u) && !/\/share\b/i.test(u)) {
|
|
154
|
+
out.telegram = absolutize(u);
|
|
83
155
|
}
|
|
84
156
|
}
|
|
85
157
|
return out;
|
|
@@ -130,11 +202,9 @@ async function enrichContacts(website, opts = {}) {
|
|
|
130
202
|
const email = extractEmails(page);
|
|
131
203
|
if (email) {
|
|
132
204
|
contacts.email = email;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!contacts
|
|
136
|
-
if (!contacts.instagram && s.instagram) contacts.instagram = s.instagram;
|
|
137
|
-
if (!contacts.whatsapp && s.whatsapp) contacts.whatsapp = s.whatsapp;
|
|
205
|
+
const s = extractSocials(page);
|
|
206
|
+
for (const k of Object.keys(s)) {
|
|
207
|
+
if (!contacts[k] && s[k]) contacts[k] = s[k];
|
|
138
208
|
}
|
|
139
209
|
break;
|
|
140
210
|
}
|
|
@@ -155,6 +225,7 @@ function filterLeads(leads, filters = {}) {
|
|
|
155
225
|
if (filters.hasPhone && !l.phone) return false;
|
|
156
226
|
if (filters.hasWebsite && !l.website) return false;
|
|
157
227
|
if (filters.hasEmail && !l.email) return false;
|
|
228
|
+
if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
|
|
158
229
|
return true;
|
|
159
230
|
});
|
|
160
231
|
}
|
|
@@ -162,7 +233,8 @@ function dedupeKey(lead, by) {
|
|
|
162
233
|
if (by === "none") return void 0;
|
|
163
234
|
if (by === "website") return normalizeHost(lead.website);
|
|
164
235
|
if (by === "phone") return lead.phone?.replace(/[^0-9]/g, "") || void 0;
|
|
165
|
-
return lead.name?.trim().toLowerCase() || void 0;
|
|
236
|
+
if (by === "name") return lead.name?.trim().toLowerCase() || void 0;
|
|
237
|
+
return normalizeHost(lead.website) ?? (lead.phone?.replace(/[^0-9]/g, "") || void 0) ?? (lead.name?.trim().toLowerCase() || void 0);
|
|
166
238
|
}
|
|
167
239
|
function normalizeHost(url) {
|
|
168
240
|
if (!url) return void 0;
|
|
@@ -189,7 +261,300 @@ function dedupeLeads(leads, by = "website") {
|
|
|
189
261
|
return out;
|
|
190
262
|
}
|
|
191
263
|
|
|
264
|
+
// src/normalize.ts
|
|
265
|
+
var TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|gbraid$|wbraid$|mc_|_hs|ref$|ref_src$|source$|igshid$|yclid$)/i;
|
|
266
|
+
function cleanWebsite(url) {
|
|
267
|
+
if (!url) return void 0;
|
|
268
|
+
let raw = url.trim();
|
|
269
|
+
if (!raw) return void 0;
|
|
270
|
+
const g = raw.match(/[?&](?:q|url)=([^&]+)/i);
|
|
271
|
+
if (g && /google\.[a-z.]+\/url/i.test(raw)) {
|
|
272
|
+
try {
|
|
273
|
+
raw = decodeURIComponent(g[1]);
|
|
274
|
+
} catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let u;
|
|
278
|
+
try {
|
|
279
|
+
u = new URL(raw.startsWith("http") ? raw : `https://${raw}`);
|
|
280
|
+
} catch {
|
|
281
|
+
return raw;
|
|
282
|
+
}
|
|
283
|
+
u.hash = "";
|
|
284
|
+
u.host = u.host.toLowerCase();
|
|
285
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
286
|
+
if (TRACKING_PARAMS.test(key)) u.searchParams.delete(key);
|
|
287
|
+
}
|
|
288
|
+
let href = u.toString();
|
|
289
|
+
if (u.pathname === "/" && !u.search) href = href.replace(/\/$/, "");
|
|
290
|
+
return href;
|
|
291
|
+
}
|
|
292
|
+
var CALLING_CODES = {
|
|
293
|
+
NP: "977",
|
|
294
|
+
IN: "91",
|
|
295
|
+
US: "1",
|
|
296
|
+
CA: "1",
|
|
297
|
+
GB: "44",
|
|
298
|
+
AU: "61",
|
|
299
|
+
NZ: "64",
|
|
300
|
+
SG: "65",
|
|
301
|
+
MY: "60",
|
|
302
|
+
ID: "62",
|
|
303
|
+
TH: "66",
|
|
304
|
+
PH: "63",
|
|
305
|
+
VN: "84",
|
|
306
|
+
BD: "880",
|
|
307
|
+
PK: "92",
|
|
308
|
+
LK: "94",
|
|
309
|
+
AE: "971",
|
|
310
|
+
SA: "966",
|
|
311
|
+
QA: "974",
|
|
312
|
+
KW: "965",
|
|
313
|
+
BH: "973",
|
|
314
|
+
OM: "968",
|
|
315
|
+
CN: "86",
|
|
316
|
+
HK: "852",
|
|
317
|
+
JP: "81",
|
|
318
|
+
KR: "82",
|
|
319
|
+
DE: "49",
|
|
320
|
+
FR: "33",
|
|
321
|
+
ES: "34",
|
|
322
|
+
IT: "39",
|
|
323
|
+
NL: "31",
|
|
324
|
+
BE: "32",
|
|
325
|
+
CH: "41",
|
|
326
|
+
AT: "43",
|
|
327
|
+
SE: "46",
|
|
328
|
+
NO: "47",
|
|
329
|
+
DK: "45",
|
|
330
|
+
FI: "358",
|
|
331
|
+
IE: "353",
|
|
332
|
+
PT: "351",
|
|
333
|
+
PL: "48",
|
|
334
|
+
ZA: "27",
|
|
335
|
+
NG: "234",
|
|
336
|
+
KE: "254",
|
|
337
|
+
EG: "20",
|
|
338
|
+
BR: "55",
|
|
339
|
+
MX: "52",
|
|
340
|
+
AR: "54",
|
|
341
|
+
TR: "90"
|
|
342
|
+
};
|
|
343
|
+
function callingCode(country) {
|
|
344
|
+
if (!country) return void 0;
|
|
345
|
+
const c = country.trim().replace(/^\+/, "");
|
|
346
|
+
if (/^\d{1,4}$/.test(c)) return c;
|
|
347
|
+
return CALLING_CODES[c.toUpperCase()];
|
|
348
|
+
}
|
|
349
|
+
function normalizePhone(raw, country) {
|
|
350
|
+
if (!raw) return void 0;
|
|
351
|
+
const trimmed = raw.trim();
|
|
352
|
+
if (!trimmed) return void 0;
|
|
353
|
+
const isIntl = /^\+/.test(trimmed) || /^00\d/.test(trimmed);
|
|
354
|
+
let digits = trimmed.replace(/[^0-9]/g, "");
|
|
355
|
+
if (/^00\d/.test(trimmed)) digits = digits.replace(/^00/, "");
|
|
356
|
+
const cc = callingCode(country);
|
|
357
|
+
if (isIntl) {
|
|
358
|
+
return plausible(digits) ? `+${digits}` : trimmed;
|
|
359
|
+
}
|
|
360
|
+
if (!cc) return trimmed;
|
|
361
|
+
if (digits.startsWith(cc)) return plausible(digits) ? `+${digits}` : trimmed;
|
|
362
|
+
const local = digits.replace(/^0+/, "");
|
|
363
|
+
const e164 = `${cc}${local}`;
|
|
364
|
+
return plausible(e164) ? `+${e164}` : trimmed;
|
|
365
|
+
}
|
|
366
|
+
function plausible(digits) {
|
|
367
|
+
return digits.length >= 8 && digits.length <= 15;
|
|
368
|
+
}
|
|
369
|
+
var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
|
|
370
|
+
function sortLeads(leads, by, dir) {
|
|
371
|
+
if (!by) return [...leads];
|
|
372
|
+
const desc = dir ? dir === "desc" : by !== "name";
|
|
373
|
+
const val = (l) => {
|
|
374
|
+
if (by === "name") return l.name?.toLowerCase();
|
|
375
|
+
if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
|
|
376
|
+
return l[by];
|
|
377
|
+
};
|
|
378
|
+
return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
|
|
379
|
+
const av = val(a.lead);
|
|
380
|
+
const bv = val(b.lead);
|
|
381
|
+
const aMissing = av === void 0 || av === "";
|
|
382
|
+
const bMissing = bv === void 0 || bv === "";
|
|
383
|
+
if (aMissing && bMissing) return a.i - b.i;
|
|
384
|
+
if (aMissing) return 1;
|
|
385
|
+
if (bMissing) return -1;
|
|
386
|
+
let cmp = 0;
|
|
387
|
+
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
|
|
388
|
+
else cmp = String(av).localeCompare(String(bv));
|
|
389
|
+
if (desc) cmp = -cmp;
|
|
390
|
+
return cmp !== 0 ? cmp : a.i - b.i;
|
|
391
|
+
}).map((x) => x.lead);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/verify.ts
|
|
395
|
+
import { resolveMx } from "dns/promises";
|
|
396
|
+
var EMAIL_SHAPE = /^[^\s@]+@([^\s@.]+\.)+[^\s@.]{2,}$/;
|
|
397
|
+
function emailFormatValid(email) {
|
|
398
|
+
return !!email && EMAIL_SHAPE.test(email.trim());
|
|
399
|
+
}
|
|
400
|
+
function emailDomain(email) {
|
|
401
|
+
if (!email) return void 0;
|
|
402
|
+
const at = email.lastIndexOf("@");
|
|
403
|
+
return at >= 0 ? email.slice(at + 1).trim().toLowerCase() || void 0 : void 0;
|
|
404
|
+
}
|
|
405
|
+
async function domainHasMail(domain, cache, timeoutMs) {
|
|
406
|
+
const cached = cache.get(domain);
|
|
407
|
+
if (cached !== void 0) return cached;
|
|
408
|
+
const timer = new Promise((res) => setTimeout(() => res(false), timeoutMs));
|
|
409
|
+
const lookup = resolveMx(domain).then((records) => Array.isArray(records) && records.length > 0).catch(() => false);
|
|
410
|
+
const ok = await Promise.race([lookup, timer]);
|
|
411
|
+
cache.set(domain, ok);
|
|
412
|
+
return ok;
|
|
413
|
+
}
|
|
414
|
+
async function verifyEmail(email, opts = {}) {
|
|
415
|
+
if (!email) return "unknown";
|
|
416
|
+
if (!emailFormatValid(email)) return "invalid-format";
|
|
417
|
+
const domain = emailDomain(email);
|
|
418
|
+
if (!domain) return "invalid-format";
|
|
419
|
+
const has = await domainHasMail(domain, opts.cache ?? /* @__PURE__ */ new Map(), opts.timeoutMs ?? 5e3);
|
|
420
|
+
return has ? "valid" : "no-mx";
|
|
421
|
+
}
|
|
422
|
+
async function verifyEmails(leads, opts = {}) {
|
|
423
|
+
const cache = /* @__PURE__ */ new Map();
|
|
424
|
+
const targets = leads.filter((l) => l.email);
|
|
425
|
+
const n = Math.max(1, Math.min(opts.concurrency ?? 5, targets.length || 1));
|
|
426
|
+
let next = 0;
|
|
427
|
+
let done = 0;
|
|
428
|
+
const verifyOpts = { cache };
|
|
429
|
+
if (opts.timeoutMs !== void 0) verifyOpts.timeoutMs = opts.timeoutMs;
|
|
430
|
+
const workers = Array.from({ length: n }, async () => {
|
|
431
|
+
for (; ; ) {
|
|
432
|
+
const i = next++;
|
|
433
|
+
if (i >= targets.length) return;
|
|
434
|
+
const lead = targets[i];
|
|
435
|
+
lead.emailStatus = await verifyEmail(lead.email, verifyOpts);
|
|
436
|
+
opts.onProgress?.(`verifying email ${++done}/${targets.length}: ${lead.email}\u2026`);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
await Promise.all(workers);
|
|
440
|
+
return leads;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/export.ts
|
|
444
|
+
import { stringify as csvStringify } from "@lacspace/csv";
|
|
445
|
+
import { jsonToXlsx } from "@lacspace/xlsx";
|
|
446
|
+
var HEADERS = {
|
|
447
|
+
name: "Name",
|
|
448
|
+
category: "Category",
|
|
449
|
+
rating: "Rating",
|
|
450
|
+
reviews: "Reviews",
|
|
451
|
+
priceLevel: "Price",
|
|
452
|
+
address: "Address",
|
|
453
|
+
phone: "Phone",
|
|
454
|
+
website: "Website",
|
|
455
|
+
email: "Email",
|
|
456
|
+
facebook: "Facebook",
|
|
457
|
+
instagram: "Instagram",
|
|
458
|
+
whatsapp: "WhatsApp",
|
|
459
|
+
linkedin: "LinkedIn",
|
|
460
|
+
twitter: "Twitter/X",
|
|
461
|
+
youtube: "YouTube",
|
|
462
|
+
tiktok: "TikTok",
|
|
463
|
+
telegram: "Telegram",
|
|
464
|
+
emailStatus: "Email Status",
|
|
465
|
+
plusCode: "Plus Code",
|
|
466
|
+
latitude: "Latitude",
|
|
467
|
+
longitude: "Longitude",
|
|
468
|
+
hours: "Hours",
|
|
469
|
+
mapsUrl: "Maps URL"
|
|
470
|
+
};
|
|
471
|
+
function toRows(leads, fields = ALL_FIELDS) {
|
|
472
|
+
return leads.map((lead) => {
|
|
473
|
+
const row = {};
|
|
474
|
+
for (const f of fields) {
|
|
475
|
+
const v = lead[f];
|
|
476
|
+
row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
|
|
477
|
+
}
|
|
478
|
+
return row;
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
|
|
482
|
+
if (format === "json" || format === "ndjson") {
|
|
483
|
+
const picked = leads.map((lead) => {
|
|
484
|
+
const o = {};
|
|
485
|
+
for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
|
|
486
|
+
return o;
|
|
487
|
+
});
|
|
488
|
+
const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
|
|
489
|
+
return { data, binary: false };
|
|
490
|
+
}
|
|
491
|
+
const rows = toRows(leads, fields);
|
|
492
|
+
if (format === "csv") {
|
|
493
|
+
const csvRows = rows;
|
|
494
|
+
return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
|
|
495
|
+
}
|
|
496
|
+
const columns = fields.map((f) => {
|
|
497
|
+
const header = HEADERS[f];
|
|
498
|
+
const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
|
|
499
|
+
return { header, width: Math.min(60, Math.max(8, widest + 2)) };
|
|
500
|
+
});
|
|
501
|
+
return {
|
|
502
|
+
data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
|
|
503
|
+
binary: true
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function computeStats(leads) {
|
|
507
|
+
const socialKeys = ["facebook", "instagram", "whatsapp", "linkedin", "twitter", "youtube", "tiktok", "telegram"];
|
|
508
|
+
const ratings = leads.map((l) => l.rating).filter((r) => typeof r === "number");
|
|
509
|
+
const stats = {
|
|
510
|
+
total: leads.length,
|
|
511
|
+
withPhone: leads.filter((l) => l.phone).length,
|
|
512
|
+
withWebsite: leads.filter((l) => l.website).length,
|
|
513
|
+
withEmail: leads.filter((l) => l.email).length,
|
|
514
|
+
withValidEmail: leads.filter((l) => l.emailStatus === "valid").length,
|
|
515
|
+
withSocial: leads.filter((l) => socialKeys.some((k) => l[k])).length
|
|
516
|
+
};
|
|
517
|
+
if (ratings.length) stats.avgRating = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
|
|
518
|
+
return stats;
|
|
519
|
+
}
|
|
520
|
+
var FIELD_BY_KEY = (() => {
|
|
521
|
+
const m = /* @__PURE__ */ new Map();
|
|
522
|
+
for (const f of ALL_FIELDS) {
|
|
523
|
+
m.set(f.toLowerCase(), f);
|
|
524
|
+
m.set(HEADERS[f].toLowerCase(), f);
|
|
525
|
+
}
|
|
526
|
+
return m;
|
|
527
|
+
})();
|
|
528
|
+
var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
|
|
529
|
+
function rowsToLeads(rows) {
|
|
530
|
+
return rows.map((row) => {
|
|
531
|
+
const lead = {};
|
|
532
|
+
for (const [key, raw] of Object.entries(row)) {
|
|
533
|
+
const field = FIELD_BY_KEY.get(key.trim().toLowerCase());
|
|
534
|
+
if (!field || raw === null || raw === void 0 || raw === "") continue;
|
|
535
|
+
if (NUMERIC_FIELDS.has(field)) {
|
|
536
|
+
const n = typeof raw === "number" ? raw : parseFloat(String(raw));
|
|
537
|
+
if (Number.isFinite(n)) lead[field] = n;
|
|
538
|
+
} else {
|
|
539
|
+
lead[field] = String(raw);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return lead;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
192
546
|
// src/scrape.ts
|
|
547
|
+
async function pool(items, n, fn, signal) {
|
|
548
|
+
let next = 0;
|
|
549
|
+
const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
|
|
550
|
+
for (; ; ) {
|
|
551
|
+
const i = next++;
|
|
552
|
+
if (i >= items.length || signal?.aborted) return;
|
|
553
|
+
await fn(items[i], i);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
await Promise.all(workers);
|
|
557
|
+
}
|
|
193
558
|
var LeadsError = class extends Error {
|
|
194
559
|
code;
|
|
195
560
|
cause;
|
|
@@ -227,17 +592,19 @@ function parseLatLng(url) {
|
|
|
227
592
|
if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
|
|
228
593
|
return {};
|
|
229
594
|
}
|
|
230
|
-
async function launchBrowser(headless) {
|
|
595
|
+
async function launchBrowser(headless, proxy) {
|
|
231
596
|
let lastErr;
|
|
597
|
+
const base = { headless };
|
|
598
|
+
if (proxy) base.proxy = { server: proxy };
|
|
232
599
|
for (const channel of ["chrome", "msedge"]) {
|
|
233
600
|
try {
|
|
234
|
-
return await chromium.launch({
|
|
601
|
+
return await chromium.launch({ ...base, channel });
|
|
235
602
|
} catch (e) {
|
|
236
603
|
lastErr = e;
|
|
237
604
|
}
|
|
238
605
|
}
|
|
239
606
|
try {
|
|
240
|
-
return await chromium.launch(
|
|
607
|
+
return await chromium.launch(base);
|
|
241
608
|
} catch (e) {
|
|
242
609
|
lastErr = e;
|
|
243
610
|
}
|
|
@@ -314,7 +681,13 @@ async function extractDetail(page, fields, fallbackName) {
|
|
|
314
681
|
}
|
|
315
682
|
if (fields.has("reviews")) {
|
|
316
683
|
const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
|
|
317
|
-
|
|
684
|
+
let count = parseReviewCount(rv);
|
|
685
|
+
if (count === void 0) {
|
|
686
|
+
const boxText = await box.innerText().catch(() => "");
|
|
687
|
+
const m = boxText.match(/\(([\d.,\s]+)\)/);
|
|
688
|
+
if (m) count = parseReviewCount(m[1]);
|
|
689
|
+
}
|
|
690
|
+
lead.reviews = count;
|
|
318
691
|
}
|
|
319
692
|
}
|
|
320
693
|
}
|
|
@@ -354,24 +727,31 @@ async function scrapeLeads(opts) {
|
|
|
354
727
|
const fields = new Set(normalizeFields(opts.fields ?? DEFAULT_FIELDS));
|
|
355
728
|
const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
|
|
356
729
|
const headless = opts.headless ?? false;
|
|
730
|
+
const retries = Math.max(0, Math.trunc(opts.retries ?? 1));
|
|
357
731
|
const onProgress = opts.onProgress;
|
|
732
|
+
const onLead = opts.onLead;
|
|
358
733
|
const signal = opts.signal;
|
|
359
734
|
const startedAt = Date.now();
|
|
360
735
|
const overBudget = () => opts.maxMs !== void 0 && Date.now() - startedAt > opts.maxMs;
|
|
361
|
-
const
|
|
736
|
+
const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
|
|
737
|
+
const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
|
|
738
|
+
const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
|
|
362
739
|
const collect = new Set(fields);
|
|
363
740
|
if (wantEnrich) collect.add("website");
|
|
364
741
|
const detailOnly = ["name", "mapsUrl"];
|
|
365
742
|
const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
|
|
366
743
|
onProgress?.(`searching Google Maps for "${query}"\u2026`);
|
|
367
|
-
const browser = await launchBrowser(headless);
|
|
744
|
+
const browser = await launchBrowser(headless, opts.proxy);
|
|
368
745
|
try {
|
|
746
|
+
const locale = opts.locale ?? "en-US";
|
|
369
747
|
const context = await browser.newContext({
|
|
370
748
|
viewport: { width: 1280, height: 900 },
|
|
371
|
-
locale
|
|
749
|
+
locale
|
|
372
750
|
});
|
|
373
751
|
const page = await context.newPage();
|
|
374
|
-
|
|
752
|
+
const urlOpts = { hl: locale };
|
|
753
|
+
if (opts.region) urlOpts.gl = opts.region;
|
|
754
|
+
await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
375
755
|
await dismissConsent(page);
|
|
376
756
|
await loadResults(page, limit, delayMs, onProgress, signal);
|
|
377
757
|
if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
|
|
@@ -399,44 +779,95 @@ async function scrapeLeads(opts) {
|
|
|
399
779
|
}
|
|
400
780
|
return lead;
|
|
401
781
|
});
|
|
782
|
+
for (const lead of leads) {
|
|
783
|
+
try {
|
|
784
|
+
onLead?.(lead);
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
787
|
+
}
|
|
402
788
|
} else {
|
|
403
789
|
for (let i = 0; i < cards.length; i++) {
|
|
404
790
|
if (signal?.aborted || overBudget()) break;
|
|
405
791
|
const card = cards[i];
|
|
406
792
|
onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
793
|
+
let lead;
|
|
794
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
795
|
+
try {
|
|
796
|
+
await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
797
|
+
await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
|
|
798
|
+
});
|
|
799
|
+
lead = await extractDetail(page, collect, card.name);
|
|
800
|
+
break;
|
|
801
|
+
} catch {
|
|
802
|
+
if (attempt < retries && !signal?.aborted) {
|
|
803
|
+
onProgress?.(` retrying ${i + 1}/${cards.length} (attempt ${attempt + 2})\u2026`);
|
|
804
|
+
if (delayMs) await page.waitForTimeout(pauseMs());
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (!lead) {
|
|
413
809
|
if (card.name && (collect.has("name") || collect.has("mapsUrl"))) {
|
|
414
|
-
|
|
415
|
-
if (collect.has("name"))
|
|
416
|
-
if (collect.has("mapsUrl"))
|
|
417
|
-
leads.push(partial);
|
|
810
|
+
lead = {};
|
|
811
|
+
if (collect.has("name")) lead.name = card.name;
|
|
812
|
+
if (collect.has("mapsUrl")) lead.mapsUrl = card.href;
|
|
418
813
|
}
|
|
419
814
|
}
|
|
420
|
-
if (
|
|
815
|
+
if (lead) {
|
|
816
|
+
leads.push(lead);
|
|
817
|
+
try {
|
|
818
|
+
onLead?.(lead);
|
|
819
|
+
} catch {
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (delayMs) await page.waitForTimeout(pauseMs());
|
|
421
823
|
}
|
|
422
824
|
}
|
|
423
825
|
onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
|
|
826
|
+
if (opts.cleanUrls ?? true) {
|
|
827
|
+
for (const lead of leads) {
|
|
828
|
+
if (lead.website) lead.website = cleanWebsite(lead.website);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
424
831
|
leads = dedupeLeads(leads, opts.dedupe ?? "website");
|
|
425
832
|
if (wantEnrich) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
833
|
+
const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
|
|
834
|
+
let done = 0;
|
|
835
|
+
await pool(
|
|
836
|
+
leads,
|
|
837
|
+
Math.max(1, Math.trunc(opts.concurrency ?? 3)),
|
|
838
|
+
async (lead) => {
|
|
839
|
+
if (overBudget()) return;
|
|
840
|
+
const site = lead.website;
|
|
841
|
+
if (site) {
|
|
842
|
+
onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
|
|
843
|
+
const c = await enrichContacts(site);
|
|
844
|
+
if (fields.has("email") && c.email) lead.email = c.email;
|
|
845
|
+
for (const f of socialFields) {
|
|
846
|
+
const v = c[f];
|
|
847
|
+
if (fields.has(f) && v) lead[f] = v;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (!fields.has("website")) delete lead.website;
|
|
851
|
+
},
|
|
852
|
+
signal
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (wantVerify && !signal?.aborted) {
|
|
856
|
+
onProgress?.("verifying email domains\u2026");
|
|
857
|
+
const vOpts = {
|
|
858
|
+
concurrency: Math.max(1, Math.trunc(opts.concurrency ?? 5))
|
|
859
|
+
};
|
|
860
|
+
if (onProgress) vOpts.onProgress = (m) => onProgress(m);
|
|
861
|
+
await verifyEmails(leads, vOpts);
|
|
862
|
+
}
|
|
863
|
+
if (opts.country && fields.has("phone")) {
|
|
864
|
+
for (const lead of leads) {
|
|
865
|
+
if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
|
|
437
866
|
}
|
|
438
867
|
}
|
|
439
868
|
if (opts.filters) leads = filterLeads(leads, opts.filters);
|
|
869
|
+
if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
|
|
870
|
+
if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
|
|
440
871
|
onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
|
|
441
872
|
return leads;
|
|
442
873
|
} finally {
|
|
@@ -444,62 +875,73 @@ async function scrapeLeads(opts) {
|
|
|
444
875
|
});
|
|
445
876
|
}
|
|
446
877
|
}
|
|
878
|
+
async function searchLeadsDetailed(opts) {
|
|
879
|
+
const started = Date.now();
|
|
880
|
+
const leads = await scrapeLeads(opts);
|
|
881
|
+
return { leads, stats: computeStats(leads), elapsedMs: Date.now() - started };
|
|
882
|
+
}
|
|
447
883
|
|
|
448
|
-
// src/
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
884
|
+
// src/batch.ts
|
|
885
|
+
async function searchLeadsBatch(queries, opts = {}) {
|
|
886
|
+
const {
|
|
887
|
+
total,
|
|
888
|
+
onProgress,
|
|
889
|
+
signal,
|
|
890
|
+
type: _t,
|
|
891
|
+
city: _c,
|
|
892
|
+
area: _a,
|
|
893
|
+
query: _q,
|
|
894
|
+
...shared
|
|
895
|
+
} = opts;
|
|
896
|
+
void _t;
|
|
897
|
+
void _c;
|
|
898
|
+
void _a;
|
|
899
|
+
void _q;
|
|
900
|
+
let merged = [];
|
|
901
|
+
const seenDedupe = shared.dedupe ?? "website";
|
|
902
|
+
for (let i = 0; i < queries.length; i++) {
|
|
903
|
+
if (signal?.aborted) break;
|
|
904
|
+
const q = queries[i];
|
|
905
|
+
const label = (() => {
|
|
906
|
+
try {
|
|
907
|
+
return composeQuery(q);
|
|
908
|
+
} catch {
|
|
909
|
+
return q.query ?? q.type ?? "search";
|
|
910
|
+
}
|
|
911
|
+
})();
|
|
912
|
+
onProgress?.(`[${i + 1}/${queries.length}] ${label}`);
|
|
913
|
+
const subOpts = {
|
|
914
|
+
...shared,
|
|
915
|
+
type: q.type ?? "",
|
|
916
|
+
// Defer sorting to the merged pass; keep per-search dedupe/filter local.
|
|
917
|
+
...q.query ? { query: q.query } : {},
|
|
918
|
+
...q.city ? { city: q.city } : {},
|
|
919
|
+
...q.area ? { area: q.area } : {}
|
|
920
|
+
};
|
|
921
|
+
delete subOpts.sort;
|
|
922
|
+
if (signal) subOpts.signal = signal;
|
|
923
|
+
if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
|
|
924
|
+
try {
|
|
925
|
+
const found = await scrapeLeads(subOpts);
|
|
926
|
+
merged = merged.concat(found);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
onProgress?.(` ! skipped "${label}": ${err.message}`);
|
|
476
929
|
}
|
|
477
|
-
return row;
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
|
|
481
|
-
if (format === "json") {
|
|
482
|
-
const picked = leads.map((lead) => {
|
|
483
|
-
const o = {};
|
|
484
|
-
for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
|
|
485
|
-
return o;
|
|
486
|
-
});
|
|
487
|
-
return { data: JSON.stringify(picked, null, 2), binary: false };
|
|
488
|
-
}
|
|
489
|
-
const rows = toRows(leads, fields);
|
|
490
|
-
if (format === "csv") {
|
|
491
|
-
const csvRows = rows;
|
|
492
|
-
return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
|
|
493
930
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
931
|
+
merged = dedupeLeads(merged, seenDedupe);
|
|
932
|
+
if (opts.sort) merged = sortLeads(merged, opts.sort, opts.sortDir);
|
|
933
|
+
if (total !== void 0 && total > 0) merged = merged.slice(0, total);
|
|
934
|
+
onProgress?.(`merged ${merged.length} unique lead${merged.length === 1 ? "" : "s"}.`);
|
|
935
|
+
return merged;
|
|
936
|
+
}
|
|
937
|
+
async function searchLeadsMulti(opts) {
|
|
938
|
+
const queries = expandQueries({
|
|
939
|
+
...opts.type ? { type: opts.type } : {},
|
|
940
|
+
...opts.city ? { city: opts.city } : {},
|
|
941
|
+
...opts.area ? { area: opts.area } : {},
|
|
942
|
+
...opts.query ? { query: opts.query } : {}
|
|
498
943
|
});
|
|
499
|
-
return
|
|
500
|
-
data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
|
|
501
|
-
binary: true
|
|
502
|
-
};
|
|
944
|
+
return searchLeadsBatch(queries, opts);
|
|
503
945
|
}
|
|
504
946
|
|
|
505
947
|
// src/convert.ts
|
|
@@ -577,27 +1019,45 @@ async function convertFile(input, opts = {}) {
|
|
|
577
1019
|
}
|
|
578
1020
|
export {
|
|
579
1021
|
ALL_FIELDS,
|
|
1022
|
+
CALLING_CODES,
|
|
1023
|
+
DEFAULT_FIELDS,
|
|
580
1024
|
ENRICHED_FIELDS,
|
|
1025
|
+
FIELD_PRESETS,
|
|
581
1026
|
LeadsError,
|
|
1027
|
+
callingCode,
|
|
1028
|
+
cleanWebsite,
|
|
582
1029
|
columnsOf,
|
|
583
1030
|
composeQuery,
|
|
1031
|
+
computeStats,
|
|
584
1032
|
convertFile,
|
|
585
1033
|
dedupeLeads,
|
|
586
1034
|
defaultFilename,
|
|
587
1035
|
detectFormat,
|
|
1036
|
+
emailDomain,
|
|
1037
|
+
emailFormatValid,
|
|
588
1038
|
enrichContacts,
|
|
1039
|
+
expandQueries,
|
|
589
1040
|
extractEmails,
|
|
590
1041
|
extractSocials,
|
|
591
1042
|
filterLeads,
|
|
592
1043
|
mapsSearchUrl,
|
|
593
1044
|
normalizeFields,
|
|
1045
|
+
normalizePhone,
|
|
594
1046
|
parseLatLng,
|
|
595
1047
|
parseRating,
|
|
596
1048
|
parseReviewCount,
|
|
597
1049
|
readRows,
|
|
1050
|
+
resolvePreset,
|
|
1051
|
+
rowsToLeads,
|
|
598
1052
|
scrapeLeads,
|
|
599
1053
|
scrapeLeads as searchLeads,
|
|
1054
|
+
searchLeadsBatch,
|
|
1055
|
+
searchLeadsDetailed,
|
|
1056
|
+
searchLeadsMulti,
|
|
600
1057
|
serialize,
|
|
601
1058
|
serializeRows,
|
|
602
|
-
|
|
1059
|
+
sortLeads,
|
|
1060
|
+
toRows,
|
|
1061
|
+
verifyEmail,
|
|
1062
|
+
verifyEmails
|
|
603
1063
|
};
|