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/dist/lib.cjs CHANGED
@@ -21,29 +21,47 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var lib_exports = {};
22
22
  __export(lib_exports, {
23
23
  ALL_FIELDS: () => ALL_FIELDS,
24
+ CALLING_CODES: () => CALLING_CODES,
25
+ DEFAULT_FIELDS: () => DEFAULT_FIELDS,
24
26
  ENRICHED_FIELDS: () => ENRICHED_FIELDS,
27
+ FIELD_PRESETS: () => FIELD_PRESETS,
25
28
  LeadsError: () => LeadsError,
29
+ callingCode: () => callingCode,
30
+ cleanWebsite: () => cleanWebsite,
26
31
  columnsOf: () => columnsOf,
27
32
  composeQuery: () => composeQuery,
33
+ computeStats: () => computeStats,
28
34
  convertFile: () => convertFile,
29
35
  dedupeLeads: () => dedupeLeads,
30
36
  defaultFilename: () => defaultFilename,
31
37
  detectFormat: () => detectFormat,
38
+ emailDomain: () => emailDomain,
39
+ emailFormatValid: () => emailFormatValid,
32
40
  enrichContacts: () => enrichContacts,
41
+ expandQueries: () => expandQueries,
33
42
  extractEmails: () => extractEmails,
34
43
  extractSocials: () => extractSocials,
35
44
  filterLeads: () => filterLeads,
36
45
  mapsSearchUrl: () => mapsSearchUrl,
37
46
  normalizeFields: () => normalizeFields,
47
+ normalizePhone: () => normalizePhone,
38
48
  parseLatLng: () => parseLatLng,
39
49
  parseRating: () => parseRating,
40
50
  parseReviewCount: () => parseReviewCount,
41
51
  readRows: () => readRows,
52
+ resolvePreset: () => resolvePreset,
53
+ rowsToLeads: () => rowsToLeads,
42
54
  scrapeLeads: () => scrapeLeads,
43
55
  searchLeads: () => scrapeLeads,
56
+ searchLeadsBatch: () => searchLeadsBatch,
57
+ searchLeadsDetailed: () => searchLeadsDetailed,
58
+ searchLeadsMulti: () => searchLeadsMulti,
44
59
  serialize: () => serialize,
45
60
  serializeRows: () => serializeRows,
46
- toRows: () => toRows
61
+ sortLeads: () => sortLeads,
62
+ toRows: () => toRows,
63
+ verifyEmail: () => verifyEmail,
64
+ verifyEmails: () => verifyEmails
47
65
  });
48
66
  module.exports = __toCommonJS(lib_exports);
49
67
 
@@ -64,13 +82,44 @@ var ALL_FIELDS = [
64
82
  "facebook",
65
83
  "instagram",
66
84
  "whatsapp",
85
+ "linkedin",
86
+ "twitter",
87
+ "youtube",
88
+ "tiktok",
89
+ "telegram",
90
+ "emailStatus",
67
91
  "plusCode",
68
92
  "latitude",
69
93
  "longitude",
70
94
  "hours",
71
95
  "mapsUrl"
72
96
  ];
73
- var ENRICHED_FIELDS = ["email", "facebook", "instagram", "whatsapp"];
97
+ var ENRICHED_FIELDS = [
98
+ "email",
99
+ "facebook",
100
+ "instagram",
101
+ "whatsapp",
102
+ "linkedin",
103
+ "twitter",
104
+ "youtube",
105
+ "tiktok",
106
+ "telegram",
107
+ "emailStatus"
108
+ ];
109
+ var FIELD_PRESETS = {
110
+ /** Just enough to place a business: name, phone, website. */
111
+ minimal: ["name", "phone", "website"],
112
+ /** Cold-outreach columns: who, how to reach them, where. */
113
+ outreach: ["name", "phone", "email", "website", "address"],
114
+ /** Contact-book columns incl. socials (implies enrichment). */
115
+ contact: ["name", "phone", "email", "website", "facebook", "instagram", "whatsapp"],
116
+ /** Map/geo columns for plotting. */
117
+ geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
118
+ /** Everything Maps shows, no website enrichment. */
119
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
120
+ /** Every field, including enriched ones. */
121
+ everything: [...ALL_FIELDS]
122
+ };
74
123
  var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
75
124
 
76
125
  // src/query.ts
@@ -81,8 +130,38 @@ function composeQuery(opts) {
81
130
  if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
82
131
  return where ? `${type} in ${where}` : type;
83
132
  }
84
- function mapsSearchUrl(query) {
85
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
133
+ function mapsSearchUrl(query, opts = {}) {
134
+ const hl = (opts.hl ?? "en").split("-")[0] || "en";
135
+ const params = new URLSearchParams({ hl });
136
+ if (opts.gl) params.set("gl", opts.gl.toLowerCase());
137
+ return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
138
+ }
139
+ function expandQueries(opts) {
140
+ if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
141
+ const split = (s) => {
142
+ const parts = (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
143
+ return parts.length ? parts : [void 0];
144
+ };
145
+ const types = split(opts.type);
146
+ const cities = split(opts.city);
147
+ const areas = split(opts.area);
148
+ const out = [];
149
+ for (const type of types) {
150
+ for (const city of cities) {
151
+ for (const area of areas) {
152
+ const entry = {};
153
+ if (type) entry.type = type;
154
+ if (city) entry.city = city;
155
+ if (area) entry.area = area;
156
+ out.push(entry);
157
+ }
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ function resolvePreset(name) {
163
+ if (!name) return void 0;
164
+ return FIELD_PRESETS[name.trim().toLowerCase()];
86
165
  }
87
166
  function normalizeFields(input) {
88
167
  if (!input) return [...ALL_FIELDS];
@@ -123,12 +202,23 @@ function extractSocials(html) {
123
202
  const hrefs = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? "");
124
203
  for (const href of hrefs) {
125
204
  const u = href.trim();
126
- if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?/i.test(u)) {
205
+ if (!u) continue;
206
+ if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?|dialog\//i.test(u)) {
127
207
  out.facebook = absolutize(u);
128
- } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u)) {
208
+ } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u) && !/\/(share|p)\//i.test(u)) {
129
209
  out.instagram = absolutize(u);
130
210
  } else if (!out.whatsapp && /(wa\.me\/|api\.whatsapp\.com\/|whatsapp:\/\/)/i.test(u)) {
131
211
  out.whatsapp = u.startsWith("http") || u.startsWith("whatsapp") ? u : `https:${u}`;
212
+ } else if (!out.linkedin && /(^|\/\/|\.)linkedin\.com\/(company|in|school)\//i.test(u) && !/sharing|shareArticle/i.test(u)) {
213
+ out.linkedin = absolutize(u);
214
+ } else if (!out.twitter && /(^|\/\/|\.)(twitter|x)\.com\//i.test(u) && !/(intent|share|widgets|hashtag)\b/i.test(u)) {
215
+ out.twitter = absolutize(u);
216
+ } else if (!out.youtube && /(youtube\.com\/(channel|c|user|@)|youtu\.be\/)/i.test(u)) {
217
+ out.youtube = absolutize(u);
218
+ } else if (!out.tiktok && /(^|\/\/|\.)tiktok\.com\/@/i.test(u)) {
219
+ out.tiktok = absolutize(u);
220
+ } else if (!out.telegram && /(t\.me\/|telegram\.me\/)/i.test(u) && !/\/share\b/i.test(u)) {
221
+ out.telegram = absolutize(u);
132
222
  }
133
223
  }
134
224
  return out;
@@ -179,11 +269,9 @@ async function enrichContacts(website, opts = {}) {
179
269
  const email = extractEmails(page);
180
270
  if (email) {
181
271
  contacts.email = email;
182
- if (!contacts.facebook || !contacts.instagram || !contacts.whatsapp) {
183
- const s = extractSocials(page);
184
- if (!contacts.facebook && s.facebook) contacts.facebook = s.facebook;
185
- if (!contacts.instagram && s.instagram) contacts.instagram = s.instagram;
186
- if (!contacts.whatsapp && s.whatsapp) contacts.whatsapp = s.whatsapp;
272
+ const s = extractSocials(page);
273
+ for (const k of Object.keys(s)) {
274
+ if (!contacts[k] && s[k]) contacts[k] = s[k];
187
275
  }
188
276
  break;
189
277
  }
@@ -204,6 +292,7 @@ function filterLeads(leads, filters = {}) {
204
292
  if (filters.hasPhone && !l.phone) return false;
205
293
  if (filters.hasWebsite && !l.website) return false;
206
294
  if (filters.hasEmail && !l.email) return false;
295
+ if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
207
296
  return true;
208
297
  });
209
298
  }
@@ -211,7 +300,8 @@ function dedupeKey(lead, by) {
211
300
  if (by === "none") return void 0;
212
301
  if (by === "website") return normalizeHost(lead.website);
213
302
  if (by === "phone") return lead.phone?.replace(/[^0-9]/g, "") || void 0;
214
- return lead.name?.trim().toLowerCase() || void 0;
303
+ if (by === "name") return lead.name?.trim().toLowerCase() || void 0;
304
+ return normalizeHost(lead.website) ?? (lead.phone?.replace(/[^0-9]/g, "") || void 0) ?? (lead.name?.trim().toLowerCase() || void 0);
215
305
  }
216
306
  function normalizeHost(url) {
217
307
  if (!url) return void 0;
@@ -238,7 +328,300 @@ function dedupeLeads(leads, by = "website") {
238
328
  return out;
239
329
  }
240
330
 
331
+ // src/normalize.ts
332
+ var TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|gbraid$|wbraid$|mc_|_hs|ref$|ref_src$|source$|igshid$|yclid$)/i;
333
+ function cleanWebsite(url) {
334
+ if (!url) return void 0;
335
+ let raw = url.trim();
336
+ if (!raw) return void 0;
337
+ const g = raw.match(/[?&](?:q|url)=([^&]+)/i);
338
+ if (g && /google\.[a-z.]+\/url/i.test(raw)) {
339
+ try {
340
+ raw = decodeURIComponent(g[1]);
341
+ } catch {
342
+ }
343
+ }
344
+ let u;
345
+ try {
346
+ u = new URL(raw.startsWith("http") ? raw : `https://${raw}`);
347
+ } catch {
348
+ return raw;
349
+ }
350
+ u.hash = "";
351
+ u.host = u.host.toLowerCase();
352
+ for (const key of [...u.searchParams.keys()]) {
353
+ if (TRACKING_PARAMS.test(key)) u.searchParams.delete(key);
354
+ }
355
+ let href = u.toString();
356
+ if (u.pathname === "/" && !u.search) href = href.replace(/\/$/, "");
357
+ return href;
358
+ }
359
+ var CALLING_CODES = {
360
+ NP: "977",
361
+ IN: "91",
362
+ US: "1",
363
+ CA: "1",
364
+ GB: "44",
365
+ AU: "61",
366
+ NZ: "64",
367
+ SG: "65",
368
+ MY: "60",
369
+ ID: "62",
370
+ TH: "66",
371
+ PH: "63",
372
+ VN: "84",
373
+ BD: "880",
374
+ PK: "92",
375
+ LK: "94",
376
+ AE: "971",
377
+ SA: "966",
378
+ QA: "974",
379
+ KW: "965",
380
+ BH: "973",
381
+ OM: "968",
382
+ CN: "86",
383
+ HK: "852",
384
+ JP: "81",
385
+ KR: "82",
386
+ DE: "49",
387
+ FR: "33",
388
+ ES: "34",
389
+ IT: "39",
390
+ NL: "31",
391
+ BE: "32",
392
+ CH: "41",
393
+ AT: "43",
394
+ SE: "46",
395
+ NO: "47",
396
+ DK: "45",
397
+ FI: "358",
398
+ IE: "353",
399
+ PT: "351",
400
+ PL: "48",
401
+ ZA: "27",
402
+ NG: "234",
403
+ KE: "254",
404
+ EG: "20",
405
+ BR: "55",
406
+ MX: "52",
407
+ AR: "54",
408
+ TR: "90"
409
+ };
410
+ function callingCode(country) {
411
+ if (!country) return void 0;
412
+ const c = country.trim().replace(/^\+/, "");
413
+ if (/^\d{1,4}$/.test(c)) return c;
414
+ return CALLING_CODES[c.toUpperCase()];
415
+ }
416
+ function normalizePhone(raw, country) {
417
+ if (!raw) return void 0;
418
+ const trimmed = raw.trim();
419
+ if (!trimmed) return void 0;
420
+ const isIntl = /^\+/.test(trimmed) || /^00\d/.test(trimmed);
421
+ let digits = trimmed.replace(/[^0-9]/g, "");
422
+ if (/^00\d/.test(trimmed)) digits = digits.replace(/^00/, "");
423
+ const cc = callingCode(country);
424
+ if (isIntl) {
425
+ return plausible(digits) ? `+${digits}` : trimmed;
426
+ }
427
+ if (!cc) return trimmed;
428
+ if (digits.startsWith(cc)) return plausible(digits) ? `+${digits}` : trimmed;
429
+ const local = digits.replace(/^0+/, "");
430
+ const e164 = `${cc}${local}`;
431
+ return plausible(e164) ? `+${e164}` : trimmed;
432
+ }
433
+ function plausible(digits) {
434
+ return digits.length >= 8 && digits.length <= 15;
435
+ }
436
+ var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
437
+ function sortLeads(leads, by, dir) {
438
+ if (!by) return [...leads];
439
+ const desc = dir ? dir === "desc" : by !== "name";
440
+ const val = (l) => {
441
+ if (by === "name") return l.name?.toLowerCase();
442
+ if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
443
+ return l[by];
444
+ };
445
+ return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
446
+ const av = val(a.lead);
447
+ const bv = val(b.lead);
448
+ const aMissing = av === void 0 || av === "";
449
+ const bMissing = bv === void 0 || bv === "";
450
+ if (aMissing && bMissing) return a.i - b.i;
451
+ if (aMissing) return 1;
452
+ if (bMissing) return -1;
453
+ let cmp = 0;
454
+ if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
455
+ else cmp = String(av).localeCompare(String(bv));
456
+ if (desc) cmp = -cmp;
457
+ return cmp !== 0 ? cmp : a.i - b.i;
458
+ }).map((x) => x.lead);
459
+ }
460
+
461
+ // src/verify.ts
462
+ var import_promises = require("dns/promises");
463
+ var EMAIL_SHAPE = /^[^\s@]+@([^\s@.]+\.)+[^\s@.]{2,}$/;
464
+ function emailFormatValid(email) {
465
+ return !!email && EMAIL_SHAPE.test(email.trim());
466
+ }
467
+ function emailDomain(email) {
468
+ if (!email) return void 0;
469
+ const at = email.lastIndexOf("@");
470
+ return at >= 0 ? email.slice(at + 1).trim().toLowerCase() || void 0 : void 0;
471
+ }
472
+ async function domainHasMail(domain, cache, timeoutMs) {
473
+ const cached = cache.get(domain);
474
+ if (cached !== void 0) return cached;
475
+ const timer = new Promise((res) => setTimeout(() => res(false), timeoutMs));
476
+ const lookup = (0, import_promises.resolveMx)(domain).then((records) => Array.isArray(records) && records.length > 0).catch(() => false);
477
+ const ok = await Promise.race([lookup, timer]);
478
+ cache.set(domain, ok);
479
+ return ok;
480
+ }
481
+ async function verifyEmail(email, opts = {}) {
482
+ if (!email) return "unknown";
483
+ if (!emailFormatValid(email)) return "invalid-format";
484
+ const domain = emailDomain(email);
485
+ if (!domain) return "invalid-format";
486
+ const has = await domainHasMail(domain, opts.cache ?? /* @__PURE__ */ new Map(), opts.timeoutMs ?? 5e3);
487
+ return has ? "valid" : "no-mx";
488
+ }
489
+ async function verifyEmails(leads, opts = {}) {
490
+ const cache = /* @__PURE__ */ new Map();
491
+ const targets = leads.filter((l) => l.email);
492
+ const n = Math.max(1, Math.min(opts.concurrency ?? 5, targets.length || 1));
493
+ let next = 0;
494
+ let done = 0;
495
+ const verifyOpts = { cache };
496
+ if (opts.timeoutMs !== void 0) verifyOpts.timeoutMs = opts.timeoutMs;
497
+ const workers = Array.from({ length: n }, async () => {
498
+ for (; ; ) {
499
+ const i = next++;
500
+ if (i >= targets.length) return;
501
+ const lead = targets[i];
502
+ lead.emailStatus = await verifyEmail(lead.email, verifyOpts);
503
+ opts.onProgress?.(`verifying email ${++done}/${targets.length}: ${lead.email}\u2026`);
504
+ }
505
+ });
506
+ await Promise.all(workers);
507
+ return leads;
508
+ }
509
+
510
+ // src/export.ts
511
+ var import_csv = require("@lacspace/csv");
512
+ var import_xlsx = require("@lacspace/xlsx");
513
+ var HEADERS = {
514
+ name: "Name",
515
+ category: "Category",
516
+ rating: "Rating",
517
+ reviews: "Reviews",
518
+ priceLevel: "Price",
519
+ address: "Address",
520
+ phone: "Phone",
521
+ website: "Website",
522
+ email: "Email",
523
+ facebook: "Facebook",
524
+ instagram: "Instagram",
525
+ whatsapp: "WhatsApp",
526
+ linkedin: "LinkedIn",
527
+ twitter: "Twitter/X",
528
+ youtube: "YouTube",
529
+ tiktok: "TikTok",
530
+ telegram: "Telegram",
531
+ emailStatus: "Email Status",
532
+ plusCode: "Plus Code",
533
+ latitude: "Latitude",
534
+ longitude: "Longitude",
535
+ hours: "Hours",
536
+ mapsUrl: "Maps URL"
537
+ };
538
+ function toRows(leads, fields = ALL_FIELDS) {
539
+ return leads.map((lead) => {
540
+ const row = {};
541
+ for (const f of fields) {
542
+ const v = lead[f];
543
+ row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
544
+ }
545
+ return row;
546
+ });
547
+ }
548
+ function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
549
+ if (format === "json" || format === "ndjson") {
550
+ const picked = leads.map((lead) => {
551
+ const o = {};
552
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
553
+ return o;
554
+ });
555
+ const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
556
+ return { data, binary: false };
557
+ }
558
+ const rows = toRows(leads, fields);
559
+ if (format === "csv") {
560
+ const csvRows = rows;
561
+ return { data: (0, import_csv.stringify)(csvRows, { escapeFormulas: true }), binary: false };
562
+ }
563
+ const columns = fields.map((f) => {
564
+ const header = HEADERS[f];
565
+ const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
566
+ return { header, width: Math.min(60, Math.max(8, widest + 2)) };
567
+ });
568
+ return {
569
+ data: (0, import_xlsx.jsonToXlsx)(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
570
+ binary: true
571
+ };
572
+ }
573
+ function computeStats(leads) {
574
+ const socialKeys = ["facebook", "instagram", "whatsapp", "linkedin", "twitter", "youtube", "tiktok", "telegram"];
575
+ const ratings = leads.map((l) => l.rating).filter((r) => typeof r === "number");
576
+ const stats = {
577
+ total: leads.length,
578
+ withPhone: leads.filter((l) => l.phone).length,
579
+ withWebsite: leads.filter((l) => l.website).length,
580
+ withEmail: leads.filter((l) => l.email).length,
581
+ withValidEmail: leads.filter((l) => l.emailStatus === "valid").length,
582
+ withSocial: leads.filter((l) => socialKeys.some((k) => l[k])).length
583
+ };
584
+ if (ratings.length) stats.avgRating = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
585
+ return stats;
586
+ }
587
+ var FIELD_BY_KEY = (() => {
588
+ const m = /* @__PURE__ */ new Map();
589
+ for (const f of ALL_FIELDS) {
590
+ m.set(f.toLowerCase(), f);
591
+ m.set(HEADERS[f].toLowerCase(), f);
592
+ }
593
+ return m;
594
+ })();
595
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
596
+ function rowsToLeads(rows) {
597
+ return rows.map((row) => {
598
+ const lead = {};
599
+ for (const [key, raw] of Object.entries(row)) {
600
+ const field = FIELD_BY_KEY.get(key.trim().toLowerCase());
601
+ if (!field || raw === null || raw === void 0 || raw === "") continue;
602
+ if (NUMERIC_FIELDS.has(field)) {
603
+ const n = typeof raw === "number" ? raw : parseFloat(String(raw));
604
+ if (Number.isFinite(n)) lead[field] = n;
605
+ } else {
606
+ lead[field] = String(raw);
607
+ }
608
+ }
609
+ return lead;
610
+ });
611
+ }
612
+
241
613
  // src/scrape.ts
614
+ async function pool(items, n, fn, signal) {
615
+ let next = 0;
616
+ const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
617
+ for (; ; ) {
618
+ const i = next++;
619
+ if (i >= items.length || signal?.aborted) return;
620
+ await fn(items[i], i);
621
+ }
622
+ });
623
+ await Promise.all(workers);
624
+ }
242
625
  var LeadsError = class extends Error {
243
626
  code;
244
627
  cause;
@@ -276,17 +659,19 @@ function parseLatLng(url) {
276
659
  if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
277
660
  return {};
278
661
  }
279
- async function launchBrowser(headless) {
662
+ async function launchBrowser(headless, proxy) {
280
663
  let lastErr;
664
+ const base = { headless };
665
+ if (proxy) base.proxy = { server: proxy };
281
666
  for (const channel of ["chrome", "msedge"]) {
282
667
  try {
283
- return await import_playwright_core.chromium.launch({ headless, channel });
668
+ return await import_playwright_core.chromium.launch({ ...base, channel });
284
669
  } catch (e) {
285
670
  lastErr = e;
286
671
  }
287
672
  }
288
673
  try {
289
- return await import_playwright_core.chromium.launch({ headless });
674
+ return await import_playwright_core.chromium.launch(base);
290
675
  } catch (e) {
291
676
  lastErr = e;
292
677
  }
@@ -363,7 +748,13 @@ async function extractDetail(page, fields, fallbackName) {
363
748
  }
364
749
  if (fields.has("reviews")) {
365
750
  const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
366
- lead.reviews = parseReviewCount(rv);
751
+ let count = parseReviewCount(rv);
752
+ if (count === void 0) {
753
+ const boxText = await box.innerText().catch(() => "");
754
+ const m = boxText.match(/\(([\d.,\s]+)\)/);
755
+ if (m) count = parseReviewCount(m[1]);
756
+ }
757
+ lead.reviews = count;
367
758
  }
368
759
  }
369
760
  }
@@ -403,24 +794,31 @@ async function scrapeLeads(opts) {
403
794
  const fields = new Set(normalizeFields(opts.fields ?? DEFAULT_FIELDS));
404
795
  const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
405
796
  const headless = opts.headless ?? false;
797
+ const retries = Math.max(0, Math.trunc(opts.retries ?? 1));
406
798
  const onProgress = opts.onProgress;
799
+ const onLead = opts.onLead;
407
800
  const signal = opts.signal;
408
801
  const startedAt = Date.now();
409
802
  const overBudget = () => opts.maxMs !== void 0 && Date.now() - startedAt > opts.maxMs;
410
- const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail);
803
+ const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
804
+ const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
805
+ const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
411
806
  const collect = new Set(fields);
412
807
  if (wantEnrich) collect.add("website");
413
808
  const detailOnly = ["name", "mapsUrl"];
414
809
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
415
810
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
416
- const browser = await launchBrowser(headless);
811
+ const browser = await launchBrowser(headless, opts.proxy);
417
812
  try {
813
+ const locale = opts.locale ?? "en-US";
418
814
  const context = await browser.newContext({
419
815
  viewport: { width: 1280, height: 900 },
420
- locale: "en-US"
816
+ locale
421
817
  });
422
818
  const page = await context.newPage();
423
- await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
819
+ const urlOpts = { hl: locale };
820
+ if (opts.region) urlOpts.gl = opts.region;
821
+ await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
424
822
  await dismissConsent(page);
425
823
  await loadResults(page, limit, delayMs, onProgress, signal);
426
824
  if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
@@ -448,44 +846,95 @@ async function scrapeLeads(opts) {
448
846
  }
449
847
  return lead;
450
848
  });
849
+ for (const lead of leads) {
850
+ try {
851
+ onLead?.(lead);
852
+ } catch {
853
+ }
854
+ }
451
855
  } else {
452
856
  for (let i = 0; i < cards.length; i++) {
453
857
  if (signal?.aborted || overBudget()) break;
454
858
  const card = cards[i];
455
859
  onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
456
- try {
457
- await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
458
- await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
459
- });
460
- leads.push(await extractDetail(page, collect, card.name));
461
- } catch {
860
+ let lead;
861
+ for (let attempt = 0; attempt <= retries; attempt++) {
862
+ try {
863
+ await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
864
+ await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
865
+ });
866
+ lead = await extractDetail(page, collect, card.name);
867
+ break;
868
+ } catch {
869
+ if (attempt < retries && !signal?.aborted) {
870
+ onProgress?.(` retrying ${i + 1}/${cards.length} (attempt ${attempt + 2})\u2026`);
871
+ if (delayMs) await page.waitForTimeout(pauseMs());
872
+ }
873
+ }
874
+ }
875
+ if (!lead) {
462
876
  if (card.name && (collect.has("name") || collect.has("mapsUrl"))) {
463
- const partial = {};
464
- if (collect.has("name")) partial.name = card.name;
465
- if (collect.has("mapsUrl")) partial.mapsUrl = card.href;
466
- leads.push(partial);
877
+ lead = {};
878
+ if (collect.has("name")) lead.name = card.name;
879
+ if (collect.has("mapsUrl")) lead.mapsUrl = card.href;
467
880
  }
468
881
  }
469
- if (delayMs) await page.waitForTimeout(delayMs);
882
+ if (lead) {
883
+ leads.push(lead);
884
+ try {
885
+ onLead?.(lead);
886
+ } catch {
887
+ }
888
+ }
889
+ if (delayMs) await page.waitForTimeout(pauseMs());
470
890
  }
471
891
  }
472
892
  onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
893
+ if (opts.cleanUrls ?? true) {
894
+ for (const lead of leads) {
895
+ if (lead.website) lead.website = cleanWebsite(lead.website);
896
+ }
897
+ }
473
898
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
474
899
  if (wantEnrich) {
475
- for (let i = 0; i < leads.length; i++) {
476
- if (signal?.aborted || overBudget()) break;
477
- const site = leads[i].website;
478
- if (!site) continue;
479
- onProgress?.(`enriching ${i + 1}/${leads.length}: ${leads[i].name ?? site}\u2026`);
480
- const c = await enrichContacts(site);
481
- if (fields.has("email") && c.email) leads[i].email = c.email;
482
- if (fields.has("facebook") && c.facebook) leads[i].facebook = c.facebook;
483
- if (fields.has("instagram") && c.instagram) leads[i].instagram = c.instagram;
484
- if (fields.has("whatsapp") && c.whatsapp) leads[i].whatsapp = c.whatsapp;
485
- if (!fields.has("website")) delete leads[i].website;
900
+ const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
901
+ let done = 0;
902
+ await pool(
903
+ leads,
904
+ Math.max(1, Math.trunc(opts.concurrency ?? 3)),
905
+ async (lead) => {
906
+ if (overBudget()) return;
907
+ const site = lead.website;
908
+ if (site) {
909
+ onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
910
+ const c = await enrichContacts(site);
911
+ if (fields.has("email") && c.email) lead.email = c.email;
912
+ for (const f of socialFields) {
913
+ const v = c[f];
914
+ if (fields.has(f) && v) lead[f] = v;
915
+ }
916
+ }
917
+ if (!fields.has("website")) delete lead.website;
918
+ },
919
+ signal
920
+ );
921
+ }
922
+ if (wantVerify && !signal?.aborted) {
923
+ onProgress?.("verifying email domains\u2026");
924
+ const vOpts = {
925
+ concurrency: Math.max(1, Math.trunc(opts.concurrency ?? 5))
926
+ };
927
+ if (onProgress) vOpts.onProgress = (m) => onProgress(m);
928
+ await verifyEmails(leads, vOpts);
929
+ }
930
+ if (opts.country && fields.has("phone")) {
931
+ for (const lead of leads) {
932
+ if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
486
933
  }
487
934
  }
488
935
  if (opts.filters) leads = filterLeads(leads, opts.filters);
936
+ if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
937
+ if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
489
938
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
490
939
  return leads;
491
940
  } finally {
@@ -493,66 +942,77 @@ async function scrapeLeads(opts) {
493
942
  });
494
943
  }
495
944
  }
945
+ async function searchLeadsDetailed(opts) {
946
+ const started = Date.now();
947
+ const leads = await scrapeLeads(opts);
948
+ return { leads, stats: computeStats(leads), elapsedMs: Date.now() - started };
949
+ }
496
950
 
497
- // src/export.ts
498
- var import_csv = require("@lacspace/csv");
499
- var import_xlsx = require("@lacspace/xlsx");
500
- var HEADERS = {
501
- name: "Name",
502
- category: "Category",
503
- rating: "Rating",
504
- reviews: "Reviews",
505
- priceLevel: "Price",
506
- address: "Address",
507
- phone: "Phone",
508
- website: "Website",
509
- email: "Email",
510
- facebook: "Facebook",
511
- instagram: "Instagram",
512
- whatsapp: "WhatsApp",
513
- plusCode: "Plus Code",
514
- latitude: "Latitude",
515
- longitude: "Longitude",
516
- hours: "Hours",
517
- mapsUrl: "Maps URL"
518
- };
519
- function toRows(leads, fields = ALL_FIELDS) {
520
- return leads.map((lead) => {
521
- const row = {};
522
- for (const f of fields) {
523
- const v = lead[f];
524
- row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
951
+ // src/batch.ts
952
+ async function searchLeadsBatch(queries, opts = {}) {
953
+ const {
954
+ total,
955
+ onProgress,
956
+ signal,
957
+ type: _t,
958
+ city: _c,
959
+ area: _a,
960
+ query: _q,
961
+ ...shared
962
+ } = opts;
963
+ void _t;
964
+ void _c;
965
+ void _a;
966
+ void _q;
967
+ let merged = [];
968
+ const seenDedupe = shared.dedupe ?? "website";
969
+ for (let i = 0; i < queries.length; i++) {
970
+ if (signal?.aborted) break;
971
+ const q = queries[i];
972
+ const label = (() => {
973
+ try {
974
+ return composeQuery(q);
975
+ } catch {
976
+ return q.query ?? q.type ?? "search";
977
+ }
978
+ })();
979
+ onProgress?.(`[${i + 1}/${queries.length}] ${label}`);
980
+ const subOpts = {
981
+ ...shared,
982
+ type: q.type ?? "",
983
+ // Defer sorting to the merged pass; keep per-search dedupe/filter local.
984
+ ...q.query ? { query: q.query } : {},
985
+ ...q.city ? { city: q.city } : {},
986
+ ...q.area ? { area: q.area } : {}
987
+ };
988
+ delete subOpts.sort;
989
+ if (signal) subOpts.signal = signal;
990
+ if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
991
+ try {
992
+ const found = await scrapeLeads(subOpts);
993
+ merged = merged.concat(found);
994
+ } catch (err) {
995
+ onProgress?.(` ! skipped "${label}": ${err.message}`);
525
996
  }
526
- return row;
527
- });
528
- }
529
- function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
530
- if (format === "json") {
531
- const picked = leads.map((lead) => {
532
- const o = {};
533
- for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
534
- return o;
535
- });
536
- return { data: JSON.stringify(picked, null, 2), binary: false };
537
- }
538
- const rows = toRows(leads, fields);
539
- if (format === "csv") {
540
- const csvRows = rows;
541
- return { data: (0, import_csv.stringify)(csvRows, { escapeFormulas: true }), binary: false };
542
997
  }
543
- const columns = fields.map((f) => {
544
- const header = HEADERS[f];
545
- const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
546
- return { header, width: Math.min(60, Math.max(8, widest + 2)) };
998
+ merged = dedupeLeads(merged, seenDedupe);
999
+ if (opts.sort) merged = sortLeads(merged, opts.sort, opts.sortDir);
1000
+ if (total !== void 0 && total > 0) merged = merged.slice(0, total);
1001
+ onProgress?.(`merged ${merged.length} unique lead${merged.length === 1 ? "" : "s"}.`);
1002
+ return merged;
1003
+ }
1004
+ async function searchLeadsMulti(opts) {
1005
+ const queries = expandQueries({
1006
+ ...opts.type ? { type: opts.type } : {},
1007
+ ...opts.city ? { city: opts.city } : {},
1008
+ ...opts.area ? { area: opts.area } : {},
1009
+ ...opts.query ? { query: opts.query } : {}
547
1010
  });
548
- return {
549
- data: (0, import_xlsx.jsonToXlsx)(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
550
- binary: true
551
- };
1011
+ return searchLeadsBatch(queries, opts);
552
1012
  }
553
1013
 
554
1014
  // src/convert.ts
555
- var import_promises = require("fs/promises");
1015
+ var import_promises2 = require("fs/promises");
556
1016
  var import_node_path = require("path");
557
1017
  var import_csv2 = require("@lacspace/csv");
558
1018
  var import_xlsx2 = require("@lacspace/xlsx");
@@ -566,10 +1026,10 @@ function detectFormat(file) {
566
1026
  async function readRows(file, format) {
567
1027
  const fmt = format ?? detectFormat(file);
568
1028
  if (fmt === "xlsx") {
569
- const bytes = await (0, import_promises.readFile)(file);
1029
+ const bytes = await (0, import_promises2.readFile)(file);
570
1030
  return await (0, import_xlsx2.xlsxToJson)(bytes);
571
1031
  }
572
- const text = await (0, import_promises.readFile)(file, "utf8");
1032
+ const text = await (0, import_promises2.readFile)(file, "utf8");
573
1033
  if (fmt === "csv") {
574
1034
  return (0, import_csv2.parse)(text, { header: true });
575
1035
  }
@@ -621,33 +1081,51 @@ async function convertFile(input, opts = {}) {
621
1081
  const format = opts.format ?? (opts.out ? detectFormat(opts.out) : "json");
622
1082
  const out = (0, import_node_path.resolve)(opts.out ?? input.replace(/\.[^.]+$/, "") + "." + format);
623
1083
  const { data, binary } = serializeRows(rows, format, { sheetName: opts.sheetName ?? "Sheet1" });
624
- await (0, import_promises.writeFile)(out, binary ? Buffer.from(data) : data);
1084
+ await (0, import_promises2.writeFile)(out, binary ? Buffer.from(data) : data);
625
1085
  return { out, format, count: rows.length };
626
1086
  }
627
1087
  // Annotate the CommonJS export names for ESM import in node:
628
1088
  0 && (module.exports = {
629
1089
  ALL_FIELDS,
1090
+ CALLING_CODES,
1091
+ DEFAULT_FIELDS,
630
1092
  ENRICHED_FIELDS,
1093
+ FIELD_PRESETS,
631
1094
  LeadsError,
1095
+ callingCode,
1096
+ cleanWebsite,
632
1097
  columnsOf,
633
1098
  composeQuery,
1099
+ computeStats,
634
1100
  convertFile,
635
1101
  dedupeLeads,
636
1102
  defaultFilename,
637
1103
  detectFormat,
1104
+ emailDomain,
1105
+ emailFormatValid,
638
1106
  enrichContacts,
1107
+ expandQueries,
639
1108
  extractEmails,
640
1109
  extractSocials,
641
1110
  filterLeads,
642
1111
  mapsSearchUrl,
643
1112
  normalizeFields,
1113
+ normalizePhone,
644
1114
  parseLatLng,
645
1115
  parseRating,
646
1116
  parseReviewCount,
647
1117
  readRows,
1118
+ resolvePreset,
1119
+ rowsToLeads,
648
1120
  scrapeLeads,
649
1121
  searchLeads,
1122
+ searchLeadsBatch,
1123
+ searchLeadsDetailed,
1124
+ searchLeadsMulti,
650
1125
  serialize,
651
1126
  serializeRows,
652
- toRows
1127
+ sortLeads,
1128
+ toRows,
1129
+ verifyEmail,
1130
+ verifyEmails
653
1131
  });