lacspace-leads 0.2.0 → 0.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 +86 -14
- package/dist/cli.js +426 -49
- package/dist/lib.cjs +355 -26
- package/dist/lib.d.cts +140 -6
- package/dist/lib.d.ts +140 -6
- package/dist/lib.js +344 -26
- package/package.json +8 -4
package/dist/lib.js
CHANGED
|
@@ -15,13 +15,42 @@ var ALL_FIELDS = [
|
|
|
15
15
|
"facebook",
|
|
16
16
|
"instagram",
|
|
17
17
|
"whatsapp",
|
|
18
|
+
"linkedin",
|
|
19
|
+
"twitter",
|
|
20
|
+
"youtube",
|
|
21
|
+
"tiktok",
|
|
22
|
+
"telegram",
|
|
18
23
|
"plusCode",
|
|
19
24
|
"latitude",
|
|
20
25
|
"longitude",
|
|
21
26
|
"hours",
|
|
22
27
|
"mapsUrl"
|
|
23
28
|
];
|
|
24
|
-
var ENRICHED_FIELDS = [
|
|
29
|
+
var ENRICHED_FIELDS = [
|
|
30
|
+
"email",
|
|
31
|
+
"facebook",
|
|
32
|
+
"instagram",
|
|
33
|
+
"whatsapp",
|
|
34
|
+
"linkedin",
|
|
35
|
+
"twitter",
|
|
36
|
+
"youtube",
|
|
37
|
+
"tiktok",
|
|
38
|
+
"telegram"
|
|
39
|
+
];
|
|
40
|
+
var FIELD_PRESETS = {
|
|
41
|
+
/** Just enough to place a business: name, phone, website. */
|
|
42
|
+
minimal: ["name", "phone", "website"],
|
|
43
|
+
/** Cold-outreach columns: who, how to reach them, where. */
|
|
44
|
+
outreach: ["name", "phone", "email", "website", "address"],
|
|
45
|
+
/** Contact-book columns incl. socials (implies enrichment). */
|
|
46
|
+
contact: ["name", "phone", "email", "website", "facebook", "instagram", "whatsapp"],
|
|
47
|
+
/** Map/geo columns for plotting. */
|
|
48
|
+
geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
|
|
49
|
+
/** Everything Maps shows, no website enrichment. */
|
|
50
|
+
full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
|
|
51
|
+
/** Every field, including enriched ones. */
|
|
52
|
+
everything: [...ALL_FIELDS]
|
|
53
|
+
};
|
|
25
54
|
var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
|
|
26
55
|
|
|
27
56
|
// src/query.ts
|
|
@@ -32,8 +61,38 @@ function composeQuery(opts) {
|
|
|
32
61
|
if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
|
|
33
62
|
return where ? `${type} in ${where}` : type;
|
|
34
63
|
}
|
|
35
|
-
function mapsSearchUrl(query) {
|
|
36
|
-
|
|
64
|
+
function mapsSearchUrl(query, opts = {}) {
|
|
65
|
+
const hl = (opts.hl ?? "en").split("-")[0] || "en";
|
|
66
|
+
const params = new URLSearchParams({ hl });
|
|
67
|
+
if (opts.gl) params.set("gl", opts.gl.toLowerCase());
|
|
68
|
+
return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
|
|
69
|
+
}
|
|
70
|
+
function expandQueries(opts) {
|
|
71
|
+
if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
|
|
72
|
+
const split = (s) => {
|
|
73
|
+
const parts = (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
74
|
+
return parts.length ? parts : [void 0];
|
|
75
|
+
};
|
|
76
|
+
const types = split(opts.type);
|
|
77
|
+
const cities = split(opts.city);
|
|
78
|
+
const areas = split(opts.area);
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const type of types) {
|
|
81
|
+
for (const city of cities) {
|
|
82
|
+
for (const area of areas) {
|
|
83
|
+
const entry = {};
|
|
84
|
+
if (type) entry.type = type;
|
|
85
|
+
if (city) entry.city = city;
|
|
86
|
+
if (area) entry.area = area;
|
|
87
|
+
out.push(entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function resolvePreset(name) {
|
|
94
|
+
if (!name) return void 0;
|
|
95
|
+
return FIELD_PRESETS[name.trim().toLowerCase()];
|
|
37
96
|
}
|
|
38
97
|
function normalizeFields(input) {
|
|
39
98
|
if (!input) return [...ALL_FIELDS];
|
|
@@ -74,12 +133,23 @@ function extractSocials(html) {
|
|
|
74
133
|
const hrefs = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1] ?? "");
|
|
75
134
|
for (const href of hrefs) {
|
|
76
135
|
const u = href.trim();
|
|
77
|
-
if (!
|
|
136
|
+
if (!u) continue;
|
|
137
|
+
if (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?|dialog\//i.test(u)) {
|
|
78
138
|
out.facebook = absolutize(u);
|
|
79
|
-
} else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u)) {
|
|
139
|
+
} else if (!out.instagram && /(^|\/\/|\.)instagram\.com\//i.test(u) && !/\/(share|p)\//i.test(u)) {
|
|
80
140
|
out.instagram = absolutize(u);
|
|
81
141
|
} else if (!out.whatsapp && /(wa\.me\/|api\.whatsapp\.com\/|whatsapp:\/\/)/i.test(u)) {
|
|
82
142
|
out.whatsapp = u.startsWith("http") || u.startsWith("whatsapp") ? u : `https:${u}`;
|
|
143
|
+
} else if (!out.linkedin && /(^|\/\/|\.)linkedin\.com\/(company|in|school)\//i.test(u) && !/sharing|shareArticle/i.test(u)) {
|
|
144
|
+
out.linkedin = absolutize(u);
|
|
145
|
+
} else if (!out.twitter && /(^|\/\/|\.)(twitter|x)\.com\//i.test(u) && !/(intent|share|widgets|hashtag)\b/i.test(u)) {
|
|
146
|
+
out.twitter = absolutize(u);
|
|
147
|
+
} else if (!out.youtube && /(youtube\.com\/(channel|c|user|@)|youtu\.be\/)/i.test(u)) {
|
|
148
|
+
out.youtube = absolutize(u);
|
|
149
|
+
} else if (!out.tiktok && /(^|\/\/|\.)tiktok\.com\/@/i.test(u)) {
|
|
150
|
+
out.tiktok = absolutize(u);
|
|
151
|
+
} else if (!out.telegram && /(t\.me\/|telegram\.me\/)/i.test(u) && !/\/share\b/i.test(u)) {
|
|
152
|
+
out.telegram = absolutize(u);
|
|
83
153
|
}
|
|
84
154
|
}
|
|
85
155
|
return out;
|
|
@@ -130,11 +200,9 @@ async function enrichContacts(website, opts = {}) {
|
|
|
130
200
|
const email = extractEmails(page);
|
|
131
201
|
if (email) {
|
|
132
202
|
contacts.email = email;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!contacts
|
|
136
|
-
if (!contacts.instagram && s.instagram) contacts.instagram = s.instagram;
|
|
137
|
-
if (!contacts.whatsapp && s.whatsapp) contacts.whatsapp = s.whatsapp;
|
|
203
|
+
const s = extractSocials(page);
|
|
204
|
+
for (const k of Object.keys(s)) {
|
|
205
|
+
if (!contacts[k] && s[k]) contacts[k] = s[k];
|
|
138
206
|
}
|
|
139
207
|
break;
|
|
140
208
|
}
|
|
@@ -189,7 +257,148 @@ function dedupeLeads(leads, by = "website") {
|
|
|
189
257
|
return out;
|
|
190
258
|
}
|
|
191
259
|
|
|
260
|
+
// src/normalize.ts
|
|
261
|
+
var TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|gbraid$|wbraid$|mc_|_hs|ref$|ref_src$|source$|igshid$|yclid$)/i;
|
|
262
|
+
function cleanWebsite(url) {
|
|
263
|
+
if (!url) return void 0;
|
|
264
|
+
let raw = url.trim();
|
|
265
|
+
if (!raw) return void 0;
|
|
266
|
+
const g = raw.match(/[?&](?:q|url)=([^&]+)/i);
|
|
267
|
+
if (g && /google\.[a-z.]+\/url/i.test(raw)) {
|
|
268
|
+
try {
|
|
269
|
+
raw = decodeURIComponent(g[1]);
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
let u;
|
|
274
|
+
try {
|
|
275
|
+
u = new URL(raw.startsWith("http") ? raw : `https://${raw}`);
|
|
276
|
+
} catch {
|
|
277
|
+
return raw;
|
|
278
|
+
}
|
|
279
|
+
u.hash = "";
|
|
280
|
+
u.host = u.host.toLowerCase();
|
|
281
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
282
|
+
if (TRACKING_PARAMS.test(key)) u.searchParams.delete(key);
|
|
283
|
+
}
|
|
284
|
+
let href = u.toString();
|
|
285
|
+
if (u.pathname === "/" && !u.search) href = href.replace(/\/$/, "");
|
|
286
|
+
return href;
|
|
287
|
+
}
|
|
288
|
+
var CALLING_CODES = {
|
|
289
|
+
NP: "977",
|
|
290
|
+
IN: "91",
|
|
291
|
+
US: "1",
|
|
292
|
+
CA: "1",
|
|
293
|
+
GB: "44",
|
|
294
|
+
AU: "61",
|
|
295
|
+
NZ: "64",
|
|
296
|
+
SG: "65",
|
|
297
|
+
MY: "60",
|
|
298
|
+
ID: "62",
|
|
299
|
+
TH: "66",
|
|
300
|
+
PH: "63",
|
|
301
|
+
VN: "84",
|
|
302
|
+
BD: "880",
|
|
303
|
+
PK: "92",
|
|
304
|
+
LK: "94",
|
|
305
|
+
AE: "971",
|
|
306
|
+
SA: "966",
|
|
307
|
+
QA: "974",
|
|
308
|
+
KW: "965",
|
|
309
|
+
BH: "973",
|
|
310
|
+
OM: "968",
|
|
311
|
+
CN: "86",
|
|
312
|
+
HK: "852",
|
|
313
|
+
JP: "81",
|
|
314
|
+
KR: "82",
|
|
315
|
+
DE: "49",
|
|
316
|
+
FR: "33",
|
|
317
|
+
ES: "34",
|
|
318
|
+
IT: "39",
|
|
319
|
+
NL: "31",
|
|
320
|
+
BE: "32",
|
|
321
|
+
CH: "41",
|
|
322
|
+
AT: "43",
|
|
323
|
+
SE: "46",
|
|
324
|
+
NO: "47",
|
|
325
|
+
DK: "45",
|
|
326
|
+
FI: "358",
|
|
327
|
+
IE: "353",
|
|
328
|
+
PT: "351",
|
|
329
|
+
PL: "48",
|
|
330
|
+
ZA: "27",
|
|
331
|
+
NG: "234",
|
|
332
|
+
KE: "254",
|
|
333
|
+
EG: "20",
|
|
334
|
+
BR: "55",
|
|
335
|
+
MX: "52",
|
|
336
|
+
AR: "54",
|
|
337
|
+
TR: "90"
|
|
338
|
+
};
|
|
339
|
+
function callingCode(country) {
|
|
340
|
+
if (!country) return void 0;
|
|
341
|
+
const c = country.trim().replace(/^\+/, "");
|
|
342
|
+
if (/^\d{1,4}$/.test(c)) return c;
|
|
343
|
+
return CALLING_CODES[c.toUpperCase()];
|
|
344
|
+
}
|
|
345
|
+
function normalizePhone(raw, country) {
|
|
346
|
+
if (!raw) return void 0;
|
|
347
|
+
const trimmed = raw.trim();
|
|
348
|
+
if (!trimmed) return void 0;
|
|
349
|
+
const isIntl = /^\+/.test(trimmed) || /^00\d/.test(trimmed);
|
|
350
|
+
let digits = trimmed.replace(/[^0-9]/g, "");
|
|
351
|
+
if (/^00\d/.test(trimmed)) digits = digits.replace(/^00/, "");
|
|
352
|
+
const cc = callingCode(country);
|
|
353
|
+
if (isIntl) {
|
|
354
|
+
return plausible(digits) ? `+${digits}` : trimmed;
|
|
355
|
+
}
|
|
356
|
+
if (!cc) return trimmed;
|
|
357
|
+
if (digits.startsWith(cc)) return plausible(digits) ? `+${digits}` : trimmed;
|
|
358
|
+
const local = digits.replace(/^0+/, "");
|
|
359
|
+
const e164 = `${cc}${local}`;
|
|
360
|
+
return plausible(e164) ? `+${e164}` : trimmed;
|
|
361
|
+
}
|
|
362
|
+
function plausible(digits) {
|
|
363
|
+
return digits.length >= 8 && digits.length <= 15;
|
|
364
|
+
}
|
|
365
|
+
var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
|
|
366
|
+
function sortLeads(leads, by, dir) {
|
|
367
|
+
if (!by) return [...leads];
|
|
368
|
+
const desc = dir ? dir === "desc" : by !== "name";
|
|
369
|
+
const val = (l) => {
|
|
370
|
+
if (by === "name") return l.name?.toLowerCase();
|
|
371
|
+
if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
|
|
372
|
+
return l[by];
|
|
373
|
+
};
|
|
374
|
+
return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
|
|
375
|
+
const av = val(a.lead);
|
|
376
|
+
const bv = val(b.lead);
|
|
377
|
+
const aMissing = av === void 0 || av === "";
|
|
378
|
+
const bMissing = bv === void 0 || bv === "";
|
|
379
|
+
if (aMissing && bMissing) return a.i - b.i;
|
|
380
|
+
if (aMissing) return 1;
|
|
381
|
+
if (bMissing) return -1;
|
|
382
|
+
let cmp = 0;
|
|
383
|
+
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
|
|
384
|
+
else cmp = String(av).localeCompare(String(bv));
|
|
385
|
+
if (desc) cmp = -cmp;
|
|
386
|
+
return cmp !== 0 ? cmp : a.i - b.i;
|
|
387
|
+
}).map((x) => x.lead);
|
|
388
|
+
}
|
|
389
|
+
|
|
192
390
|
// src/scrape.ts
|
|
391
|
+
async function pool(items, n, fn, signal) {
|
|
392
|
+
let next = 0;
|
|
393
|
+
const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
|
|
394
|
+
for (; ; ) {
|
|
395
|
+
const i = next++;
|
|
396
|
+
if (i >= items.length || signal?.aborted) return;
|
|
397
|
+
await fn(items[i], i);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
await Promise.all(workers);
|
|
401
|
+
}
|
|
193
402
|
var LeadsError = class extends Error {
|
|
194
403
|
code;
|
|
195
404
|
cause;
|
|
@@ -314,7 +523,13 @@ async function extractDetail(page, fields, fallbackName) {
|
|
|
314
523
|
}
|
|
315
524
|
if (fields.has("reviews")) {
|
|
316
525
|
const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
|
|
317
|
-
|
|
526
|
+
let count = parseReviewCount(rv);
|
|
527
|
+
if (count === void 0) {
|
|
528
|
+
const boxText = await box.innerText().catch(() => "");
|
|
529
|
+
const m = boxText.match(/\(([\d.,\s]+)\)/);
|
|
530
|
+
if (m) count = parseReviewCount(m[1]);
|
|
531
|
+
}
|
|
532
|
+
lead.reviews = count;
|
|
318
533
|
}
|
|
319
534
|
}
|
|
320
535
|
}
|
|
@@ -366,12 +581,15 @@ async function scrapeLeads(opts) {
|
|
|
366
581
|
onProgress?.(`searching Google Maps for "${query}"\u2026`);
|
|
367
582
|
const browser = await launchBrowser(headless);
|
|
368
583
|
try {
|
|
584
|
+
const locale = opts.locale ?? "en-US";
|
|
369
585
|
const context = await browser.newContext({
|
|
370
586
|
viewport: { width: 1280, height: 900 },
|
|
371
|
-
locale
|
|
587
|
+
locale
|
|
372
588
|
});
|
|
373
589
|
const page = await context.newPage();
|
|
374
|
-
|
|
590
|
+
const urlOpts = { hl: locale };
|
|
591
|
+
if (opts.region) urlOpts.gl = opts.region;
|
|
592
|
+
await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
375
593
|
await dismissConsent(page);
|
|
376
594
|
await loadResults(page, limit, delayMs, onProgress, signal);
|
|
377
595
|
if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
|
|
@@ -421,22 +639,42 @@ async function scrapeLeads(opts) {
|
|
|
421
639
|
}
|
|
422
640
|
}
|
|
423
641
|
onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
|
|
642
|
+
if (opts.cleanUrls ?? true) {
|
|
643
|
+
for (const lead of leads) {
|
|
644
|
+
if (lead.website) lead.website = cleanWebsite(lead.website);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
424
647
|
leads = dedupeLeads(leads, opts.dedupe ?? "website");
|
|
425
648
|
if (wantEnrich) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
649
|
+
const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
|
|
650
|
+
let done = 0;
|
|
651
|
+
await pool(
|
|
652
|
+
leads,
|
|
653
|
+
Math.max(1, Math.trunc(opts.concurrency ?? 3)),
|
|
654
|
+
async (lead) => {
|
|
655
|
+
if (overBudget()) return;
|
|
656
|
+
const site = lead.website;
|
|
657
|
+
if (site) {
|
|
658
|
+
onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
|
|
659
|
+
const c = await enrichContacts(site);
|
|
660
|
+
if (fields.has("email") && c.email) lead.email = c.email;
|
|
661
|
+
for (const f of socialFields) {
|
|
662
|
+
const v = c[f];
|
|
663
|
+
if (fields.has(f) && v) lead[f] = v;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (!fields.has("website")) delete lead.website;
|
|
667
|
+
},
|
|
668
|
+
signal
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (opts.country && fields.has("phone")) {
|
|
672
|
+
for (const lead of leads) {
|
|
673
|
+
if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
|
|
437
674
|
}
|
|
438
675
|
}
|
|
439
676
|
if (opts.filters) leads = filterLeads(leads, opts.filters);
|
|
677
|
+
if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
|
|
440
678
|
onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
|
|
441
679
|
return leads;
|
|
442
680
|
} finally {
|
|
@@ -445,6 +683,69 @@ async function scrapeLeads(opts) {
|
|
|
445
683
|
}
|
|
446
684
|
}
|
|
447
685
|
|
|
686
|
+
// src/batch.ts
|
|
687
|
+
async function searchLeadsBatch(queries, opts = {}) {
|
|
688
|
+
const {
|
|
689
|
+
total,
|
|
690
|
+
onProgress,
|
|
691
|
+
signal,
|
|
692
|
+
type: _t,
|
|
693
|
+
city: _c,
|
|
694
|
+
area: _a,
|
|
695
|
+
query: _q,
|
|
696
|
+
...shared
|
|
697
|
+
} = opts;
|
|
698
|
+
void _t;
|
|
699
|
+
void _c;
|
|
700
|
+
void _a;
|
|
701
|
+
void _q;
|
|
702
|
+
let merged = [];
|
|
703
|
+
const seenDedupe = shared.dedupe ?? "website";
|
|
704
|
+
for (let i = 0; i < queries.length; i++) {
|
|
705
|
+
if (signal?.aborted) break;
|
|
706
|
+
const q = queries[i];
|
|
707
|
+
const label = (() => {
|
|
708
|
+
try {
|
|
709
|
+
return composeQuery(q);
|
|
710
|
+
} catch {
|
|
711
|
+
return q.query ?? q.type ?? "search";
|
|
712
|
+
}
|
|
713
|
+
})();
|
|
714
|
+
onProgress?.(`[${i + 1}/${queries.length}] ${label}`);
|
|
715
|
+
const subOpts = {
|
|
716
|
+
...shared,
|
|
717
|
+
type: q.type ?? "",
|
|
718
|
+
// Defer sorting to the merged pass; keep per-search dedupe/filter local.
|
|
719
|
+
...q.query ? { query: q.query } : {},
|
|
720
|
+
...q.city ? { city: q.city } : {},
|
|
721
|
+
...q.area ? { area: q.area } : {}
|
|
722
|
+
};
|
|
723
|
+
delete subOpts.sort;
|
|
724
|
+
if (signal) subOpts.signal = signal;
|
|
725
|
+
if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
|
|
726
|
+
try {
|
|
727
|
+
const found = await scrapeLeads(subOpts);
|
|
728
|
+
merged = merged.concat(found);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
onProgress?.(` ! skipped "${label}": ${err.message}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
merged = dedupeLeads(merged, seenDedupe);
|
|
734
|
+
if (opts.sort) merged = sortLeads(merged, opts.sort, opts.sortDir);
|
|
735
|
+
if (total !== void 0 && total > 0) merged = merged.slice(0, total);
|
|
736
|
+
onProgress?.(`merged ${merged.length} unique lead${merged.length === 1 ? "" : "s"}.`);
|
|
737
|
+
return merged;
|
|
738
|
+
}
|
|
739
|
+
async function searchLeadsMulti(opts) {
|
|
740
|
+
const queries = expandQueries({
|
|
741
|
+
...opts.type ? { type: opts.type } : {},
|
|
742
|
+
...opts.city ? { city: opts.city } : {},
|
|
743
|
+
...opts.area ? { area: opts.area } : {},
|
|
744
|
+
...opts.query ? { query: opts.query } : {}
|
|
745
|
+
});
|
|
746
|
+
return searchLeadsBatch(queries, opts);
|
|
747
|
+
}
|
|
748
|
+
|
|
448
749
|
// src/export.ts
|
|
449
750
|
import { stringify as csvStringify } from "@lacspace/csv";
|
|
450
751
|
import { jsonToXlsx } from "@lacspace/xlsx";
|
|
@@ -461,6 +762,11 @@ var HEADERS = {
|
|
|
461
762
|
facebook: "Facebook",
|
|
462
763
|
instagram: "Instagram",
|
|
463
764
|
whatsapp: "WhatsApp",
|
|
765
|
+
linkedin: "LinkedIn",
|
|
766
|
+
twitter: "Twitter/X",
|
|
767
|
+
youtube: "YouTube",
|
|
768
|
+
tiktok: "TikTok",
|
|
769
|
+
telegram: "Telegram",
|
|
464
770
|
plusCode: "Plus Code",
|
|
465
771
|
latitude: "Latitude",
|
|
466
772
|
longitude: "Longitude",
|
|
@@ -478,13 +784,14 @@ function toRows(leads, fields = ALL_FIELDS) {
|
|
|
478
784
|
});
|
|
479
785
|
}
|
|
480
786
|
function serialize(leads, format, fields = ALL_FIELDS, opts = {}) {
|
|
481
|
-
if (format === "json") {
|
|
787
|
+
if (format === "json" || format === "ndjson") {
|
|
482
788
|
const picked = leads.map((lead) => {
|
|
483
789
|
const o = {};
|
|
484
790
|
for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
|
|
485
791
|
return o;
|
|
486
792
|
});
|
|
487
|
-
|
|
793
|
+
const data = format === "ndjson" ? picked.map((o) => JSON.stringify(o)).join("\n") + (picked.length ? "\n" : "") : JSON.stringify(picked, null, 2);
|
|
794
|
+
return { data, binary: false };
|
|
488
795
|
}
|
|
489
796
|
const rows = toRows(leads, fields);
|
|
490
797
|
if (format === "csv") {
|
|
@@ -577,8 +884,13 @@ async function convertFile(input, opts = {}) {
|
|
|
577
884
|
}
|
|
578
885
|
export {
|
|
579
886
|
ALL_FIELDS,
|
|
887
|
+
CALLING_CODES,
|
|
888
|
+
DEFAULT_FIELDS,
|
|
580
889
|
ENRICHED_FIELDS,
|
|
890
|
+
FIELD_PRESETS,
|
|
581
891
|
LeadsError,
|
|
892
|
+
callingCode,
|
|
893
|
+
cleanWebsite,
|
|
582
894
|
columnsOf,
|
|
583
895
|
composeQuery,
|
|
584
896
|
convertFile,
|
|
@@ -586,18 +898,24 @@ export {
|
|
|
586
898
|
defaultFilename,
|
|
587
899
|
detectFormat,
|
|
588
900
|
enrichContacts,
|
|
901
|
+
expandQueries,
|
|
589
902
|
extractEmails,
|
|
590
903
|
extractSocials,
|
|
591
904
|
filterLeads,
|
|
592
905
|
mapsSearchUrl,
|
|
593
906
|
normalizeFields,
|
|
907
|
+
normalizePhone,
|
|
594
908
|
parseLatLng,
|
|
595
909
|
parseRating,
|
|
596
910
|
parseReviewCount,
|
|
597
911
|
readRows,
|
|
912
|
+
resolvePreset,
|
|
598
913
|
scrapeLeads,
|
|
599
914
|
scrapeLeads as searchLeads,
|
|
915
|
+
searchLeadsBatch,
|
|
916
|
+
searchLeadsMulti,
|
|
600
917
|
serialize,
|
|
601
918
|
serializeRows,
|
|
919
|
+
sortLeads,
|
|
602
920
|
toRows
|
|
603
921
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lacspace-leads",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Free, open-source local-business lead finder — name a city, area and business type, and it drives a real browser over Google Maps to collect names, phones, websites, ratings and
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Free, open-source local-business lead finder — name a city, area and business type, and it drives a real browser over Google Maps to collect names, phones, websites, ratings, addresses, emails and social links, then exports to JSON, NDJSON, CSV or Excel. Sweep multiple areas at once, normalise phones to E.164, and enrich in parallel. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"lacspace-leads": "dist/cli.js"
|
|
@@ -35,11 +35,14 @@
|
|
|
35
35
|
"local-business",
|
|
36
36
|
"prospecting",
|
|
37
37
|
"b2b",
|
|
38
|
+
"email-finder",
|
|
38
39
|
"csv",
|
|
39
40
|
"excel",
|
|
41
|
+
"ndjson",
|
|
40
42
|
"playwright",
|
|
41
43
|
"cli",
|
|
42
44
|
"osint",
|
|
45
|
+
"e164",
|
|
43
46
|
"nepal",
|
|
44
47
|
"typescript"
|
|
45
48
|
],
|
|
@@ -48,10 +51,11 @@
|
|
|
48
51
|
"homepage": "https://developer.lacspace.com",
|
|
49
52
|
"repository": {
|
|
50
53
|
"type": "git",
|
|
51
|
-
"url": "git+https://github.com/lacspace/
|
|
54
|
+
"url": "git+https://github.com/lacspace/npm-packages.git",
|
|
55
|
+
"directory": "lacspace-leads"
|
|
52
56
|
},
|
|
53
57
|
"bugs": {
|
|
54
|
-
"url": "https://github.com/lacspace/
|
|
58
|
+
"url": "https://github.com/lacspace/npm-packages/issues"
|
|
55
59
|
},
|
|
56
60
|
"engines": {
|
|
57
61
|
"node": ">=20"
|