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.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 = ["email", "facebook", "instagram", "whatsapp"];
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
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
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 (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?/i.test(u)) {
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
- if (!contacts.facebook || !contacts.instagram || !contacts.whatsapp) {
134
- const s = extractSocials(page);
135
- if (!contacts.facebook && s.facebook) contacts.facebook = s.facebook;
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({ headless, channel });
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({ headless });
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
- lead.reviews = parseReviewCount(rv);
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 wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail);
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: "en-US"
749
+ locale
372
750
  });
373
751
  const page = await context.newPage();
374
- await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
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
- try {
408
- await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
409
- await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
410
- });
411
- leads.push(await extractDetail(page, collect, card.name));
412
- } catch {
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
- const partial = {};
415
- if (collect.has("name")) partial.name = card.name;
416
- if (collect.has("mapsUrl")) partial.mapsUrl = card.href;
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 (delayMs) await page.waitForTimeout(delayMs);
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
- for (let i = 0; i < leads.length; i++) {
427
- if (signal?.aborted || overBudget()) break;
428
- const site = leads[i].website;
429
- if (!site) continue;
430
- onProgress?.(`enriching ${i + 1}/${leads.length}: ${leads[i].name ?? site}\u2026`);
431
- const c = await enrichContacts(site);
432
- if (fields.has("email") && c.email) leads[i].email = c.email;
433
- if (fields.has("facebook") && c.facebook) leads[i].facebook = c.facebook;
434
- if (fields.has("instagram") && c.instagram) leads[i].instagram = c.instagram;
435
- if (fields.has("whatsapp") && c.whatsapp) leads[i].whatsapp = c.whatsapp;
436
- if (!fields.has("website")) delete leads[i].website;
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/export.ts
449
- import { stringify as csvStringify } from "@lacspace/csv";
450
- import { jsonToXlsx } from "@lacspace/xlsx";
451
- var HEADERS = {
452
- name: "Name",
453
- category: "Category",
454
- rating: "Rating",
455
- reviews: "Reviews",
456
- priceLevel: "Price",
457
- address: "Address",
458
- phone: "Phone",
459
- website: "Website",
460
- email: "Email",
461
- facebook: "Facebook",
462
- instagram: "Instagram",
463
- whatsapp: "WhatsApp",
464
- plusCode: "Plus Code",
465
- latitude: "Latitude",
466
- longitude: "Longitude",
467
- hours: "Hours",
468
- mapsUrl: "Maps URL"
469
- };
470
- function toRows(leads, fields = ALL_FIELDS) {
471
- return leads.map((lead) => {
472
- const row = {};
473
- for (const f of fields) {
474
- const v = lead[f];
475
- row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
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
- const columns = fields.map((f) => {
495
- const header = HEADERS[f];
496
- const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
497
- return { header, width: Math.min(60, Math.max(8, widest + 2)) };
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
- toRows
1059
+ sortLeads,
1060
+ toRows,
1061
+ verifyEmail,
1062
+ verifyEmails
603
1063
  };