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/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";
@@ -23,13 +23,44 @@ var ALL_FIELDS = [
23
23
  "facebook",
24
24
  "instagram",
25
25
  "whatsapp",
26
+ "linkedin",
27
+ "twitter",
28
+ "youtube",
29
+ "tiktok",
30
+ "telegram",
31
+ "emailStatus",
26
32
  "plusCode",
27
33
  "latitude",
28
34
  "longitude",
29
35
  "hours",
30
36
  "mapsUrl"
31
37
  ];
32
- var ENRICHED_FIELDS = ["email", "facebook", "instagram", "whatsapp"];
38
+ var ENRICHED_FIELDS = [
39
+ "email",
40
+ "facebook",
41
+ "instagram",
42
+ "whatsapp",
43
+ "linkedin",
44
+ "twitter",
45
+ "youtube",
46
+ "tiktok",
47
+ "telegram",
48
+ "emailStatus"
49
+ ];
50
+ var FIELD_PRESETS = {
51
+ /** Just enough to place a business: name, phone, website. */
52
+ minimal: ["name", "phone", "website"],
53
+ /** Cold-outreach columns: who, how to reach them, where. */
54
+ outreach: ["name", "phone", "email", "website", "address"],
55
+ /** Contact-book columns incl. socials (implies enrichment). */
56
+ contact: ["name", "phone", "email", "website", "facebook", "instagram", "whatsapp"],
57
+ /** Map/geo columns for plotting. */
58
+ geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
59
+ /** Everything Maps shows, no website enrichment. */
60
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
61
+ /** Every field, including enriched ones. */
62
+ everything: [...ALL_FIELDS]
63
+ };
33
64
  var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
34
65
 
35
66
  // src/query.ts
@@ -40,8 +71,38 @@ function composeQuery(opts) {
40
71
  if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
41
72
  return where ? `${type} in ${where}` : type;
42
73
  }
43
- function mapsSearchUrl(query) {
44
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
74
+ function mapsSearchUrl(query, opts = {}) {
75
+ const hl = (opts.hl ?? "en").split("-")[0] || "en";
76
+ const params = new URLSearchParams({ hl });
77
+ if (opts.gl) params.set("gl", opts.gl.toLowerCase());
78
+ return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
79
+ }
80
+ function expandQueries(opts) {
81
+ if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
82
+ const split = (s) => {
83
+ const parts = (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
84
+ return parts.length ? parts : [void 0];
85
+ };
86
+ const types = split(opts.type);
87
+ const cities = split(opts.city);
88
+ const areas = split(opts.area);
89
+ const out = [];
90
+ for (const type of types) {
91
+ for (const city of cities) {
92
+ for (const area of areas) {
93
+ const entry = {};
94
+ if (type) entry.type = type;
95
+ if (city) entry.city = city;
96
+ if (area) entry.area = area;
97
+ out.push(entry);
98
+ }
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ function resolvePreset(name) {
104
+ if (!name) return void 0;
105
+ return FIELD_PRESETS[name.trim().toLowerCase()];
45
106
  }
46
107
  function normalizeFields(input) {
47
108
  if (!input) return [...ALL_FIELDS];
@@ -82,12 +143,23 @@ function extractSocials(html) {
82
143
  const hrefs = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? "");
83
144
  for (const href of hrefs) {
84
145
  const u = href.trim();
85
- if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?/i.test(u)) {
146
+ if (!u) continue;
147
+ if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?|dialog\//i.test(u)) {
86
148
  out.facebook = absolutize(u);
87
- } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u)) {
149
+ } else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u) && !/\/(share|p)\//i.test(u)) {
88
150
  out.instagram = absolutize(u);
89
151
  } else if (!out.whatsapp && /(wa\.me\/|api\.whatsapp\.com\/|whatsapp:\/\/)/i.test(u)) {
90
152
  out.whatsapp = u.startsWith("http") || u.startsWith("whatsapp") ? u : `https:${u}`;
153
+ } else if (!out.linkedin && /(^|\/\/|\.)linkedin\.com\/(company|in|school)\//i.test(u) && !/sharing|shareArticle/i.test(u)) {
154
+ out.linkedin = absolutize(u);
155
+ } else if (!out.twitter && /(^|\/\/|\.)(twitter|x)\.com\//i.test(u) && !/(intent|share|widgets|hashtag)\b/i.test(u)) {
156
+ out.twitter = absolutize(u);
157
+ } else if (!out.youtube && /(youtube\.com\/(channel|c|user|@)|youtu\.be\/)/i.test(u)) {
158
+ out.youtube = absolutize(u);
159
+ } else if (!out.tiktok && /(^|\/\/|\.)tiktok\.com\/@/i.test(u)) {
160
+ out.tiktok = absolutize(u);
161
+ } else if (!out.telegram && /(t\.me\/|telegram\.me\/)/i.test(u) && !/\/share\b/i.test(u)) {
162
+ out.telegram = absolutize(u);
91
163
  }
92
164
  }
93
165
  return out;
@@ -138,11 +210,9 @@ async function enrichContacts(website, opts = {}) {
138
210
  const email = extractEmails(page);
139
211
  if (email) {
140
212
  contacts.email = email;
141
- if (!contacts.facebook || !contacts.instagram || !contacts.whatsapp) {
142
- const s = extractSocials(page);
143
- if (!contacts.facebook && s.facebook) contacts.facebook = s.facebook;
144
- if (!contacts.instagram && s.instagram) contacts.instagram = s.instagram;
145
- if (!contacts.whatsapp && s.whatsapp) contacts.whatsapp = s.whatsapp;
213
+ const s = extractSocials(page);
214
+ for (const k of Object.keys(s)) {
215
+ if (!contacts[k] && s[k]) contacts[k] = s[k];
146
216
  }
147
217
  break;
148
218
  }
@@ -163,6 +233,7 @@ function filterLeads(leads, filters = {}) {
163
233
  if (filters.hasPhone && !l.phone) return false;
164
234
  if (filters.hasWebsite && !l.website) return false;
165
235
  if (filters.hasEmail && !l.email) return false;
236
+ if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
166
237
  return true;
167
238
  });
168
239
  }
@@ -170,7 +241,8 @@ function dedupeKey(lead, by) {
170
241
  if (by === "none") return void 0;
171
242
  if (by === "website") return normalizeHost(lead.website);
172
243
  if (by === "phone") return lead.phone?.replace(/[^0-9]/g, "") || void 0;
173
- 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);
174
246
  }
175
247
  function normalizeHost(url) {
176
248
  if (!url) return void 0;
@@ -197,7 +269,300 @@ function dedupeLeads(leads, by = "website") {
197
269
  return out;
198
270
  }
199
271
 
272
+ // src/normalize.ts
273
+ var TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|gbraid$|wbraid$|mc_|_hs|ref$|ref_src$|source$|igshid$|yclid$)/i;
274
+ function cleanWebsite(url) {
275
+ if (!url) return void 0;
276
+ let raw = url.trim();
277
+ if (!raw) return void 0;
278
+ const g = raw.match(/[?&](?:q|url)=([^&]+)/i);
279
+ if (g && /google\.[a-z.]+\/url/i.test(raw)) {
280
+ try {
281
+ raw = decodeURIComponent(g[1]);
282
+ } catch {
283
+ }
284
+ }
285
+ let u;
286
+ try {
287
+ u = new URL(raw.startsWith("http") ? raw : `https://${raw}`);
288
+ } catch {
289
+ return raw;
290
+ }
291
+ u.hash = "";
292
+ u.host = u.host.toLowerCase();
293
+ for (const key of [...u.searchParams.keys()]) {
294
+ if (TRACKING_PARAMS.test(key)) u.searchParams.delete(key);
295
+ }
296
+ let href = u.toString();
297
+ if (u.pathname === "/" && !u.search) href = href.replace(/\/$/, "");
298
+ return href;
299
+ }
300
+ var CALLING_CODES = {
301
+ NP: "977",
302
+ IN: "91",
303
+ US: "1",
304
+ CA: "1",
305
+ GB: "44",
306
+ AU: "61",
307
+ NZ: "64",
308
+ SG: "65",
309
+ MY: "60",
310
+ ID: "62",
311
+ TH: "66",
312
+ PH: "63",
313
+ VN: "84",
314
+ BD: "880",
315
+ PK: "92",
316
+ LK: "94",
317
+ AE: "971",
318
+ SA: "966",
319
+ QA: "974",
320
+ KW: "965",
321
+ BH: "973",
322
+ OM: "968",
323
+ CN: "86",
324
+ HK: "852",
325
+ JP: "81",
326
+ KR: "82",
327
+ DE: "49",
328
+ FR: "33",
329
+ ES: "34",
330
+ IT: "39",
331
+ NL: "31",
332
+ BE: "32",
333
+ CH: "41",
334
+ AT: "43",
335
+ SE: "46",
336
+ NO: "47",
337
+ DK: "45",
338
+ FI: "358",
339
+ IE: "353",
340
+ PT: "351",
341
+ PL: "48",
342
+ ZA: "27",
343
+ NG: "234",
344
+ KE: "254",
345
+ EG: "20",
346
+ BR: "55",
347
+ MX: "52",
348
+ AR: "54",
349
+ TR: "90"
350
+ };
351
+ function callingCode(country) {
352
+ if (!country) return void 0;
353
+ const c2 = country.trim().replace(/^\+/, "");
354
+ if (/^\d{1,4}$/.test(c2)) return c2;
355
+ return CALLING_CODES[c2.toUpperCase()];
356
+ }
357
+ function normalizePhone(raw, country) {
358
+ if (!raw) return void 0;
359
+ const trimmed = raw.trim();
360
+ if (!trimmed) return void 0;
361
+ const isIntl = /^\+/.test(trimmed) || /^00\d/.test(trimmed);
362
+ let digits = trimmed.replace(/[^0-9]/g, "");
363
+ if (/^00\d/.test(trimmed)) digits = digits.replace(/^00/, "");
364
+ const cc = callingCode(country);
365
+ if (isIntl) {
366
+ return plausible(digits) ? `+${digits}` : trimmed;
367
+ }
368
+ if (!cc) return trimmed;
369
+ if (digits.startsWith(cc)) return plausible(digits) ? `+${digits}` : trimmed;
370
+ const local = digits.replace(/^0+/, "");
371
+ const e164 = `${cc}${local}`;
372
+ return plausible(e164) ? `+${e164}` : trimmed;
373
+ }
374
+ function plausible(digits) {
375
+ return digits.length >= 8 && digits.length <= 15;
376
+ }
377
+ var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
378
+ function sortLeads(leads, by, dir) {
379
+ if (!by) return [...leads];
380
+ const desc = dir ? dir === "desc" : by !== "name";
381
+ const val = (l) => {
382
+ if (by === "name") return l.name?.toLowerCase();
383
+ if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
384
+ return l[by];
385
+ };
386
+ return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
387
+ const av = val(a.lead);
388
+ const bv = val(b.lead);
389
+ const aMissing = av === void 0 || av === "";
390
+ const bMissing = bv === void 0 || bv === "";
391
+ if (aMissing && bMissing) return a.i - b.i;
392
+ if (aMissing) return 1;
393
+ if (bMissing) return -1;
394
+ let cmp = 0;
395
+ if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
396
+ else cmp = String(av).localeCompare(String(bv));
397
+ if (desc) cmp = -cmp;
398
+ return cmp !== 0 ? cmp : a.i - b.i;
399
+ }).map((x) => x.lead);
400
+ }
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
+
200
554
  // src/scrape.ts
555
+ async function pool(items, n, fn, signal) {
556
+ let next = 0;
557
+ const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
558
+ for (; ; ) {
559
+ const i = next++;
560
+ if (i >= items.length || signal?.aborted) return;
561
+ await fn(items[i], i);
562
+ }
563
+ });
564
+ await Promise.all(workers);
565
+ }
201
566
  var LeadsError = class extends Error {
202
567
  code;
203
568
  cause;
@@ -235,17 +600,19 @@ function parseLatLng(url) {
235
600
  if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
236
601
  return {};
237
602
  }
238
- async function launchBrowser(headless) {
603
+ async function launchBrowser(headless, proxy) {
239
604
  let lastErr;
605
+ const base = { headless };
606
+ if (proxy) base.proxy = { server: proxy };
240
607
  for (const channel of ["chrome", "msedge"]) {
241
608
  try {
242
- return await chromium.launch({ headless, channel });
609
+ return await chromium.launch({ ...base, channel });
243
610
  } catch (e) {
244
611
  lastErr = e;
245
612
  }
246
613
  }
247
614
  try {
248
- return await chromium.launch({ headless });
615
+ return await chromium.launch(base);
249
616
  } catch (e) {
250
617
  lastErr = e;
251
618
  }
@@ -322,7 +689,13 @@ async function extractDetail(page, fields, fallbackName) {
322
689
  }
323
690
  if (fields.has("reviews")) {
324
691
  const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
325
- lead.reviews = parseReviewCount(rv);
692
+ let count = parseReviewCount(rv);
693
+ if (count === void 0) {
694
+ const boxText = await box.innerText().catch(() => "");
695
+ const m = boxText.match(/\(([\d.,\s]+)\)/);
696
+ if (m) count = parseReviewCount(m[1]);
697
+ }
698
+ lead.reviews = count;
326
699
  }
327
700
  }
328
701
  }
@@ -362,24 +735,31 @@ async function scrapeLeads(opts) {
362
735
  const fields = new Set(normalizeFields(opts.fields ?? DEFAULT_FIELDS));
363
736
  const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
364
737
  const headless = opts.headless ?? false;
738
+ const retries = Math.max(0, Math.trunc(opts.retries ?? 1));
365
739
  const onProgress = opts.onProgress;
740
+ const onLead = opts.onLead;
366
741
  const signal = opts.signal;
367
742
  const startedAt = Date.now();
368
743
  const overBudget = () => opts.maxMs !== void 0 && Date.now() - startedAt > opts.maxMs;
369
- 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;
370
747
  const collect = new Set(fields);
371
748
  if (wantEnrich) collect.add("website");
372
749
  const detailOnly = ["name", "mapsUrl"];
373
750
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
374
751
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
375
- const browser = await launchBrowser(headless);
752
+ const browser = await launchBrowser(headless, opts.proxy);
376
753
  try {
754
+ const locale = opts.locale ?? "en-US";
377
755
  const context = await browser.newContext({
378
756
  viewport: { width: 1280, height: 900 },
379
- locale: "en-US"
757
+ locale
380
758
  });
381
759
  const page = await context.newPage();
382
- await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
760
+ const urlOpts = { hl: locale };
761
+ if (opts.region) urlOpts.gl = opts.region;
762
+ await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
383
763
  await dismissConsent(page);
384
764
  await loadResults(page, limit, delayMs, onProgress, signal);
385
765
  if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
@@ -407,44 +787,95 @@ async function scrapeLeads(opts) {
407
787
  }
408
788
  return lead;
409
789
  });
790
+ for (const lead of leads) {
791
+ try {
792
+ onLead?.(lead);
793
+ } catch {
794
+ }
795
+ }
410
796
  } else {
411
797
  for (let i = 0; i < cards.length; i++) {
412
798
  if (signal?.aborted || overBudget()) break;
413
799
  const card = cards[i];
414
800
  onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
415
- try {
416
- await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
417
- await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
418
- });
419
- leads.push(await extractDetail(page, collect, card.name));
420
- } 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) {
421
817
  if (card.name && (collect.has("name") || collect.has("mapsUrl"))) {
422
- const partial = {};
423
- if (collect.has("name")) partial.name = card.name;
424
- if (collect.has("mapsUrl")) partial.mapsUrl = card.href;
425
- leads.push(partial);
818
+ lead = {};
819
+ if (collect.has("name")) lead.name = card.name;
820
+ if (collect.has("mapsUrl")) lead.mapsUrl = card.href;
426
821
  }
427
822
  }
428
- 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());
429
831
  }
430
832
  }
431
833
  onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
834
+ if (opts.cleanUrls ?? true) {
835
+ for (const lead of leads) {
836
+ if (lead.website) lead.website = cleanWebsite(lead.website);
837
+ }
838
+ }
432
839
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
433
840
  if (wantEnrich) {
434
- for (let i = 0; i < leads.length; i++) {
435
- if (signal?.aborted || overBudget()) break;
436
- const site = leads[i].website;
437
- if (!site) continue;
438
- onProgress?.(`enriching ${i + 1}/${leads.length}: ${leads[i].name ?? site}\u2026`);
439
- const c2 = await enrichContacts(site);
440
- if (fields.has("email") && c2.email) leads[i].email = c2.email;
441
- if (fields.has("facebook") && c2.facebook) leads[i].facebook = c2.facebook;
442
- if (fields.has("instagram") && c2.instagram) leads[i].instagram = c2.instagram;
443
- if (fields.has("whatsapp") && c2.whatsapp) leads[i].whatsapp = c2.whatsapp;
444
- if (!fields.has("website")) delete leads[i].website;
841
+ const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
842
+ let done = 0;
843
+ await pool(
844
+ leads,
845
+ Math.max(1, Math.trunc(opts.concurrency ?? 3)),
846
+ async (lead) => {
847
+ if (overBudget()) return;
848
+ const site = lead.website;
849
+ if (site) {
850
+ onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
851
+ const c2 = await enrichContacts(site);
852
+ if (fields.has("email") && c2.email) lead.email = c2.email;
853
+ for (const f of socialFields) {
854
+ const v = c2[f];
855
+ if (fields.has(f) && v) lead[f] = v;
856
+ }
857
+ }
858
+ if (!fields.has("website")) delete lead.website;
859
+ },
860
+ signal
861
+ );
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
+ }
871
+ if (opts.country && fields.has("phone")) {
872
+ for (const lead of leads) {
873
+ if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
445
874
  }
446
875
  }
447
876
  if (opts.filters) leads = filterLeads(leads, opts.filters);
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;
448
879
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
449
880
  return leads;
450
881
  } finally {
@@ -453,61 +884,58 @@ async function scrapeLeads(opts) {
453
884
  }
454
885
  }
455
886
 
456
- // src/export.ts
457
- import { stringify as csvStringify } from "@lacspace/csv";
458
- import { jsonToXlsx } from "@lacspace/xlsx";
459
- var HEADERS = {
460
- name: "Name",
461
- category: "Category",
462
- rating: "Rating",
463
- reviews: "Reviews",
464
- priceLevel: "Price",
465
- address: "Address",
466
- phone: "Phone",
467
- website: "Website",
468
- email: "Email",
469
- facebook: "Facebook",
470
- instagram: "Instagram",
471
- whatsapp: "WhatsApp",
472
- plusCode: "Plus Code",
473
- latitude: "Latitude",
474
- longitude: "Longitude",
475
- hours: "Hours",
476
- mapsUrl: "Maps URL"
477
- };
478
- function toRows(leads, fields = ALL_FIELDS) {
479
- return leads.map((lead) => {
480
- const row = {};
481
- for (const f of fields) {
482
- const v = lead[f];
483
- row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
887
+ // src/batch.ts
888
+ async function searchLeadsBatch(queries, opts = {}) {
889
+ const {
890
+ total,
891
+ onProgress,
892
+ signal,
893
+ type: _t,
894
+ city: _c,
895
+ area: _a,
896
+ query: _q,
897
+ ...shared
898
+ } = opts;
899
+ void _t;
900
+ void _c;
901
+ void _a;
902
+ void _q;
903
+ let merged = [];
904
+ const seenDedupe = shared.dedupe ?? "website";
905
+ for (let i = 0; i < queries.length; i++) {
906
+ if (signal?.aborted) break;
907
+ const q = queries[i];
908
+ const label = (() => {
909
+ try {
910
+ return composeQuery(q);
911
+ } catch {
912
+ return q.query ?? q.type ?? "search";
913
+ }
914
+ })();
915
+ onProgress?.(`[${i + 1}/${queries.length}] ${label}`);
916
+ const subOpts = {
917
+ ...shared,
918
+ type: q.type ?? "",
919
+ // Defer sorting to the merged pass; keep per-search dedupe/filter local.
920
+ ...q.query ? { query: q.query } : {},
921
+ ...q.city ? { city: q.city } : {},
922
+ ...q.area ? { area: q.area } : {}
923
+ };
924
+ delete subOpts.sort;
925
+ if (signal) subOpts.signal = signal;
926
+ if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
927
+ try {
928
+ const found = await scrapeLeads(subOpts);
929
+ merged = merged.concat(found);
930
+ } catch (err) {
931
+ onProgress?.(` ! skipped "${label}": ${err.message}`);
484
932
  }
485
- return row;
486
- });
487
- }
488
- function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
489
- if (format === "json") {
490
- const picked = leads.map((lead) => {
491
- const o = {};
492
- for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
493
- return o;
494
- });
495
- return { data: JSON.stringify(picked, null, 2), binary: false };
496
933
  }
497
- const rows = toRows(leads, fields);
498
- if (format === "csv") {
499
- const csvRows = rows;
500
- return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
501
- }
502
- const columns = fields.map((f) => {
503
- const header = HEADERS[f];
504
- const widest = rows.reduce((m, r) => Math.max(m, String(r[header] ?? "").length), header.length);
505
- return { header, width: Math.min(60, Math.max(8, widest + 2)) };
506
- });
507
- return {
508
- data: jsonToXlsx(rows, { sheetName: opts.sheetName ?? "Leads", columns }),
509
- binary: true
510
- };
934
+ merged = dedupeLeads(merged, seenDedupe);
935
+ if (opts.sort) merged = sortLeads(merged, opts.sort, opts.sortDir);
936
+ if (total !== void 0 && total > 0) merged = merged.slice(0, total);
937
+ onProgress?.(`merged ${merged.length} unique lead${merged.length === 1 ? "" : "s"}.`);
938
+ return merged;
511
939
  }
512
940
 
513
941
  // src/convert.ts
@@ -585,6 +1013,8 @@ async function convertFile(input, opts = {}) {
585
1013
  }
586
1014
 
587
1015
  // src/cli.ts
1016
+ var SOCIAL_FIELDS = ENRICHED_FIELDS.filter((f) => f !== "email");
1017
+ var OUTPUT_FORMATS = ["json", "ndjson", "csv", "xlsx"];
588
1018
  var C = {
589
1019
  reset: "\x1B[0m",
590
1020
  bold: "\x1B[1m",
@@ -600,29 +1030,37 @@ var log = (s = "") => void stderr.write(s + "\n");
600
1030
  function parseArgs(list) {
601
1031
  const a = {
602
1032
  format: "json",
1033
+ append: false,
603
1034
  limit: 60,
604
1035
  headless: false,
605
1036
  details: true,
606
1037
  delay: 700,
607
1038
  emails: false,
608
1039
  socials: false,
1040
+ verifyEmails: false,
609
1041
  hasPhone: false,
610
1042
  hasWebsite: false,
611
1043
  hasEmail: false,
1044
+ hasValidEmail: false,
1045
+ cleanUrls: true,
1046
+ jitter: false,
612
1047
  yes: false,
613
1048
  help: false
614
1049
  };
615
1050
  for (let i = 0; i < list.length; i++) {
616
1051
  const arg = list[i];
617
1052
  const next = () => list[++i] ?? "";
618
- if (arg === "--city") a.city = next();
619
- else if (arg === "--area") a.area = next();
620
- else if (arg === "-t" || arg === "--type") a.type = next();
1053
+ if (arg === "--city" || arg === "--cities") a.city = next();
1054
+ else if (arg === "--area" || arg === "--areas") a.area = next();
1055
+ else if (arg === "-t" || arg === "--type" || arg === "--types") a.type = next();
621
1056
  else if (arg === "-q" || arg === "--query") a.query = next();
622
1057
  else if (arg === "--fields") a.fields = next();
1058
+ else if (arg === "--preset") a.preset = next();
623
1059
  else if (arg === "-f" || arg === "--format") a.format = next();
624
1060
  else if (arg === "-o" || arg === "--out") a.out = next();
1061
+ else if (arg === "--append") a.append = true;
625
1062
  else if (arg === "-n" || arg === "--limit") a.limit = parseInt(next(), 10) || a.limit;
1063
+ else if (arg === "--total") a.total = parseInt(next(), 10) || a.total;
626
1064
  else if (arg === "--headless") a.headless = true;
627
1065
  else if (arg === "--no-details") a.details = false;
628
1066
  else if (arg === "--delay") a.delay = parseInt(next(), 10) || a.delay;
@@ -631,14 +1069,32 @@ function parseArgs(list) {
631
1069
  else if (arg === "--enrich") {
632
1070
  a.emails = true;
633
1071
  a.socials = true;
634
- } 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());
635
1079
  else if (arg === "--min-reviews") a.minReviews = parseInt(next(), 10);
636
1080
  else if (arg === "--has-phone") a.hasPhone = true;
637
1081
  else if (arg === "--has-website") a.hasWebsite = true;
638
1082
  else if (arg === "--has-email") {
639
1083
  a.hasEmail = true;
640
1084
  a.emails = true;
1085
+ } else if (arg === "--has-valid-email") {
1086
+ a.hasValidEmail = true;
1087
+ a.verifyEmails = true;
1088
+ a.emails = true;
641
1089
  } else if (arg === "--dedupe") a.dedupe = next();
1090
+ else if (arg === "--sort") a.sort = next();
1091
+ else if (arg === "--desc") a.desc = true;
1092
+ else if (arg === "--asc") a.desc = false;
1093
+ else if (arg === "--country") a.country = next();
1094
+ else if (arg === "--lang" || arg === "--locale") a.locale = next();
1095
+ else if (arg === "--region" || arg === "--gl") a.region = next();
1096
+ else if (arg === "--concurrency") a.concurrency = parseInt(next(), 10) || a.concurrency;
1097
+ else if (arg === "--no-clean-urls") a.cleanUrls = false;
642
1098
  else if (arg === "--sheet") a.sheet = next();
643
1099
  else if (arg === "--max-time") a.maxTime = parseInt(next(), 10);
644
1100
  else if (arg === "-y" || arg === "--yes") a.yes = true;
@@ -656,35 +1112,57 @@ ${c("bold", "Usage")}
656
1112
  npx lacspace-leads convert <file> [-f json|csv|xlsx] [-o out]
657
1113
 
658
1114
  ${c("bold", "Search options")}
659
- -t, --type <text> Business type / keyword, e.g. "restaurants"
660
- --city <text> City, e.g. "Kathmandu"
661
- --area <text> Area / neighbourhood, e.g. "Baneshwor"
1115
+ -t, --type <text> Business type/keyword. Comma-separate for several,
1116
+ e.g. "restaurants,cafes"
1117
+ --city <text> City. Comma-separate for several.
1118
+ --area <text> Area/neighbourhood. Comma-separate to sweep a whole
1119
+ city, e.g. --area "Baneshwor,Thamel,Patan"
662
1120
  -q, --query <text> Raw query verbatim (overrides city/area/type)
663
1121
  --fields <list> Columns: ${ALL_FIELDS.join(",")}
664
- -n, --limit <n> Max listings to collect (default 60)
1122
+ --preset <name> Field bundle: ${Object.keys(FIELD_PRESETS).join(" | ")}
1123
+ -n, --limit <n> Max listings per search (default 60)
1124
+ --total <n> Cap the merged result (batch searches)
665
1125
  --no-details Names + Maps URLs only (fast, no per-listing open)
666
1126
 
667
1127
  ${c("bold", "Enrichment (visits each website)")}
668
1128
  --emails Also find an email from each website
669
- --socials Also find Facebook / Instagram / WhatsApp
1129
+ --socials Also find Facebook/Instagram/WhatsApp/LinkedIn/X/
1130
+ YouTube/TikTok/Telegram
670
1131
  --enrich Both of the above
1132
+ --verify-emails Check each email's domain has MX records (implies --emails)
1133
+ --concurrency <n> Websites to enrich in parallel (default 3)
1134
+
1135
+ ${c("bold", "Clean-up")}
1136
+ --country <c> Normalise phones to E.164 for this country
1137
+ (ISO-2 like NP/US, or a calling code like 977)
1138
+ --no-clean-urls Don't tidy website URLs (redirects/tracking params)
671
1139
 
672
- ${c("bold", "Filters")}
1140
+ ${c("bold", "Filters & order")}
673
1141
  --min-rating <n> Keep only ratings \u2265 n
674
1142
  --min-reviews <n> Keep only \u2265 n reviews
675
1143
  --has-phone Keep only leads with a phone
676
1144
  --has-website Keep only leads with a website
677
1145
  --has-email Keep only leads with an email (implies --emails)
678
- --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)
1149
+ --sort <key> rating | reviews | name | priceLevel
1150
+ --desc / --asc Sort direction
679
1151
 
680
1152
  ${c("bold", "Output")}
681
- -f, --format <fmt> json | csv | xlsx (default json)
682
- -o, --out <file> Output file (default: a slug + date)
1153
+ -f, --format <fmt> json | ndjson | csv | xlsx (default json)
1154
+ -o, --out <file> Output file, or "-" for stdout (default: slug + date)
1155
+ --append Merge into an existing output file (accumulate + dedupe)
683
1156
  --sheet <name> Excel sheet name (default "Leads")
684
1157
 
685
1158
  ${c("bold", "Runtime")}
686
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)
687
1162
  --max-time <s> Stop collecting after n seconds
1163
+ --proxy <url> Route the browser via a proxy (http://user:pass@host:port)
1164
+ --lang <locale> Browser locale, e.g. en-US, ne-NP (default en-US)
1165
+ --region <cc> Region bias for results, e.g. np, us
688
1166
  --headless Run the browser without a window
689
1167
  -y, --yes Skip prompts + the browser-open confirmation
690
1168
  -h, --help Show this help
@@ -692,7 +1170,11 @@ ${c("bold", "Runtime")}
692
1170
  ${c("bold", "Examples")}
693
1171
  npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
694
1172
  npx lacspace-leads "dental clinic" --city Pokhara --emails --has-email -f csv -n 40
695
- npx lacspace-leads gyms --city Lalitpur --min-rating 4 --no-details -n 100
1173
+ npx lacspace-leads gyms --city Lalitpur --min-rating 4 --sort reviews --desc
1174
+ npx lacspace-leads cafes --city Kathmandu --area "Thamel,Baneshwor,Patan" --country NP
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
696
1178
  npx lacspace-leads convert leads.json -f xlsx
697
1179
 
698
1180
  ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
@@ -767,29 +1249,62 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
767
1249
  exit(1);
768
1250
  return;
769
1251
  }
770
- const base = args.fields ? normalizeFields(args.fields) : [...DEFAULT_FIELDS];
1252
+ if (!OUTPUT_FORMATS.includes(args.format)) {
1253
+ log(c("red", `
1254
+ \u2717 Unknown format "${args.format}". Use: ${OUTPUT_FORMATS.join(", ")}.`));
1255
+ exit(1);
1256
+ return;
1257
+ }
1258
+ if (args.preset && !resolvePreset(args.preset)) {
1259
+ log(c("red", `
1260
+ \u2717 Unknown preset "${args.preset}". Use: ${Object.keys(FIELD_PRESETS).join(", ")}.`));
1261
+ exit(1);
1262
+ return;
1263
+ }
1264
+ const base = args.fields ? normalizeFields(args.fields) : resolvePreset(args.preset) ?? [...DEFAULT_FIELDS];
771
1265
  const wanted = new Set(base);
772
1266
  if (args.emails) wanted.add("email");
773
- if (args.socials) {
774
- wanted.add("facebook");
775
- wanted.add("instagram");
776
- wanted.add("whatsapp");
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");
777
1271
  }
778
1272
  const fields = ALL_FIELDS.filter((f) => wanted.has(f));
779
- const out = resolve2(args.out ?? defaultFilename(query, args.format));
1273
+ const toStdout = args.out === "-";
1274
+ const out = toStdout ? "-" : resolve2(args.out ?? defaultFilename(query, args.format));
780
1275
  const filters = {};
781
1276
  if (args.minRating !== void 0) filters.minRating = args.minRating;
782
1277
  if (args.minReviews !== void 0) filters.minReviews = args.minReviews;
783
1278
  if (args.hasPhone) filters.hasPhone = true;
784
1279
  if (args.hasWebsite) filters.hasWebsite = true;
785
1280
  if (args.hasEmail) filters.hasEmail = true;
1281
+ if (args.hasValidEmail) filters.hasValidEmail = true;
786
1282
  const hasFilters = Object.keys(filters).length > 0;
787
- log(`
1283
+ const queries = expandQueries({
1284
+ type: args.type ?? "",
1285
+ city: args.city ?? "",
1286
+ area: args.area ?? "",
1287
+ query: args.query ?? ""
1288
+ });
1289
+ const isBatch = queries.length > 1;
1290
+ if (isBatch) log(`
1291
+ ${c("dim", "searches")} ${c("bold", String(queries.length))} ${c("dim", "\u2192")} ${queries.map((q) => {
1292
+ try {
1293
+ return composeQuery(q);
1294
+ } catch {
1295
+ return "?";
1296
+ }
1297
+ }).join(" \xB7 ")}`);
1298
+ else log(`
788
1299
  ${c("dim", "search")} ${c("bold", query)}`);
789
1300
  log(` ${c("dim", "fields")} ${fields.join(", ")}`);
790
- if (args.emails || args.socials) log(` ${c("dim", "enrich")} ${[args.emails && "emails", args.socials && "socials"].filter(Boolean).join(" + ")} ${c("dim", "(visits each website)")}`);
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)`);
791
1303
  if (hasFilters) log(` ${c("dim", "filters")} ${Object.entries(filters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
792
- log(` ${c("dim", "limit")} ${args.limit} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${out}`);
1304
+ if (args.sort) log(` ${c("dim", "sort")} ${args.sort} ${args.desc === false ? "asc" : "desc"}`);
1305
+ if (args.country) log(` ${c("dim", "phones")} E.164 for ${args.country}`);
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)") : ""}`);
793
1308
  if (!args.yes) {
794
1309
  const ok = await prompt(`
795
1310
  ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c("dim", "[y/N]")} `);
@@ -814,16 +1329,33 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
814
1329
  headless: args.headless,
815
1330
  details: args.details,
816
1331
  delayMs: args.delay,
1332
+ cleanUrls: args.cleanUrls,
817
1333
  enrich: args.emails || args.socials,
1334
+ verifyEmails: args.verifyEmails,
1335
+ jitter: args.jitter,
818
1336
  signal: controller.signal,
819
1337
  onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
820
1338
  };
821
1339
  if (hasFilters) opts.filters = filters;
822
1340
  if (args.dedupe) opts.dedupe = args.dedupe;
1341
+ if (args.sort) opts.sort = args.sort;
1342
+ if (args.desc !== void 0) opts.sortDir = args.desc ? "desc" : "asc";
1343
+ if (args.country) opts.country = args.country;
1344
+ if (args.locale) opts.locale = args.locale;
1345
+ if (args.region) opts.region = args.region;
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;
823
1349
  if (args.maxTime !== void 0) opts.maxMs = args.maxTime * 1e3;
824
1350
  let leads;
825
1351
  try {
826
- leads = await scrapeLeads(opts);
1352
+ if (isBatch) {
1353
+ const batchOpts = { ...opts };
1354
+ if (args.total !== void 0) batchOpts.total = args.total;
1355
+ leads = await searchLeadsBatch(queries, batchOpts);
1356
+ } else {
1357
+ leads = await scrapeLeads(opts);
1358
+ }
827
1359
  } catch (err) {
828
1360
  log(c("red", `
829
1361
  \u2717 ${err.message}`));
@@ -836,18 +1368,40 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
836
1368
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
837
1369
  return;
838
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
+ }
839
1384
  const serOpts = {};
840
1385
  if (args.sheet) serOpts.sheetName = args.sheet;
841
1386
  const { data, binary } = serialize(leads, args.format, fields, serOpts);
1387
+ if (toStdout) {
1388
+ stdout.write(binary ? Buffer.from(data) : data);
1389
+ if (!binary) stdout.write("\n");
1390
+ log(`
1391
+ ${c("green", "\u2714")} ${c("bold", String(leads.length))} leads written to stdout ${c("dim", `(${args.format})`)}
1392
+ `);
1393
+ return;
1394
+ }
842
1395
  writeFileSync(out, binary ? Buffer.from(data) : data);
843
- const withPhone = leads.filter((l) => l.phone).length;
844
- const withSite = leads.filter((l) => l.website).length;
845
- const withEmail = leads.filter((l) => l.email).length;
1396
+ const s = computeStats(leads);
846
1397
  log(`
847
- ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
848
- const stats = [`${withPhone} with a phone`, `${withSite} with a website`];
849
- if (args.emails) stats.push(`${withEmail} with an email`);
850
- 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 "))}
851
1405
  `);
852
1406
  }
853
1407
  main().catch((err) => {