lacspace-leads 0.3.0 → 1.3.0

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/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { writeFileSync } from "fs";
4
+ import { writeFileSync, existsSync } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
  import { createInterface } from "readline/promises";
7
7
  import { stdin, stdout, stderr, argv, exit } from "process";
@@ -28,12 +28,15 @@ var ALL_FIELDS = [
28
28
  "youtube",
29
29
  "tiktok",
30
30
  "telegram",
31
+ "emailStatus",
31
32
  "plusCode",
32
33
  "latitude",
33
34
  "longitude",
35
+ "distanceKm",
34
36
  "hours",
35
37
  "mapsUrl"
36
38
  ];
39
+ var DERIVED_FIELDS = ["distanceKm"];
37
40
  var ENRICHED_FIELDS = [
38
41
  "email",
39
42
  "facebook",
@@ -43,7 +46,8 @@ var ENRICHED_FIELDS = [
43
46
  "twitter",
44
47
  "youtube",
45
48
  "tiktok",
46
- "telegram"
49
+ "telegram",
50
+ "emailStatus"
47
51
  ];
48
52
  var FIELD_PRESETS = {
49
53
  /** Just enough to place a business: name, phone, website. */
@@ -55,11 +59,13 @@ var FIELD_PRESETS = {
55
59
  /** Map/geo columns for plotting. */
56
60
  geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
57
61
  /** Everything Maps shows, no website enrichment. */
58
- full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
62
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)),
59
63
  /** Every field, including enriched ones. */
60
64
  everything: [...ALL_FIELDS]
61
65
  };
62
- var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
66
+ var DEFAULT_FIELDS = ALL_FIELDS.filter(
67
+ (f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)
68
+ );
63
69
 
64
70
  // src/query.ts
65
71
  function composeQuery(opts) {
@@ -73,7 +79,9 @@ function mapsSearchUrl(query, opts = {}) {
73
79
  const hl = (opts.hl ?? "en").split("-")[0] || "en";
74
80
  const params = new URLSearchParams({ hl });
75
81
  if (opts.gl) params.set("gl", opts.gl.toLowerCase());
76
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
82
+ const base = `https://www.google.com/maps/search/${encodeURIComponent(query)}`;
83
+ const at = opts.center ? `/@${opts.center.lat},${opts.center.lng},${Math.round(opts.center.zoom ?? 14)}z` : "";
84
+ return `${base}${at}?${params.toString()}`;
77
85
  }
78
86
  function expandQueries(opts) {
79
87
  if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
@@ -231,6 +239,7 @@ function filterLeads(leads, filters = {}) {
231
239
  if (filters.hasPhone && !l.phone) return false;
232
240
  if (filters.hasWebsite && !l.website) return false;
233
241
  if (filters.hasEmail && !l.email) return false;
242
+ if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
234
243
  return true;
235
244
  });
236
245
  }
@@ -238,7 +247,8 @@ function dedupeKey(lead, by) {
238
247
  if (by === "none") return void 0;
239
248
  if (by === "website") return normalizeHost(lead.website);
240
249
  if (by === "phone") return lead.phone?.replace(/[^0-9]/g, "") || void 0;
241
- return lead.name?.trim().toLowerCase() || void 0;
250
+ if (by === "name") return lead.name?.trim().toLowerCase() || void 0;
251
+ return normalizeHost(lead.website) ?? (lead.phone?.replace(/[^0-9]/g, "") || void 0) ?? (lead.name?.trim().toLowerCase() || void 0);
242
252
  }
243
253
  function normalizeHost(url) {
244
254
  if (!url) return void 0;
@@ -371,12 +381,14 @@ function plausible(digits) {
371
381
  return digits.length >= 8 && digits.length <= 15;
372
382
  }
373
383
  var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
384
+ var ASC_BY_DEFAULT = /* @__PURE__ */ new Set(["name", "distance"]);
374
385
  function sortLeads(leads, by, dir) {
375
386
  if (!by) return [...leads];
376
- const desc = dir ? dir === "desc" : by !== "name";
387
+ const desc = dir ? dir === "desc" : !ASC_BY_DEFAULT.has(by);
377
388
  const val = (l) => {
378
389
  if (by === "name") return l.name?.toLowerCase();
379
390
  if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
391
+ if (by === "distance") return l.distanceKm;
380
392
  return l[by];
381
393
  };
382
394
  return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
@@ -395,6 +407,196 @@ function sortLeads(leads, by, dir) {
395
407
  }).map((x) => x.lead);
396
408
  }
397
409
 
410
+ // src/verify.ts
411
+ import { resolveMx } from "dns/promises";
412
+ var EMAIL_SHAPE = /^[^\s@]+@([^\s@.]+\.)+[^\s@.]{2,}$/;
413
+ function emailFormatValid(email) {
414
+ return !!email && EMAIL_SHAPE.test(email.trim());
415
+ }
416
+ function emailDomain(email) {
417
+ if (!email) return void 0;
418
+ const at = email.lastIndexOf("@");
419
+ return at >= 0 ? email.slice(at + 1).trim().toLowerCase() || void 0 : void 0;
420
+ }
421
+ async function domainHasMail(domain, cache, timeoutMs) {
422
+ const cached = cache.get(domain);
423
+ if (cached !== void 0) return cached;
424
+ const timer = new Promise((res) => setTimeout(() => res(false), timeoutMs));
425
+ const lookup = resolveMx(domain).then((records) => Array.isArray(records) && records.length > 0).catch(() => false);
426
+ const ok = await Promise.race([lookup, timer]);
427
+ cache.set(domain, ok);
428
+ return ok;
429
+ }
430
+ async function verifyEmail(email, opts = {}) {
431
+ if (!email) return "unknown";
432
+ if (!emailFormatValid(email)) return "invalid-format";
433
+ const domain = emailDomain(email);
434
+ if (!domain) return "invalid-format";
435
+ const has = await domainHasMail(domain, opts.cache ?? /* @__PURE__ */ new Map(), opts.timeoutMs ?? 5e3);
436
+ return has ? "valid" : "no-mx";
437
+ }
438
+ async function verifyEmails(leads, opts = {}) {
439
+ const cache = /* @__PURE__ */ new Map();
440
+ const targets = leads.filter((l) => l.email);
441
+ const n = Math.max(1, Math.min(opts.concurrency ?? 5, targets.length || 1));
442
+ let next = 0;
443
+ let done = 0;
444
+ const verifyOpts = { cache };
445
+ if (opts.timeoutMs !== void 0) verifyOpts.timeoutMs = opts.timeoutMs;
446
+ const workers = Array.from({ length: n }, async () => {
447
+ for (; ; ) {
448
+ const i = next++;
449
+ if (i >= targets.length) return;
450
+ const lead = targets[i];
451
+ lead.emailStatus = await verifyEmail(lead.email, verifyOpts);
452
+ opts.onProgress?.(`verifying email ${++done}/${targets.length}: ${lead.email}\u2026`);
453
+ }
454
+ });
455
+ await Promise.all(workers);
456
+ return leads;
457
+ }
458
+
459
+ // src/geo.ts
460
+ var R_EARTH_M = 63710088e-1;
461
+ var toRad = (deg) => deg * Math.PI / 180;
462
+ function haversineMeters(a, b) {
463
+ const dLat = toRad(b.lat - a.lat);
464
+ const dLng = toRad(b.lng - a.lng);
465
+ const lat1 = toRad(a.lat);
466
+ const lat2 = toRad(b.lat);
467
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
468
+ return 2 * R_EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
469
+ }
470
+ function parseLatLngPair(input) {
471
+ if (!input) return void 0;
472
+ const m = input.replace(/^@/, "").match(/(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)/);
473
+ if (!m) return void 0;
474
+ const lat = parseFloat(m[1]);
475
+ const lng = parseFloat(m[2]);
476
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void 0;
477
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return void 0;
478
+ return { lat, lng };
479
+ }
480
+ function parseDistance(input) {
481
+ if (!input) return void 0;
482
+ const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(km|m|mi|mile|miles|meters?|metres?)?$/);
483
+ if (!m) return void 0;
484
+ const value = parseFloat(m[1]);
485
+ if (!Number.isFinite(value) || value <= 0) return void 0;
486
+ const unit = m[2] ?? "m";
487
+ const metres = unit === "km" ? value * 1e3 : unit.startsWith("mi") ? value * 1609.344 : value;
488
+ return metres;
489
+ }
490
+ function zoomForRadius(radiusM, lat = 0) {
491
+ const mpp = 2 * Math.max(50, radiusM) / 600;
492
+ const zoom = Math.log2(156543.03392 * Math.cos(toRad(lat)) / mpp);
493
+ return Math.max(3, Math.min(19, Math.round(zoom)));
494
+ }
495
+
496
+ // src/export.ts
497
+ import { stringify as csvStringify } from "@lacspace/csv";
498
+ import { jsonToXlsx } from "@lacspace/xlsx";
499
+ var HEADERS = {
500
+ name: "Name",
501
+ category: "Category",
502
+ rating: "Rating",
503
+ reviews: "Reviews",
504
+ priceLevel: "Price",
505
+ address: "Address",
506
+ phone: "Phone",
507
+ website: "Website",
508
+ email: "Email",
509
+ facebook: "Facebook",
510
+ instagram: "Instagram",
511
+ whatsapp: "WhatsApp",
512
+ linkedin: "LinkedIn",
513
+ twitter: "Twitter/X",
514
+ youtube: "YouTube",
515
+ tiktok: "TikTok",
516
+ telegram: "Telegram",
517
+ emailStatus: "Email Status",
518
+ plusCode: "Plus Code",
519
+ latitude: "Latitude",
520
+ longitude: "Longitude",
521
+ distanceKm: "Distance (km)",
522
+ hours: "Hours",
523
+ mapsUrl: "Maps URL"
524
+ };
525
+ function toRows(leads, fields = ALL_FIELDS) {
526
+ return leads.map((lead) => {
527
+ const row = {};
528
+ for (const f of fields) {
529
+ const v = lead[f];
530
+ row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
531
+ }
532
+ return row;
533
+ });
534
+ }
535
+ function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
536
+ if (format === "json" || format === "ndjson") {
537
+ const picked = leads.map((lead) => {
538
+ const o = {};
539
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
540
+ return o;
541
+ });
542
+ const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
543
+ return { data, binary: false };
544
+ }
545
+ const rows = toRows(leads, fields);
546
+ if (format === "csv") {
547
+ const csvRows = rows;
548
+ return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
549
+ }
550
+ const columns = fields.map((f) => {
551
+ const header = HEADERS[f];
552
+ const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
553
+ return { header, width: Math.min(60, Math.max(8, widest + 2)) };
554
+ });
555
+ return {
556
+ data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
557
+ binary: true
558
+ };
559
+ }
560
+ function computeStats(leads) {
561
+ const socialKeys = ["facebook", "instagram", "whatsapp", "linkedin", "twitter", "youtube", "tiktok", "telegram"];
562
+ const ratings = leads.map((l) => l.rating).filter((r) => typeof r === "number");
563
+ const stats = {
564
+ total: leads.length,
565
+ withPhone: leads.filter((l) => l.phone).length,
566
+ withWebsite: leads.filter((l) => l.website).length,
567
+ withEmail: leads.filter((l) => l.email).length,
568
+ withValidEmail: leads.filter((l) => l.emailStatus === "valid").length,
569
+ withSocial: leads.filter((l) => socialKeys.some((k) => l[k])).length
570
+ };
571
+ if (ratings.length) stats.avgRating = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
572
+ return stats;
573
+ }
574
+ var FIELD_BY_KEY = (() => {
575
+ const m = /* @__PURE__ */ new Map();
576
+ for (const f of ALL_FIELDS) {
577
+ m.set(f.toLowerCase(), f);
578
+ m.set(HEADERS[f].toLowerCase(), f);
579
+ }
580
+ return m;
581
+ })();
582
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude", "distanceKm"]);
583
+ function rowsToLeads(rows) {
584
+ return rows.map((row) => {
585
+ const lead = {};
586
+ for (const [key, raw] of Object.entries(row)) {
587
+ const field = FIELD_BY_KEY.get(key.trim().toLowerCase());
588
+ if (!field || raw === null || raw === void 0 || raw === "") continue;
589
+ if (NUMERIC_FIELDS.has(field)) {
590
+ const n = typeof raw === "number" ? raw : parseFloat(String(raw));
591
+ if (Number.isFinite(n)) lead[field] = n;
592
+ } else {
593
+ lead[field] = String(raw);
594
+ }
595
+ }
596
+ return lead;
597
+ });
598
+ }
599
+
398
600
  // src/scrape.ts
399
601
  async function pool(items, n, fn, signal) {
400
602
  let next = 0;
@@ -444,17 +646,19 @@ function parseLatLng(url) {
444
646
  if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
445
647
  return {};
446
648
  }
447
- async function launchBrowser(headless) {
649
+ async function launchBrowser(headless, proxy) {
448
650
  let lastErr;
651
+ const base = { headless };
652
+ if (proxy) base.proxy = { server: proxy };
449
653
  for (const channel of ["chrome", "msedge"]) {
450
654
  try {
451
- return await chromium.launch({ headless, channel });
655
+ return await chromium.launch({ ...base, channel });
452
656
  } catch (e) {
453
657
  lastErr = e;
454
658
  }
455
659
  }
456
660
  try {
457
- return await chromium.launch({ headless });
661
+ return await chromium.launch(base);
458
662
  } catch (e) {
459
663
  lastErr = e;
460
664
  }
@@ -577,17 +781,26 @@ async function scrapeLeads(opts) {
577
781
  const fields = new Set(normalizeFields(opts.fields ?? DEFAULT_FIELDS));
578
782
  const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
579
783
  const headless = opts.headless ?? false;
784
+ const retries = Math.max(0, Math.trunc(opts.retries ?? 1));
580
785
  const onProgress = opts.onProgress;
786
+ const onLead = opts.onLead;
581
787
  const signal = opts.signal;
582
788
  const startedAt = Date.now();
583
789
  const overBudget = () => opts.maxMs !== void 0 && Date.now() - startedAt > opts.maxMs;
584
- const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail);
790
+ const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
791
+ const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
792
+ const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
793
+ const near = opts.near;
585
794
  const collect = new Set(fields);
586
795
  if (wantEnrich) collect.add("website");
796
+ if (near) {
797
+ collect.add("latitude");
798
+ collect.add("longitude");
799
+ }
587
800
  const detailOnly = ["name", "mapsUrl"];
588
801
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
589
802
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
590
- const browser = await launchBrowser(headless);
803
+ const browser = await launchBrowser(headless, opts.proxy);
591
804
  try {
592
805
  const locale = opts.locale ?? "en-US";
593
806
  const context = await browser.newContext({
@@ -597,6 +810,7 @@ async function scrapeLeads(opts) {
597
810
  const page = await context.newPage();
598
811
  const urlOpts = { hl: locale };
599
812
  if (opts.region) urlOpts.gl = opts.region;
813
+ if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? 2e3, near.lat) };
600
814
  await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
601
815
  await dismissConsent(page);
602
816
  await loadResults(page, limit, delayMs, onProgress, signal);
@@ -625,25 +839,47 @@ async function scrapeLeads(opts) {
625
839
  }
626
840
  return lead;
627
841
  });
842
+ for (const lead of leads) {
843
+ try {
844
+ onLead?.(lead);
845
+ } catch {
846
+ }
847
+ }
628
848
  } else {
629
849
  for (let i = 0; i < cards.length; i++) {
630
850
  if (signal?.aborted || overBudget()) break;
631
851
  const card = cards[i];
632
852
  onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
633
- try {
634
- await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
635
- await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
636
- });
637
- leads.push(await extractDetail(page, collect, card.name));
638
- } catch {
853
+ let lead;
854
+ for (let attempt = 0; attempt <= retries; attempt++) {
855
+ try {
856
+ await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
857
+ await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
858
+ });
859
+ lead = await extractDetail(page, collect, card.name);
860
+ break;
861
+ } catch {
862
+ if (attempt < retries && !signal?.aborted) {
863
+ onProgress?.(` retrying ${i + 1}/${cards.length} (attempt ${attempt + 2})\u2026`);
864
+ if (delayMs) await page.waitForTimeout(pauseMs());
865
+ }
866
+ }
867
+ }
868
+ if (!lead) {
639
869
  if (card.name && (collect.has("name") || collect.has("mapsUrl"))) {
640
- const partial = {};
641
- if (collect.has("name")) partial.name = card.name;
642
- if (collect.has("mapsUrl")) partial.mapsUrl = card.href;
643
- leads.push(partial);
870
+ lead = {};
871
+ if (collect.has("name")) lead.name = card.name;
872
+ if (collect.has("mapsUrl")) lead.mapsUrl = card.href;
873
+ }
874
+ }
875
+ if (lead) {
876
+ leads.push(lead);
877
+ try {
878
+ onLead?.(lead);
879
+ } catch {
644
880
  }
645
881
  }
646
- if (delayMs) await page.waitForTimeout(delayMs);
882
+ if (delayMs) await page.waitForTimeout(pauseMs());
647
883
  }
648
884
  }
649
885
  onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
@@ -652,6 +888,19 @@ async function scrapeLeads(opts) {
652
888
  if (lead.website) lead.website = cleanWebsite(lead.website);
653
889
  }
654
890
  }
891
+ if (near) {
892
+ for (const lead of leads) {
893
+ if (typeof lead.latitude === "number" && typeof lead.longitude === "number") {
894
+ const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
895
+ lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
896
+ }
897
+ }
898
+ if (opts.radiusM !== void 0) {
899
+ const r = opts.radiusM;
900
+ leads = leads.filter((l) => l.distanceKm !== void 0 && l.distanceKm * 1e3 <= r);
901
+ onProgress?.(`within ${Math.round(r)} m: ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
902
+ }
903
+ }
655
904
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
656
905
  if (wantEnrich) {
657
906
  const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
@@ -676,6 +925,14 @@ async function scrapeLeads(opts) {
676
925
  signal
677
926
  );
678
927
  }
928
+ if (wantVerify && !signal?.aborted) {
929
+ onProgress?.("verifying email domains\u2026");
930
+ const vOpts = {
931
+ concurrency: Math.max(1, Math.trunc(opts.concurrency ?? 5))
932
+ };
933
+ if (onProgress) vOpts.onProgress = (m) => onProgress(m);
934
+ await verifyEmails(leads, vOpts);
935
+ }
679
936
  if (opts.country && fields.has("phone")) {
680
937
  for (const lead of leads) {
681
938
  if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
@@ -683,6 +940,17 @@ async function scrapeLeads(opts) {
683
940
  }
684
941
  if (opts.filters) leads = filterLeads(leads, opts.filters);
685
942
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
943
+ if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
944
+ if (near) {
945
+ const keepLat = fields.has("latitude");
946
+ const keepLng = fields.has("longitude");
947
+ const keepDist = fields.has("distanceKm");
948
+ for (const l of leads) {
949
+ if (!keepLat) delete l.latitude;
950
+ if (!keepLng) delete l.longitude;
951
+ if (!keepDist) delete l.distanceKm;
952
+ }
953
+ }
686
954
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
687
955
  return leads;
688
956
  } finally {
@@ -745,69 +1013,6 @@ async function searchLeadsBatch(queries, opts = {}) {
745
1013
  return merged;
746
1014
  }
747
1015
 
748
- // src/export.ts
749
- import { stringify as csvStringify } from "@lacspace/csv";
750
- import { jsonToXlsx } from "@lacspace/xlsx";
751
- var HEADERS = {
752
- name: "Name",
753
- category: "Category",
754
- rating: "Rating",
755
- reviews: "Reviews",
756
- priceLevel: "Price",
757
- address: "Address",
758
- phone: "Phone",
759
- website: "Website",
760
- email: "Email",
761
- facebook: "Facebook",
762
- instagram: "Instagram",
763
- whatsapp: "WhatsApp",
764
- linkedin: "LinkedIn",
765
- twitter: "Twitter/X",
766
- youtube: "YouTube",
767
- tiktok: "TikTok",
768
- telegram: "Telegram",
769
- plusCode: "Plus Code",
770
- latitude: "Latitude",
771
- longitude: "Longitude",
772
- hours: "Hours",
773
- mapsUrl: "Maps URL"
774
- };
775
- function toRows(leads, fields = ALL_FIELDS) {
776
- return leads.map((lead) => {
777
- const row = {};
778
- for (const f of fields) {
779
- const v = lead[f];
780
- row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
781
- }
782
- return row;
783
- });
784
- }
785
- function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
786
- if (format === "json" || format === "ndjson") {
787
- const picked = leads.map((lead) => {
788
- const o = {};
789
- for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
790
- return o;
791
- });
792
- const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
793
- return { data, binary: false };
794
- }
795
- const rows = toRows(leads, fields);
796
- if (format === "csv") {
797
- const csvRows = rows;
798
- return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
799
- }
800
- const columns = fields.map((f) => {
801
- const header = HEADERS[f];
802
- const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
803
- return { header, width: Math.min(60, Math.max(8, widest + 2)) };
804
- });
805
- return {
806
- data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
807
- binary: true
808
- };
809
- }
810
-
811
1016
  // src/convert.ts
812
1017
  import { readFile, writeFile } from "fs/promises";
813
1018
  import { extname, resolve } from "path";
@@ -900,16 +1105,20 @@ var log = (s = "") => void stderr.write(s + "\n");
900
1105
  function parseArgs(list) {
901
1106
  const a = {
902
1107
  format: "json",
1108
+ append: false,
903
1109
  limit: 60,
904
1110
  headless: false,
905
1111
  details: true,
906
1112
  delay: 700,
907
1113
  emails: false,
908
1114
  socials: false,
1115
+ verifyEmails: false,
909
1116
  hasPhone: false,
910
1117
  hasWebsite: false,
911
1118
  hasEmail: false,
1119
+ hasValidEmail: false,
912
1120
  cleanUrls: true,
1121
+ jitter: false,
913
1122
  yes: false,
914
1123
  help: false
915
1124
  };
@@ -920,10 +1129,13 @@ function parseArgs(list) {
920
1129
  else if (arg === "--area" || arg === "--areas") a.area = next();
921
1130
  else if (arg === "-t" || arg === "--type" || arg === "--types") a.type = next();
922
1131
  else if (arg === "-q" || arg === "--query") a.query = next();
1132
+ else if (arg === "--near") a.near = next();
1133
+ else if (arg === "--radius") a.radius = next();
923
1134
  else if (arg === "--fields") a.fields = next();
924
1135
  else if (arg === "--preset") a.preset = next();
925
1136
  else if (arg === "-f" || arg === "--format") a.format = next();
926
1137
  else if (arg === "-o" || arg === "--out") a.out = next();
1138
+ else if (arg === "--append") a.append = true;
927
1139
  else if (arg === "-n" || arg === "--limit") a.limit = parseInt(next(), 10) || a.limit;
928
1140
  else if (arg === "--total") a.total = parseInt(next(), 10) || a.total;
929
1141
  else if (arg === "--headless") a.headless = true;
@@ -934,13 +1146,23 @@ function parseArgs(list) {
934
1146
  else if (arg === "--enrich") {
935
1147
  a.emails = true;
936
1148
  a.socials = true;
937
- } else if (arg === "--min-rating") a.minRating = parseFloat(next());
1149
+ } else if (arg === "--verify-emails" || arg === "--verify") {
1150
+ a.verifyEmails = true;
1151
+ a.emails = true;
1152
+ } else if (arg === "--proxy") a.proxy = next();
1153
+ else if (arg === "--retries") a.retries = parseInt(next(), 10);
1154
+ else if (arg === "--jitter") a.jitter = true;
1155
+ else if (arg === "--min-rating") a.minRating = parseFloat(next());
938
1156
  else if (arg === "--min-reviews") a.minReviews = parseInt(next(), 10);
939
1157
  else if (arg === "--has-phone") a.hasPhone = true;
940
1158
  else if (arg === "--has-website") a.hasWebsite = true;
941
1159
  else if (arg === "--has-email") {
942
1160
  a.hasEmail = true;
943
1161
  a.emails = true;
1162
+ } else if (arg === "--has-valid-email") {
1163
+ a.hasValidEmail = true;
1164
+ a.verifyEmails = true;
1165
+ a.emails = true;
944
1166
  } else if (arg === "--dedupe") a.dedupe = next();
945
1167
  else if (arg === "--sort") a.sort = next();
946
1168
  else if (arg === "--desc") a.desc = true;
@@ -973,6 +1195,8 @@ ${c("bold", "Search options")}
973
1195
  --area <text> Area/neighbourhood. Comma-separate to sweep a whole
974
1196
  city, e.g. --area "Baneshwor,Thamel,Patan"
975
1197
  -q, --query <text> Raw query verbatim (overrides city/area/type)
1198
+ --near <lat,lng> Centre the search on a coordinate (radius search)
1199
+ --radius <dist> Keep only leads within this of --near, e.g. 2km, 500m, 1mi
976
1200
  --fields <list> Columns: ${ALL_FIELDS.join(",")}
977
1201
  --preset <name> Field bundle: ${Object.keys(FIELD_PRESETS).join(" | ")}
978
1202
  -n, --limit <n> Max listings per search (default 60)
@@ -984,6 +1208,7 @@ ${c("bold", "Enrichment (visits each website)")}
984
1208
  --socials Also find Facebook/Instagram/WhatsApp/LinkedIn/X/
985
1209
  YouTube/TikTok/Telegram
986
1210
  --enrich Both of the above
1211
+ --verify-emails Check each email's domain has MX records (implies --emails)
987
1212
  --concurrency <n> Websites to enrich in parallel (default 3)
988
1213
 
989
1214
  ${c("bold", "Clean-up")}
@@ -997,18 +1222,24 @@ ${c("bold", "Filters & order")}
997
1222
  --has-phone Keep only leads with a phone
998
1223
  --has-website Keep only leads with a website
999
1224
  --has-email Keep only leads with an email (implies --emails)
1000
- --dedupe <key> website | phone | name | none (default website)
1001
- --sort <key> rating | reviews | name | priceLevel
1002
- --desc / --asc Sort direction
1225
+ --has-valid-email Keep only leads with an MX-verified email (implies --verify-emails)
1226
+ --dedupe <key> website | phone | name | smart | none (default website;
1227
+ --append uses smart: website\u2192phone\u2192name)
1228
+ --sort <key> rating | reviews | name | priceLevel | distance
1229
+ --desc / --asc Sort direction (distance defaults nearest-first)
1003
1230
 
1004
1231
  ${c("bold", "Output")}
1005
1232
  -f, --format <fmt> json | ndjson | csv | xlsx (default json)
1006
1233
  -o, --out <file> Output file, or "-" for stdout (default: slug + date)
1234
+ --append Merge into an existing output file (accumulate + dedupe)
1007
1235
  --sheet <name> Excel sheet name (default "Leads")
1008
1236
 
1009
1237
  ${c("bold", "Runtime")}
1010
1238
  --delay <ms> Pause between listings (default 700)
1239
+ --jitter Randomise the delay \xB140% (more human)
1240
+ --retries <n> Retry a listing that fails to open (default 1)
1011
1241
  --max-time <s> Stop collecting after n seconds
1242
+ --proxy <url> Route the browser via a proxy (http://user:pass@host:port)
1012
1243
  --lang <locale> Browser locale, e.g. en-US, ne-NP (default en-US)
1013
1244
  --region <cc> Region bias for results, e.g. np, us
1014
1245
  --headless Run the browser without a window
@@ -1021,6 +1252,9 @@ ${c("bold", "Examples")}
1021
1252
  npx lacspace-leads gyms --city Lalitpur --min-rating 4 --sort reviews --desc
1022
1253
  npx lacspace-leads cafes --city Kathmandu --area "Thamel,Baneshwor,Patan" --country NP
1023
1254
  npx lacspace-leads salons --city Pokhara --preset outreach --country NP -f csv -o -
1255
+ npx lacspace-leads dentists --city Pokhara --verify-emails --has-valid-email -f csv
1256
+ npx lacspace-leads cafes --city Kathmandu -o master.csv --append # accumulate daily
1257
+ npx lacspace-leads restaurants --near "27.7172,85.3240" --radius 2km -f csv
1024
1258
  npx lacspace-leads convert leads.json -f xlsx
1025
1259
 
1026
1260
  ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
@@ -1107,10 +1341,35 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1107
1341
  exit(1);
1108
1342
  return;
1109
1343
  }
1344
+ const nearPoint = args.near ? parseLatLngPair(args.near) : void 0;
1345
+ if (args.near && !nearPoint) {
1346
+ log(c("red", `
1347
+ \u2717 --near must be "lat,lng", e.g. --near "27.7172,85.3240".`));
1348
+ exit(1);
1349
+ return;
1350
+ }
1351
+ const radiusM = args.radius ? parseDistance(args.radius) : void 0;
1352
+ if (args.radius && radiusM === void 0) {
1353
+ log(c("red", `
1354
+ \u2717 --radius must be a distance like 2km, 500m or 1mi.`));
1355
+ exit(1);
1356
+ return;
1357
+ }
1358
+ if (args.radius && !nearPoint) {
1359
+ log(c("red", `
1360
+ \u2717 --radius needs a centre \u2014 add --near "lat,lng".`));
1361
+ exit(1);
1362
+ return;
1363
+ }
1110
1364
  const base = args.fields ? normalizeFields(args.fields) : resolvePreset(args.preset) ?? [...DEFAULT_FIELDS];
1111
1365
  const wanted = new Set(base);
1112
1366
  if (args.emails) wanted.add("email");
1113
1367
  if (args.socials) for (const f of SOCIAL_FIELDS) wanted.add(f);
1368
+ if (args.verifyEmails) {
1369
+ wanted.add("email");
1370
+ wanted.add("emailStatus");
1371
+ }
1372
+ if (nearPoint) wanted.add("distanceKm");
1114
1373
  const fields = ALL_FIELDS.filter((f) => wanted.has(f));
1115
1374
  const toStdout = args.out === "-";
1116
1375
  const out = toStdout ? "-" : resolve2(args.out ?? defaultFilename(query, args.format));
@@ -1120,6 +1379,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1120
1379
  if (args.hasPhone) filters.hasPhone = true;
1121
1380
  if (args.hasWebsite) filters.hasWebsite = true;
1122
1381
  if (args.hasEmail) filters.hasEmail = true;
1382
+ if (args.hasValidEmail) filters.hasValidEmail = true;
1123
1383
  const hasFilters = Object.keys(filters).length > 0;
1124
1384
  const queries = expandQueries({
1125
1385
  type: args.type ?? "",
@@ -1140,10 +1400,14 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1140
1400
  ${c("dim", "search")} ${c("bold", query)}`);
1141
1401
  log(` ${c("dim", "fields")} ${fields.join(", ")}`);
1142
1402
  if (args.emails || args.socials) log(` ${c("dim", "enrich")} ${[args.emails && "emails", args.socials && "socials"].filter(Boolean).join(" + ")} ${c("dim", `(${args.concurrency ?? 3}\xD7 parallel, visits each website)`)}`);
1403
+ if (args.verifyEmails) log(` ${c("dim", "verify")} email domains (MX lookup)`);
1143
1404
  if (hasFilters) log(` ${c("dim", "filters")} ${Object.entries(filters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
1405
+ if (nearPoint) log(` ${c("dim", "near")} ${nearPoint.lat},${nearPoint.lng}${radiusM !== void 0 ? c("dim", ` (within ${args.radius})`) : c("dim", " (centred, no radius filter)")}`);
1144
1406
  if (args.sort) log(` ${c("dim", "sort")} ${args.sort} ${args.desc === false ? "asc" : "desc"}`);
1407
+ else if (nearPoint) log(` ${c("dim", "sort")} distance (nearest first)`);
1145
1408
  if (args.country) log(` ${c("dim", "phones")} E.164 for ${args.country}`);
1146
- log(` ${c("dim", "limit")} ${args.limit}${isBatch ? "/search" : ""}${args.total ? ` (cap ${args.total})` : ""} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${toStdout ? "stdout" : out}`);
1409
+ if (args.proxy) log(` ${c("dim", "proxy")} ${args.proxy.replace(/\/\/[^@]+@/, "//***@")}`);
1410
+ log(` ${c("dim", "limit")} ${args.limit}${isBatch ? "/search" : ""}${args.total ? ` (cap ${args.total})` : ""} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${toStdout ? "stdout" : out}${args.append ? c("dim", " (append)") : ""}`);
1147
1411
  if (!args.yes) {
1148
1412
  const ok = await prompt(`
1149
1413
  ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c("dim", "[y/N]")} `);
@@ -1170,17 +1434,24 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1170
1434
  delayMs: args.delay,
1171
1435
  cleanUrls: args.cleanUrls,
1172
1436
  enrich: args.emails || args.socials,
1437
+ verifyEmails: args.verifyEmails,
1438
+ jitter: args.jitter,
1173
1439
  signal: controller.signal,
1174
1440
  onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
1175
1441
  };
1176
1442
  if (hasFilters) opts.filters = filters;
1177
1443
  if (args.dedupe) opts.dedupe = args.dedupe;
1444
+ if (nearPoint) opts.near = { lat: nearPoint.lat, lng: nearPoint.lng };
1445
+ if (radiusM !== void 0) opts.radiusM = radiusM;
1178
1446
  if (args.sort) opts.sort = args.sort;
1447
+ else if (nearPoint) opts.sort = "distance";
1179
1448
  if (args.desc !== void 0) opts.sortDir = args.desc ? "desc" : "asc";
1180
1449
  if (args.country) opts.country = args.country;
1181
1450
  if (args.locale) opts.locale = args.locale;
1182
1451
  if (args.region) opts.region = args.region;
1183
1452
  if (args.concurrency !== void 0) opts.concurrency = args.concurrency;
1453
+ if (args.proxy) opts.proxy = args.proxy;
1454
+ if (args.retries !== void 0) opts.retries = args.retries;
1184
1455
  if (args.maxTime !== void 0) opts.maxMs = args.maxTime * 1e3;
1185
1456
  let leads;
1186
1457
  try {
@@ -1203,6 +1474,19 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1203
1474
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
1204
1475
  return;
1205
1476
  }
1477
+ let fresh = leads.length;
1478
+ if (args.append && !toStdout && existsSync(out)) {
1479
+ try {
1480
+ const existing = rowsToLeads(await readRows(out));
1481
+ const before = existing.length;
1482
+ const merged = dedupeLeads([...existing, ...leads], args.dedupe ?? "smart");
1483
+ fresh = merged.length - before;
1484
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${merged.length} total (${fresh} new)`)}`);
1485
+ leads = merged;
1486
+ } catch (err) {
1487
+ log(c("yellow", ` ! couldn't read "${out}" to append (${err.message}); overwriting instead`));
1488
+ }
1489
+ }
1206
1490
  const serOpts = {};
1207
1491
  if (args.sheet) serOpts.sheetName = args.sheet;
1208
1492
  const { data, binary } = serialize(leads, args.format, fields, serOpts);
@@ -1215,16 +1499,15 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1215
1499
  return;
1216
1500
  }
1217
1501
  writeFileSync(out, binary ? Buffer.from(data) : data);
1218
- const withPhone = leads.filter((l) => l.phone).length;
1219
- const withSite = leads.filter((l) => l.website).length;
1220
- const withEmail = leads.filter((l) => l.email).length;
1221
- const withSocial = leads.filter((l) => l.facebook || l.instagram || l.linkedin || l.twitter).length;
1502
+ const s = computeStats(leads);
1222
1503
  log(`
1223
- ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
1224
- const stats = [`${withPhone} with a phone`, `${withSite} with a website`];
1225
- if (args.emails) stats.push(`${withEmail} with an email`);
1226
- if (args.socials) stats.push(`${withSocial} with a social link`);
1227
- log(` ${c("dim", stats.join(" \xB7 "))}
1504
+ ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}${args.append && fresh !== leads.length ? c("dim", ` (${fresh} new)`) : ""}`);
1505
+ const parts = [`${s.withPhone} with a phone`, `${s.withWebsite} with a website`];
1506
+ if (args.emails) parts.push(`${s.withEmail} with an email`);
1507
+ if (args.verifyEmails) parts.push(`${s.withValidEmail} MX-valid`);
1508
+ if (args.socials) parts.push(`${s.withSocial} with a social link`);
1509
+ if (s.avgRating !== void 0) parts.push(`avg \u2605 ${s.avgRating}`);
1510
+ log(` ${c("dim", parts.join(" \xB7 "))}
1228
1511
  `);
1229
1512
  }
1230
1513
  main().catch((err) => {