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/README.md +50 -8
- package/dist/cli.js +383 -100
- package/dist/lib.cjs +323 -91
- package/dist/lib.d.cts +134 -8
- package/dist/lib.d.ts +134 -8
- package/dist/lib.js +307 -86
- package/package.json +6 -2
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(
|
|
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
|
-
|
|
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
|
|
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({
|
|
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(
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
if (collect.has("name"))
|
|
642
|
-
if (collect.has("mapsUrl"))
|
|
643
|
-
|
|
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(
|
|
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 === "--
|
|
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
|
-
--
|
|
1001
|
-
--
|
|
1002
|
-
|
|
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", "
|
|
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
|
|
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
|
|
1225
|
-
if (args.emails)
|
|
1226
|
-
if (args.
|
|
1227
|
-
|
|
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) => {
|