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/README.md +115 -16
- package/dist/cli.js +680 -126
- package/dist/lib.cjs +577 -99
- package/dist/lib.d.cts +221 -8
- package/dist/lib.d.ts +221 -8
- package/dist/lib.js +554 -94
- package/package.json +7 -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";
|
|
@@ -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 = [
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
if (!contacts
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
757
|
+
locale
|
|
380
758
|
});
|
|
381
759
|
const page = await context.newPage();
|
|
382
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
if (collect.has("name"))
|
|
424
|
-
if (collect.has("mapsUrl"))
|
|
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 (
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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/
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
498
|
-
if (
|
|
499
|
-
|
|
500
|
-
|
|
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 === "--
|
|
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
|
|
660
|
-
|
|
661
|
-
--
|
|
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
|
-
|
|
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
|
|
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
|
-
--
|
|
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
|
|
682
|
-
-o, --out <file> Output file (default:
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
wanted.add("
|
|
776
|
-
wanted.add("
|
|
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
|
|
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
|
-
|
|
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",
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|
|
849
|
-
if (args.emails)
|
|
850
|
-
|
|
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) => {
|