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/README.md +33 -6
- package/dist/cli.js +271 -94
- package/dist/lib.cjs +236 -87
- package/dist/lib.d.cts +83 -4
- package/dist/lib.d.ts +83 -4
- package/dist/lib.js +224 -82
- package/package.json +4 -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,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({
|
|
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(
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
if (collect.has("name"))
|
|
642
|
-
if (collect.has("mapsUrl"))
|
|
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 (
|
|
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 === "--
|
|
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
|
-
--
|
|
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", "
|
|
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
|
|
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
|
|
1225
|
-
if (args.emails)
|
|
1226
|
-
if (args.
|
|
1227
|
-
|
|
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) => {
|