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/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 = ["email", "facebook", "instagram", "whatsapp"];
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
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
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 (!out.facebook && /(^|\/\/|\.)facebook\.com\//i.test(u) && !/sharer|plugins|\/tr\?/i.test(u)) {
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
- if (!contacts.facebook || !contacts.instagram || !contacts.whatsapp) {
134
- const s = extractSocials(page);
135
- if (!contacts.facebook && s.facebook) contacts.facebook = s.facebook;
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
- lead.reviews = parseReviewCount(rv);
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: "en-US"
587
+ locale
372
588
  });
373
589
  const page = await context.newPage();
374
- await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
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
- for (let i = 0; i < leads.length; i++) {
427
- if (signal?.aborted || overBudget()) break;
428
- const site = leads[i].website;
429
- if (!site) continue;
430
- onProgress?.(`enriching ${i + 1}/${leads.length}: ${leads[i].name ?? site}\u2026`);
431
- const c = await enrichContacts(site);
432
- if (fields.has("email") && c.email) leads[i].email = c.email;
433
- if (fields.has("facebook") && c.facebook) leads[i].facebook = c.facebook;
434
- if (fields.has("instagram") && c.instagram) leads[i].instagram = c.instagram;
435
- if (fields.has("whatsapp") && c.whatsapp) leads[i].whatsapp = c.whatsapp;
436
- if (!fields.has("website")) delete leads[i].website;
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
- return { data: JSON.stringify(picked, null, 2), binary: false };
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.2.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 addresses, then exports to JSON, CSV or Excel. No API keys.",
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/lacspace-leads.git"
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/lacspace-leads/issues"
58
+ "url": "https://github.com/lacspace/npm-packages/issues"
55
59
  },
56
60
  "engines": {
57
61
  "node": ">=20"