lacspace-leads 0.3.0 → 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/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,6 +28,7 @@ var ALL_FIELDS = [
28
28
  "youtube",
29
29
  "tiktok",
30
30
  "telegram",
31
+ "emailStatus",
31
32
  "plusCode",
32
33
  "latitude",
33
34
  "longitude",
@@ -43,7 +44,8 @@ var ENRICHED_FIELDS = [
43
44
  "twitter",
44
45
  "youtube",
45
46
  "tiktok",
46
- "telegram"
47
+ "telegram",
48
+ "emailStatus"
47
49
  ];
48
50
  var FIELD_PRESETS = {
49
51
  /** Just enough to place a business: name, phone, website. */
@@ -231,6 +233,7 @@ function filterLeads(leads, filters = {}) {
231
233
  if (filters.hasPhone && !l.phone) return false;
232
234
  if (filters.hasWebsite && !l.website) return false;
233
235
  if (filters.hasEmail && !l.email) return false;
236
+ if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
234
237
  return true;
235
238
  });
236
239
  }
@@ -238,7 +241,8 @@ function dedupeKey(lead, by) {
238
241
  if (by === "none") return void 0;
239
242
  if (by === "website") return normalizeHost(lead.website);
240
243
  if (by === "phone") return lead.phone?.replace(/[^0-9]/g, "") || void 0;
241
- return lead.name?.trim().toLowerCase() || void 0;
244
+ if (by === "name") return lead.name?.trim().toLowerCase() || void 0;
245
+ return normalizeHost(lead.website) ?? (lead.phone?.replace(/[^0-9]/g, "") || void 0) ?? (lead.name?.trim().toLowerCase() || void 0);
242
246
  }
243
247
  function normalizeHost(url) {
244
248
  if (!url) return void 0;
@@ -395,6 +399,158 @@ function sortLeads(leads, by, dir) {
395
399
  }).map((x) => x.lead);
396
400
  }
397
401
 
402
+ // src/verify.ts
403
+ import { resolveMx } from "dns/promises";
404
+ var EMAIL_SHAPE = /^[^\s@]+@([^\s@.]+\.)+[^\s@.]{2,}$/;
405
+ function emailFormatValid(email) {
406
+ return !!email && EMAIL_SHAPE.test(email.trim());
407
+ }
408
+ function emailDomain(email) {
409
+ if (!email) return void 0;
410
+ const at = email.lastIndexOf("@");
411
+ return at >= 0 ? email.slice(at + 1).trim().toLowerCase() || void 0 : void 0;
412
+ }
413
+ async function domainHasMail(domain, cache, timeoutMs) {
414
+ const cached = cache.get(domain);
415
+ if (cached !== void 0) return cached;
416
+ const timer = new Promise((res) => setTimeout(() => res(false), timeoutMs));
417
+ const lookup = resolveMx(domain).then((records) => Array.isArray(records) && records.length > 0).catch(() => false);
418
+ const ok = await Promise.race([lookup, timer]);
419
+ cache.set(domain, ok);
420
+ return ok;
421
+ }
422
+ async function verifyEmail(email, opts = {}) {
423
+ if (!email) return "unknown";
424
+ if (!emailFormatValid(email)) return "invalid-format";
425
+ const domain = emailDomain(email);
426
+ if (!domain) return "invalid-format";
427
+ const has = await domainHasMail(domain, opts.cache ?? /* @__PURE__ */ new Map(), opts.timeoutMs ?? 5e3);
428
+ return has ? "valid" : "no-mx";
429
+ }
430
+ async function verifyEmails(leads, opts = {}) {
431
+ const cache = /* @__PURE__ */ new Map();
432
+ const targets = leads.filter((l) => l.email);
433
+ const n = Math.max(1, Math.min(opts.concurrency ?? 5, targets.length || 1));
434
+ let next = 0;
435
+ let done = 0;
436
+ const verifyOpts = { cache };
437
+ if (opts.timeoutMs !== void 0) verifyOpts.timeoutMs = opts.timeoutMs;
438
+ const workers = Array.from({ length: n }, async () => {
439
+ for (; ; ) {
440
+ const i = next++;
441
+ if (i >= targets.length) return;
442
+ const lead = targets[i];
443
+ lead.emailStatus = await verifyEmail(lead.email, verifyOpts);
444
+ opts.onProgress?.(`verifying email ${++done}/${targets.length}: ${lead.email}\u2026`);
445
+ }
446
+ });
447
+ await Promise.all(workers);
448
+ return leads;
449
+ }
450
+
451
+ // src/export.ts
452
+ import { stringify as csvStringify } from "@lacspace/csv";
453
+ import { jsonToXlsx } from "@lacspace/xlsx";
454
+ var HEADERS = {
455
+ name: "Name",
456
+ category: "Category",
457
+ rating: "Rating",
458
+ reviews: "Reviews",
459
+ priceLevel: "Price",
460
+ address: "Address",
461
+ phone: "Phone",
462
+ website: "Website",
463
+ email: "Email",
464
+ facebook: "Facebook",
465
+ instagram: "Instagram",
466
+ whatsapp: "WhatsApp",
467
+ linkedin: "LinkedIn",
468
+ twitter: "Twitter/X",
469
+ youtube: "YouTube",
470
+ tiktok: "TikTok",
471
+ telegram: "Telegram",
472
+ emailStatus: "Email Status",
473
+ plusCode: "Plus Code",
474
+ latitude: "Latitude",
475
+ longitude: "Longitude",
476
+ hours: "Hours",
477
+ mapsUrl: "Maps URL"
478
+ };
479
+ function toRows(leads, fields = ALL_FIELDS) {
480
+ return leads.map((lead) => {
481
+ const row = {};
482
+ for (const f of fields) {
483
+ const v = lead[f];
484
+ row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
485
+ }
486
+ return row;
487
+ });
488
+ }
489
+ function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
490
+ if (format === "json" || format === "ndjson") {
491
+ const picked = leads.map((lead) => {
492
+ const o = {};
493
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
494
+ return o;
495
+ });
496
+ const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
497
+ return { data, binary: false };
498
+ }
499
+ const rows = toRows(leads, fields);
500
+ if (format === "csv") {
501
+ const csvRows = rows;
502
+ return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
503
+ }
504
+ const columns = fields.map((f) => {
505
+ const header = HEADERS[f];
506
+ const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
507
+ return { header, width: Math.min(60, Math.max(8, widest + 2)) };
508
+ });
509
+ return {
510
+ data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
511
+ binary: true
512
+ };
513
+ }
514
+ function computeStats(leads) {
515
+ const socialKeys = ["facebook", "instagram", "whatsapp", "linkedin", "twitter", "youtube", "tiktok", "telegram"];
516
+ const ratings = leads.map((l) => l.rating).filter((r) => typeof r === "number");
517
+ const stats = {
518
+ total: leads.length,
519
+ withPhone: leads.filter((l) => l.phone).length,
520
+ withWebsite: leads.filter((l) => l.website).length,
521
+ withEmail: leads.filter((l) => l.email).length,
522
+ withValidEmail: leads.filter((l) => l.emailStatus === "valid").length,
523
+ withSocial: leads.filter((l) => socialKeys.some((k) => l[k])).length
524
+ };
525
+ if (ratings.length) stats.avgRating = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
526
+ return stats;
527
+ }
528
+ var FIELD_BY_KEY = (() => {
529
+ const m = /* @__PURE__ */ new Map();
530
+ for (const f of ALL_FIELDS) {
531
+ m.set(f.toLowerCase(), f);
532
+ m.set(HEADERS[f].toLowerCase(), f);
533
+ }
534
+ return m;
535
+ })();
536
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
537
+ function rowsToLeads(rows) {
538
+ return rows.map((row) => {
539
+ const lead = {};
540
+ for (const [key, raw] of Object.entries(row)) {
541
+ const field = FIELD_BY_KEY.get(key.trim().toLowerCase());
542
+ if (!field || raw === null || raw === void 0 || raw === "") continue;
543
+ if (NUMERIC_FIELDS.has(field)) {
544
+ const n = typeof raw === "number" ? raw : parseFloat(String(raw));
545
+ if (Number.isFinite(n)) lead[field] = n;
546
+ } else {
547
+ lead[field] = String(raw);
548
+ }
549
+ }
550
+ return lead;
551
+ });
552
+ }
553
+
398
554
  // src/scrape.ts
399
555
  async function pool(items, n, fn, signal) {
400
556
  let next = 0;
@@ -444,17 +600,19 @@ function parseLatLng(url) {
444
600
  if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
445
601
  return {};
446
602
  }
447
- async function launchBrowser(headless) {
603
+ async function launchBrowser(headless, proxy) {
448
604
  let lastErr;
605
+ const base = { headless };
606
+ if (proxy) base.proxy = { server: proxy };
449
607
  for (const channel of ["chrome", "msedge"]) {
450
608
  try {
451
- return await chromium.launch({ headless, channel });
609
+ return await chromium.launch({ ...base, channel });
452
610
  } catch (e) {
453
611
  lastErr = e;
454
612
  }
455
613
  }
456
614
  try {
457
- return await chromium.launch({ headless });
615
+ return await chromium.launch(base);
458
616
  } catch (e) {
459
617
  lastErr = e;
460
618
  }
@@ -577,17 +735,21 @@ async function scrapeLeads(opts) {
577
735
  const fields = new Set(normalizeFields(opts.fields ?? DEFAULT_FIELDS));
578
736
  const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
579
737
  const headless = opts.headless ?? false;
738
+ const retries = Math.max(0, Math.trunc(opts.retries ?? 1));
580
739
  const onProgress = opts.onProgress;
740
+ const onLead = opts.onLead;
581
741
  const signal = opts.signal;
582
742
  const startedAt = Date.now();
583
743
  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);
744
+ const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
745
+ const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
746
+ const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
585
747
  const collect = new Set(fields);
586
748
  if (wantEnrich) collect.add("website");
587
749
  const detailOnly = ["name", "mapsUrl"];
588
750
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
589
751
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
590
- const browser = await launchBrowser(headless);
752
+ const browser = await launchBrowser(headless, opts.proxy);
591
753
  try {
592
754
  const locale = opts.locale ?? "en-US";
593
755
  const context = await browser.newContext({
@@ -625,25 +787,47 @@ async function scrapeLeads(opts) {
625
787
  }
626
788
  return lead;
627
789
  });
790
+ for (const lead of leads) {
791
+ try {
792
+ onLead?.(lead);
793
+ } catch {
794
+ }
795
+ }
628
796
  } else {
629
797
  for (let i = 0; i < cards.length; i++) {
630
798
  if (signal?.aborted || overBudget()) break;
631
799
  const card = cards[i];
632
800
  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 {
801
+ let lead;
802
+ for (let attempt = 0; attempt <= retries; attempt++) {
803
+ try {
804
+ await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
805
+ await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
806
+ });
807
+ lead = await extractDetail(page, collect, card.name);
808
+ break;
809
+ } catch {
810
+ if (attempt < retries && !signal?.aborted) {
811
+ onProgress?.(` retrying ${i + 1}/${cards.length} (attempt ${attempt + 2})\u2026`);
812
+ if (delayMs) await page.waitForTimeout(pauseMs());
813
+ }
814
+ }
815
+ }
816
+ if (!lead) {
639
817
  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);
818
+ lead = {};
819
+ if (collect.has("name")) lead.name = card.name;
820
+ if (collect.has("mapsUrl")) lead.mapsUrl = card.href;
644
821
  }
645
822
  }
646
- if (delayMs) await page.waitForTimeout(delayMs);
823
+ if (lead) {
824
+ leads.push(lead);
825
+ try {
826
+ onLead?.(lead);
827
+ } catch {
828
+ }
829
+ }
830
+ if (delayMs) await page.waitForTimeout(pauseMs());
647
831
  }
648
832
  }
649
833
  onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
@@ -676,6 +860,14 @@ async function scrapeLeads(opts) {
676
860
  signal
677
861
  );
678
862
  }
863
+ if (wantVerify && !signal?.aborted) {
864
+ onProgress?.("verifying email domains\u2026");
865
+ const vOpts = {
866
+ concurrency: Math.max(1, Math.trunc(opts.concurrency ?? 5))
867
+ };
868
+ if (onProgress) vOpts.onProgress = (m) => onProgress(m);
869
+ await verifyEmails(leads, vOpts);
870
+ }
679
871
  if (opts.country && fields.has("phone")) {
680
872
  for (const lead of leads) {
681
873
  if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
@@ -683,6 +875,7 @@ async function scrapeLeads(opts) {
683
875
  }
684
876
  if (opts.filters) leads = filterLeads(leads, opts.filters);
685
877
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
878
+ if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
686
879
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
687
880
  return leads;
688
881
  } finally {
@@ -745,69 +938,6 @@ async function searchLeadsBatch(queries, opts = {}) {
745
938
  return merged;
746
939
  }
747
940
 
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
941
  // src/convert.ts
812
942
  import { readFile, writeFile } from "fs/promises";
813
943
  import { extname, resolve } from "path";
@@ -900,16 +1030,20 @@ var log = (s = "") => void stderr.write(s + "\n");
900
1030
  function parseArgs(list) {
901
1031
  const a = {
902
1032
  format: "json",
1033
+ append: false,
903
1034
  limit: 60,
904
1035
  headless: false,
905
1036
  details: true,
906
1037
  delay: 700,
907
1038
  emails: false,
908
1039
  socials: false,
1040
+ verifyEmails: false,
909
1041
  hasPhone: false,
910
1042
  hasWebsite: false,
911
1043
  hasEmail: false,
1044
+ hasValidEmail: false,
912
1045
  cleanUrls: true,
1046
+ jitter: false,
913
1047
  yes: false,
914
1048
  help: false
915
1049
  };
@@ -924,6 +1058,7 @@ function parseArgs(list) {
924
1058
  else if (arg === "--preset") a.preset = next();
925
1059
  else if (arg === "-f" || arg === "--format") a.format = next();
926
1060
  else if (arg === "-o" || arg === "--out") a.out = next();
1061
+ else if (arg === "--append") a.append = true;
927
1062
  else if (arg === "-n" || arg === "--limit") a.limit = parseInt(next(), 10) || a.limit;
928
1063
  else if (arg === "--total") a.total = parseInt(next(), 10) || a.total;
929
1064
  else if (arg === "--headless") a.headless = true;
@@ -934,13 +1069,23 @@ function parseArgs(list) {
934
1069
  else if (arg === "--enrich") {
935
1070
  a.emails = true;
936
1071
  a.socials = true;
937
- } else if (arg === "--min-rating") a.minRating = parseFloat(next());
1072
+ } else if (arg === "--verify-emails" || arg === "--verify") {
1073
+ a.verifyEmails = true;
1074
+ a.emails = true;
1075
+ } else if (arg === "--proxy") a.proxy = next();
1076
+ else if (arg === "--retries") a.retries = parseInt(next(), 10);
1077
+ else if (arg === "--jitter") a.jitter = true;
1078
+ else if (arg === "--min-rating") a.minRating = parseFloat(next());
938
1079
  else if (arg === "--min-reviews") a.minReviews = parseInt(next(), 10);
939
1080
  else if (arg === "--has-phone") a.hasPhone = true;
940
1081
  else if (arg === "--has-website") a.hasWebsite = true;
941
1082
  else if (arg === "--has-email") {
942
1083
  a.hasEmail = true;
943
1084
  a.emails = true;
1085
+ } else if (arg === "--has-valid-email") {
1086
+ a.hasValidEmail = true;
1087
+ a.verifyEmails = true;
1088
+ a.emails = true;
944
1089
  } else if (arg === "--dedupe") a.dedupe = next();
945
1090
  else if (arg === "--sort") a.sort = next();
946
1091
  else if (arg === "--desc") a.desc = true;
@@ -984,6 +1129,7 @@ ${c("bold", "Enrichment (visits each website)")}
984
1129
  --socials Also find Facebook/Instagram/WhatsApp/LinkedIn/X/
985
1130
  YouTube/TikTok/Telegram
986
1131
  --enrich Both of the above
1132
+ --verify-emails Check each email's domain has MX records (implies --emails)
987
1133
  --concurrency <n> Websites to enrich in parallel (default 3)
988
1134
 
989
1135
  ${c("bold", "Clean-up")}
@@ -997,18 +1143,24 @@ ${c("bold", "Filters & order")}
997
1143
  --has-phone Keep only leads with a phone
998
1144
  --has-website Keep only leads with a website
999
1145
  --has-email Keep only leads with an email (implies --emails)
1000
- --dedupe <key> website | phone | name | none (default website)
1146
+ --has-valid-email Keep only leads with an MX-verified email (implies --verify-emails)
1147
+ --dedupe <key> website | phone | name | smart | none (default website;
1148
+ --append uses smart: website\u2192phone\u2192name)
1001
1149
  --sort <key> rating | reviews | name | priceLevel
1002
1150
  --desc / --asc Sort direction
1003
1151
 
1004
1152
  ${c("bold", "Output")}
1005
1153
  -f, --format <fmt> json | ndjson | csv | xlsx (default json)
1006
1154
  -o, --out <file> Output file, or "-" for stdout (default: slug + date)
1155
+ --append Merge into an existing output file (accumulate + dedupe)
1007
1156
  --sheet <name> Excel sheet name (default "Leads")
1008
1157
 
1009
1158
  ${c("bold", "Runtime")}
1010
1159
  --delay <ms> Pause between listings (default 700)
1160
+ --jitter Randomise the delay \xB140% (more human)
1161
+ --retries <n> Retry a listing that fails to open (default 1)
1011
1162
  --max-time <s> Stop collecting after n seconds
1163
+ --proxy <url> Route the browser via a proxy (http://user:pass@host:port)
1012
1164
  --lang <locale> Browser locale, e.g. en-US, ne-NP (default en-US)
1013
1165
  --region <cc> Region bias for results, e.g. np, us
1014
1166
  --headless Run the browser without a window
@@ -1021,6 +1173,8 @@ ${c("bold", "Examples")}
1021
1173
  npx lacspace-leads gyms --city Lalitpur --min-rating 4 --sort reviews --desc
1022
1174
  npx lacspace-leads cafes --city Kathmandu --area "Thamel,Baneshwor,Patan" --country NP
1023
1175
  npx lacspace-leads salons --city Pokhara --preset outreach --country NP -f csv -o -
1176
+ npx lacspace-leads dentists --city Pokhara --verify-emails --has-valid-email -f csv
1177
+ npx lacspace-leads cafes --city Kathmandu -o master.csv --append # accumulate daily
1024
1178
  npx lacspace-leads convert leads.json -f xlsx
1025
1179
 
1026
1180
  ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
@@ -1111,6 +1265,10 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1111
1265
  const wanted = new Set(base);
1112
1266
  if (args.emails) wanted.add("email");
1113
1267
  if (args.socials) for (const f of SOCIAL_FIELDS) wanted.add(f);
1268
+ if (args.verifyEmails) {
1269
+ wanted.add("email");
1270
+ wanted.add("emailStatus");
1271
+ }
1114
1272
  const fields = ALL_FIELDS.filter((f) => wanted.has(f));
1115
1273
  const toStdout = args.out === "-";
1116
1274
  const out = toStdout ? "-" : resolve2(args.out ?? defaultFilename(query, args.format));
@@ -1120,6 +1278,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1120
1278
  if (args.hasPhone) filters.hasPhone = true;
1121
1279
  if (args.hasWebsite) filters.hasWebsite = true;
1122
1280
  if (args.hasEmail) filters.hasEmail = true;
1281
+ if (args.hasValidEmail) filters.hasValidEmail = true;
1123
1282
  const hasFilters = Object.keys(filters).length > 0;
1124
1283
  const queries = expandQueries({
1125
1284
  type: args.type ?? "",
@@ -1140,10 +1299,12 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1140
1299
  ${c("dim", "search")} ${c("bold", query)}`);
1141
1300
  log(` ${c("dim", "fields")} ${fields.join(", ")}`);
1142
1301
  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)`)}`);
1302
+ if (args.verifyEmails) log(` ${c("dim", "verify")} email domains (MX lookup)`);
1143
1303
  if (hasFilters) log(` ${c("dim", "filters")} ${Object.entries(filters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
1144
1304
  if (args.sort) log(` ${c("dim", "sort")} ${args.sort} ${args.desc === false ? "asc" : "desc"}`);
1145
1305
  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}`);
1306
+ if (args.proxy) log(` ${c("dim", "proxy")} ${args.proxy.replace(/\/\/[^@]+@/, "//***@")}`);
1307
+ 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
1308
  if (!args.yes) {
1148
1309
  const ok = await prompt(`
1149
1310
  ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c("dim", "[y/N]")} `);
@@ -1170,6 +1331,8 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1170
1331
  delayMs: args.delay,
1171
1332
  cleanUrls: args.cleanUrls,
1172
1333
  enrich: args.emails || args.socials,
1334
+ verifyEmails: args.verifyEmails,
1335
+ jitter: args.jitter,
1173
1336
  signal: controller.signal,
1174
1337
  onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
1175
1338
  };
@@ -1181,6 +1344,8 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1181
1344
  if (args.locale) opts.locale = args.locale;
1182
1345
  if (args.region) opts.region = args.region;
1183
1346
  if (args.concurrency !== void 0) opts.concurrency = args.concurrency;
1347
+ if (args.proxy) opts.proxy = args.proxy;
1348
+ if (args.retries !== void 0) opts.retries = args.retries;
1184
1349
  if (args.maxTime !== void 0) opts.maxMs = args.maxTime * 1e3;
1185
1350
  let leads;
1186
1351
  try {
@@ -1203,6 +1368,19 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1203
1368
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
1204
1369
  return;
1205
1370
  }
1371
+ let fresh = leads.length;
1372
+ if (args.append && !toStdout && existsSync(out)) {
1373
+ try {
1374
+ const existing = rowsToLeads(await readRows(out));
1375
+ const before = existing.length;
1376
+ const merged = dedupeLeads([...existing, ...leads], args.dedupe ?? "smart");
1377
+ fresh = merged.length - before;
1378
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${merged.length} total (${fresh} new)`)}`);
1379
+ leads = merged;
1380
+ } catch (err) {
1381
+ log(c("yellow", ` ! couldn't read "${out}" to append (${err.message}); overwriting instead`));
1382
+ }
1383
+ }
1206
1384
  const serOpts = {};
1207
1385
  if (args.sheet) serOpts.sheetName = args.sheet;
1208
1386
  const { data, binary } = serialize(leads, args.format, fields, serOpts);
@@ -1215,16 +1393,15 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1215
1393
  return;
1216
1394
  }
1217
1395
  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;
1396
+ const s = computeStats(leads);
1222
1397
  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 "))}
1398
+ ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}${args.append && fresh !== leads.length ? c("dim", ` (${fresh} new)`) : ""}`);
1399
+ const parts = [`${s.withPhone} with a phone`, `${s.withWebsite} with a website`];
1400
+ if (args.emails) parts.push(`${s.withEmail} with an email`);
1401
+ if (args.verifyEmails) parts.push(`${s.withValidEmail} MX-valid`);
1402
+ if (args.socials) parts.push(`${s.withSocial} with a social link`);
1403
+ if (s.avgRating !== void 0) parts.push(`avg \u2605 ${s.avgRating}`);
1404
+ log(` ${c("dim", parts.join(" \xB7 "))}
1228
1405
  `);
1229
1406
  }
1230
1407
  main().catch((err) => {