lacspace-leads 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ That opens a browser, searches Maps for *"restaurants in Baneshwor, Kathmandu"*,
12
12
 
13
13
  - **Free & keyless** — uses a real browser (via [Playwright](https://playwright.dev)), not a paid Places API.
14
14
  - **Sweep a whole city** — comma-separate areas and it runs each search, then **merges and de-duplicates** into one list: `--area "Thamel,Baneshwor,Patan"`.
15
+ - **Search by radius** — centre on a coordinate and keep only what's within range: `--near "27.72,85.32" --radius 2km`. Each lead gets a `distanceKm`, sorted nearest-first.
15
16
  - **Accumulate a master list** — `--append` merges each run into your existing file and de-duplicates, so daily runs build one clean database.
16
17
  - **Rich enrichment** — visit each website to pull an **email** and links for **Facebook, Instagram, WhatsApp, LinkedIn, X, YouTube, TikTok and Telegram** — a few sites in parallel.
17
18
  - **Verified emails** — `--verify-emails` checks each address's domain has **MX records** (no message sent) and tags it `valid`/`no-mx`; `--has-valid-email` keeps only deliverable ones.
@@ -42,7 +43,9 @@ npx lacspace-leads [type] [options]
42
43
  | `--city <text>` | City, e.g. `Kathmandu`. Comma-separate for several. |
43
44
  | `--area <text>` | Area / neighbourhood, e.g. `Baneshwor`. Comma-separate to **sweep a whole city**. |
44
45
  | `-q, --query <text>` | Raw query, used verbatim (overrides city/area/type) |
45
- | `--fields <list>` | Columns: `name,category,rating,reviews,priceLevel,address,phone,website,email,facebook,instagram,whatsapp,linkedin,twitter,youtube,tiktok,telegram,emailStatus,plusCode,latitude,longitude,hours,mapsUrl` |
46
+ | `--near <lat,lng>` | Centre the search on a coordinate (radius search) |
47
+ | `--radius <dist>` | Keep only leads within this of `--near`, e.g. `2km`, `500m`, `1mi` |
48
+ | `--fields <list>` | Columns: `name,category,rating,reviews,priceLevel,address,phone,website,email,facebook,instagram,whatsapp,linkedin,twitter,youtube,tiktok,telegram,emailStatus,plusCode,latitude,longitude,distanceKm,hours,mapsUrl` |
46
49
  | `--preset <name>` | Field bundle: `minimal` · `outreach` · `contact` · `geo` · `full` · `everything` |
47
50
  | `-f, --format <fmt>` | `json` · `ndjson` · `csv` · `xlsx` (default `json`) |
48
51
  | `-o, --out <file>` | Output file, or `-` for **stdout** (default: a slug + date) |
@@ -51,8 +54,8 @@ npx lacspace-leads [type] [options]
51
54
  | `-n, --limit <n>` | Max listings **per search** (default `60`) |
52
55
  | `--total <n>` | Cap the merged result when sweeping several searches |
53
56
  | `--no-details` | Skip opening each listing — names + Maps URLs only, much faster |
54
- | `--sort <key>` | `rating` · `reviews` · `name` · `priceLevel` (missing values last) |
55
- | `--desc` / `--asc` | Sort direction (default: `desc` for numbers, `asc` for name) |
57
+ | `--sort <key>` | `rating` · `reviews` · `name` · `priceLevel` · `distance` (missing values last) |
58
+ | `--desc` / `--asc` | Sort direction (default: `desc` for quality keys, `asc` for name/distance) |
56
59
  | `--delay <ms>` | Pause between listings (default `700`) |
57
60
  | `--jitter` | Randomise the delay ±40% (more human) |
58
61
  | `--retries <n>` | Retry a listing that fails to open (default `1`) |
@@ -108,6 +111,17 @@ npx lacspace-leads "coffee shop" \
108
111
  npx lacspace-leads --type "gym,fitness studio" --city Pokhara --total 100 -f csv
109
112
  ```
110
113
 
114
+ ## Search by radius
115
+
116
+ Centre the search on a coordinate and keep only what's within range — precise catchment-area targeting for delivery zones, field sales or store-radius research:
117
+
118
+ ```bash
119
+ # Restaurants within 2 km of a point, nearest first, with a distance column
120
+ npx lacspace-leads restaurants --near "27.7172,85.3240" --radius 2km -f csv
121
+ ```
122
+
123
+ Every kept lead gains a `distanceKm` column and results are sorted **nearest-first** by default (override with `--sort`). `--radius` accepts `km`, `m` or `mi` (a bare number is metres); omit it to just centre the search without a hard cutoff. Under the hood it points Google Maps at the coordinate, then filters by real great-circle (haversine) distance.
124
+
111
125
  ## Field presets
112
126
 
113
127
  Skip spelling out `--fields` with a ready-made bundle:
@@ -213,6 +227,7 @@ const leads = await searchLeadsBatch(
213
227
  | `toRows(leads, fields?)` | Header-keyed rows, for your own exporter. |
214
228
  | `enrichContacts(website)` / `extractEmails` / `extractSocials` | Website enrichment, on tap. |
215
229
  | `cleanWebsite` / `normalizePhone` / `sortLeads` | Pure data-cleaning helpers (unit-tested). |
230
+ | `haversineMeters` / `parseLatLngPair` / `parseDistance` | Pure geo helpers for radius search. |
216
231
  | `filterLeads` / `dedupeLeads` | Pure post-processing over any `Lead[]`. |
217
232
  | `expandQueries` / `resolvePreset` / `FIELD_PRESETS` | Batch expansion + field presets. |
218
233
  | `composeQuery` / `mapsSearchUrl` / `normalizeFields` / `defaultFilename` | Query + helper utilities. |
package/dist/cli.js CHANGED
@@ -32,9 +32,11 @@ var ALL_FIELDS = [
32
32
  "plusCode",
33
33
  "latitude",
34
34
  "longitude",
35
+ "distanceKm",
35
36
  "hours",
36
37
  "mapsUrl"
37
38
  ];
39
+ var DERIVED_FIELDS = ["distanceKm"];
38
40
  var ENRICHED_FIELDS = [
39
41
  "email",
40
42
  "facebook",
@@ -57,11 +59,13 @@ var FIELD_PRESETS = {
57
59
  /** Map/geo columns for plotting. */
58
60
  geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
59
61
  /** Everything Maps shows, no website enrichment. */
60
- full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
62
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)),
61
63
  /** Every field, including enriched ones. */
62
64
  everything: [...ALL_FIELDS]
63
65
  };
64
- var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
66
+ var DEFAULT_FIELDS = ALL_FIELDS.filter(
67
+ (f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)
68
+ );
65
69
 
66
70
  // src/query.ts
67
71
  function composeQuery(opts) {
@@ -75,7 +79,9 @@ function mapsSearchUrl(query, opts = {}) {
75
79
  const hl = (opts.hl ?? "en").split("-")[0] || "en";
76
80
  const params = new URLSearchParams({ hl });
77
81
  if (opts.gl) params.set("gl", opts.gl.toLowerCase());
78
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
82
+ const base = `https://www.google.com/maps/search/${encodeURIComponent(query)}`;
83
+ const at = opts.center ? `/@${opts.center.lat},${opts.center.lng},${Math.round(opts.center.zoom ?? 14)}z` : "";
84
+ return `${base}${at}?${params.toString()}`;
79
85
  }
80
86
  function expandQueries(opts) {
81
87
  if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
@@ -375,12 +381,14 @@ function plausible(digits) {
375
381
  return digits.length >= 8 && digits.length <= 15;
376
382
  }
377
383
  var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
384
+ var ASC_BY_DEFAULT = /* @__PURE__ */ new Set(["name", "distance"]);
378
385
  function sortLeads(leads, by, dir) {
379
386
  if (!by) return [...leads];
380
- const desc = dir ? dir === "desc" : by !== "name";
387
+ const desc = dir ? dir === "desc" : !ASC_BY_DEFAULT.has(by);
381
388
  const val = (l) => {
382
389
  if (by === "name") return l.name?.toLowerCase();
383
390
  if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
391
+ if (by === "distance") return l.distanceKm;
384
392
  return l[by];
385
393
  };
386
394
  return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
@@ -448,6 +456,43 @@ async function verifyEmails(leads, opts = {}) {
448
456
  return leads;
449
457
  }
450
458
 
459
+ // src/geo.ts
460
+ var R_EARTH_M = 63710088e-1;
461
+ var toRad = (deg) => deg * Math.PI / 180;
462
+ function haversineMeters(a, b) {
463
+ const dLat = toRad(b.lat - a.lat);
464
+ const dLng = toRad(b.lng - a.lng);
465
+ const lat1 = toRad(a.lat);
466
+ const lat2 = toRad(b.lat);
467
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
468
+ return 2 * R_EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
469
+ }
470
+ function parseLatLngPair(input) {
471
+ if (!input) return void 0;
472
+ const m = input.replace(/^@/, "").match(/(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)/);
473
+ if (!m) return void 0;
474
+ const lat = parseFloat(m[1]);
475
+ const lng = parseFloat(m[2]);
476
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void 0;
477
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return void 0;
478
+ return { lat, lng };
479
+ }
480
+ function parseDistance(input) {
481
+ if (!input) return void 0;
482
+ const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(km|m|mi|mile|miles|meters?|metres?)?$/);
483
+ if (!m) return void 0;
484
+ const value = parseFloat(m[1]);
485
+ if (!Number.isFinite(value) || value <= 0) return void 0;
486
+ const unit = m[2] ?? "m";
487
+ const metres = unit === "km" ? value * 1e3 : unit.startsWith("mi") ? value * 1609.344 : value;
488
+ return metres;
489
+ }
490
+ function zoomForRadius(radiusM, lat = 0) {
491
+ const mpp = 2 * Math.max(50, radiusM) / 600;
492
+ const zoom = Math.log2(156543.03392 * Math.cos(toRad(lat)) / mpp);
493
+ return Math.max(3, Math.min(19, Math.round(zoom)));
494
+ }
495
+
451
496
  // src/export.ts
452
497
  import { stringify as csvStringify } from "@lacspace/csv";
453
498
  import { jsonToXlsx } from "@lacspace/xlsx";
@@ -473,6 +518,7 @@ var HEADERS = {
473
518
  plusCode: "Plus Code",
474
519
  latitude: "Latitude",
475
520
  longitude: "Longitude",
521
+ distanceKm: "Distance (km)",
476
522
  hours: "Hours",
477
523
  mapsUrl: "Maps URL"
478
524
  };
@@ -533,7 +579,7 @@ var FIELD_BY_KEY = (() => {
533
579
  }
534
580
  return m;
535
581
  })();
536
- var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
582
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude", "distanceKm"]);
537
583
  function rowsToLeads(rows) {
538
584
  return rows.map((row) => {
539
585
  const lead = {};
@@ -744,8 +790,13 @@ async function scrapeLeads(opts) {
744
790
  const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
745
791
  const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
746
792
  const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
793
+ const near = opts.near;
747
794
  const collect = new Set(fields);
748
795
  if (wantEnrich) collect.add("website");
796
+ if (near) {
797
+ collect.add("latitude");
798
+ collect.add("longitude");
799
+ }
749
800
  const detailOnly = ["name", "mapsUrl"];
750
801
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
751
802
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
@@ -759,6 +810,7 @@ async function scrapeLeads(opts) {
759
810
  const page = await context.newPage();
760
811
  const urlOpts = { hl: locale };
761
812
  if (opts.region) urlOpts.gl = opts.region;
813
+ if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? 2e3, near.lat) };
762
814
  await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
763
815
  await dismissConsent(page);
764
816
  await loadResults(page, limit, delayMs, onProgress, signal);
@@ -836,6 +888,19 @@ async function scrapeLeads(opts) {
836
888
  if (lead.website) lead.website = cleanWebsite(lead.website);
837
889
  }
838
890
  }
891
+ if (near) {
892
+ for (const lead of leads) {
893
+ if (typeof lead.latitude === "number" && typeof lead.longitude === "number") {
894
+ const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
895
+ lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
896
+ }
897
+ }
898
+ if (opts.radiusM !== void 0) {
899
+ const r = opts.radiusM;
900
+ leads = leads.filter((l) => l.distanceKm !== void 0 && l.distanceKm * 1e3 <= r);
901
+ onProgress?.(`within ${Math.round(r)} m: ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
902
+ }
903
+ }
839
904
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
840
905
  if (wantEnrich) {
841
906
  const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
@@ -876,6 +941,16 @@ async function scrapeLeads(opts) {
876
941
  if (opts.filters) leads = filterLeads(leads, opts.filters);
877
942
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
878
943
  if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
944
+ if (near) {
945
+ const keepLat = fields.has("latitude");
946
+ const keepLng = fields.has("longitude");
947
+ const keepDist = fields.has("distanceKm");
948
+ for (const l of leads) {
949
+ if (!keepLat) delete l.latitude;
950
+ if (!keepLng) delete l.longitude;
951
+ if (!keepDist) delete l.distanceKm;
952
+ }
953
+ }
879
954
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
880
955
  return leads;
881
956
  } finally {
@@ -1054,6 +1129,8 @@ function parseArgs(list) {
1054
1129
  else if (arg === "--area" || arg === "--areas") a.area = next();
1055
1130
  else if (arg === "-t" || arg === "--type" || arg === "--types") a.type = next();
1056
1131
  else if (arg === "-q" || arg === "--query") a.query = next();
1132
+ else if (arg === "--near") a.near = next();
1133
+ else if (arg === "--radius") a.radius = next();
1057
1134
  else if (arg === "--fields") a.fields = next();
1058
1135
  else if (arg === "--preset") a.preset = next();
1059
1136
  else if (arg === "-f" || arg === "--format") a.format = next();
@@ -1118,6 +1195,8 @@ ${c("bold", "Search options")}
1118
1195
  --area <text> Area/neighbourhood. Comma-separate to sweep a whole
1119
1196
  city, e.g. --area "Baneshwor,Thamel,Patan"
1120
1197
  -q, --query <text> Raw query verbatim (overrides city/area/type)
1198
+ --near <lat,lng> Centre the search on a coordinate (radius search)
1199
+ --radius <dist> Keep only leads within this of --near, e.g. 2km, 500m, 1mi
1121
1200
  --fields <list> Columns: ${ALL_FIELDS.join(",")}
1122
1201
  --preset <name> Field bundle: ${Object.keys(FIELD_PRESETS).join(" | ")}
1123
1202
  -n, --limit <n> Max listings per search (default 60)
@@ -1146,8 +1225,8 @@ ${c("bold", "Filters & order")}
1146
1225
  --has-valid-email Keep only leads with an MX-verified email (implies --verify-emails)
1147
1226
  --dedupe <key> website | phone | name | smart | none (default website;
1148
1227
  --append uses smart: website\u2192phone\u2192name)
1149
- --sort <key> rating | reviews | name | priceLevel
1150
- --desc / --asc Sort direction
1228
+ --sort <key> rating | reviews | name | priceLevel | distance
1229
+ --desc / --asc Sort direction (distance defaults nearest-first)
1151
1230
 
1152
1231
  ${c("bold", "Output")}
1153
1232
  -f, --format <fmt> json | ndjson | csv | xlsx (default json)
@@ -1175,6 +1254,7 @@ ${c("bold", "Examples")}
1175
1254
  npx lacspace-leads salons --city Pokhara --preset outreach --country NP -f csv -o -
1176
1255
  npx lacspace-leads dentists --city Pokhara --verify-emails --has-valid-email -f csv
1177
1256
  npx lacspace-leads cafes --city Kathmandu -o master.csv --append # accumulate daily
1257
+ npx lacspace-leads restaurants --near "27.7172,85.3240" --radius 2km -f csv
1178
1258
  npx lacspace-leads convert leads.json -f xlsx
1179
1259
 
1180
1260
  ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
@@ -1261,6 +1341,26 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1261
1341
  exit(1);
1262
1342
  return;
1263
1343
  }
1344
+ const nearPoint = args.near ? parseLatLngPair(args.near) : void 0;
1345
+ if (args.near && !nearPoint) {
1346
+ log(c("red", `
1347
+ \u2717 --near must be "lat,lng", e.g. --near "27.7172,85.3240".`));
1348
+ exit(1);
1349
+ return;
1350
+ }
1351
+ const radiusM = args.radius ? parseDistance(args.radius) : void 0;
1352
+ if (args.radius && radiusM === void 0) {
1353
+ log(c("red", `
1354
+ \u2717 --radius must be a distance like 2km, 500m or 1mi.`));
1355
+ exit(1);
1356
+ return;
1357
+ }
1358
+ if (args.radius && !nearPoint) {
1359
+ log(c("red", `
1360
+ \u2717 --radius needs a centre \u2014 add --near "lat,lng".`));
1361
+ exit(1);
1362
+ return;
1363
+ }
1264
1364
  const base = args.fields ? normalizeFields(args.fields) : resolvePreset(args.preset) ?? [...DEFAULT_FIELDS];
1265
1365
  const wanted = new Set(base);
1266
1366
  if (args.emails) wanted.add("email");
@@ -1269,6 +1369,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1269
1369
  wanted.add("email");
1270
1370
  wanted.add("emailStatus");
1271
1371
  }
1372
+ if (nearPoint) wanted.add("distanceKm");
1272
1373
  const fields = ALL_FIELDS.filter((f) => wanted.has(f));
1273
1374
  const toStdout = args.out === "-";
1274
1375
  const out = toStdout ? "-" : resolve2(args.out ?? defaultFilename(query, args.format));
@@ -1301,7 +1402,9 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1301
1402
  if (args.emails || args.socials) log(` ${c("dim", "enrich")} ${[args.emails && "emails", args.socials && "socials"].filter(Boolean).join(" + ")} ${c("dim", `(${args.concurrency ?? 3}\xD7 parallel, visits each website)`)}`);
1302
1403
  if (args.verifyEmails) log(` ${c("dim", "verify")} email domains (MX lookup)`);
1303
1404
  if (hasFilters) log(` ${c("dim", "filters")} ${Object.entries(filters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
1405
+ if (nearPoint) log(` ${c("dim", "near")} ${nearPoint.lat},${nearPoint.lng}${radiusM !== void 0 ? c("dim", ` (within ${args.radius})`) : c("dim", " (centred, no radius filter)")}`);
1304
1406
  if (args.sort) log(` ${c("dim", "sort")} ${args.sort} ${args.desc === false ? "asc" : "desc"}`);
1407
+ else if (nearPoint) log(` ${c("dim", "sort")} distance (nearest first)`);
1305
1408
  if (args.country) log(` ${c("dim", "phones")} E.164 for ${args.country}`);
1306
1409
  if (args.proxy) log(` ${c("dim", "proxy")} ${args.proxy.replace(/\/\/[^@]+@/, "//***@")}`);
1307
1410
  log(` ${c("dim", "limit")} ${args.limit}${isBatch ? "/search" : ""}${args.total ? ` (cap ${args.total})` : ""} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${toStdout ? "stdout" : out}${args.append ? c("dim", " (append)") : ""}`);
@@ -1338,7 +1441,10 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1338
1441
  };
1339
1442
  if (hasFilters) opts.filters = filters;
1340
1443
  if (args.dedupe) opts.dedupe = args.dedupe;
1444
+ if (nearPoint) opts.near = { lat: nearPoint.lat, lng: nearPoint.lng };
1445
+ if (radiusM !== void 0) opts.radiusM = radiusM;
1341
1446
  if (args.sort) opts.sort = args.sort;
1447
+ else if (nearPoint) opts.sort = "distance";
1342
1448
  if (args.desc !== void 0) opts.sortDir = args.desc ? "desc" : "asc";
1343
1449
  if (args.country) opts.country = args.country;
1344
1450
  if (args.locale) opts.locale = args.locale;
package/dist/lib.cjs CHANGED
@@ -42,10 +42,13 @@ __export(lib_exports, {
42
42
  extractEmails: () => extractEmails,
43
43
  extractSocials: () => extractSocials,
44
44
  filterLeads: () => filterLeads,
45
+ haversineMeters: () => haversineMeters,
45
46
  mapsSearchUrl: () => mapsSearchUrl,
46
47
  normalizeFields: () => normalizeFields,
47
48
  normalizePhone: () => normalizePhone,
49
+ parseDistance: () => parseDistance,
48
50
  parseLatLng: () => parseLatLng,
51
+ parseLatLngPair: () => parseLatLngPair,
49
52
  parseRating: () => parseRating,
50
53
  parseReviewCount: () => parseReviewCount,
51
54
  readRows: () => readRows,
@@ -61,7 +64,8 @@ __export(lib_exports, {
61
64
  sortLeads: () => sortLeads,
62
65
  toRows: () => toRows,
63
66
  verifyEmail: () => verifyEmail,
64
- verifyEmails: () => verifyEmails
67
+ verifyEmails: () => verifyEmails,
68
+ zoomForRadius: () => zoomForRadius
65
69
  });
66
70
  module.exports = __toCommonJS(lib_exports);
67
71
 
@@ -91,9 +95,11 @@ var ALL_FIELDS = [
91
95
  "plusCode",
92
96
  "latitude",
93
97
  "longitude",
98
+ "distanceKm",
94
99
  "hours",
95
100
  "mapsUrl"
96
101
  ];
102
+ var DERIVED_FIELDS = ["distanceKm"];
97
103
  var ENRICHED_FIELDS = [
98
104
  "email",
99
105
  "facebook",
@@ -116,11 +122,13 @@ var FIELD_PRESETS = {
116
122
  /** Map/geo columns for plotting. */
117
123
  geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
118
124
  /** Everything Maps shows, no website enrichment. */
119
- full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
125
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)),
120
126
  /** Every field, including enriched ones. */
121
127
  everything: [...ALL_FIELDS]
122
128
  };
123
- var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
129
+ var DEFAULT_FIELDS = ALL_FIELDS.filter(
130
+ (f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)
131
+ );
124
132
 
125
133
  // src/query.ts
126
134
  function composeQuery(opts) {
@@ -134,7 +142,9 @@ function mapsSearchUrl(query, opts = {}) {
134
142
  const hl = (opts.hl ?? "en").split("-")[0] || "en";
135
143
  const params = new URLSearchParams({ hl });
136
144
  if (opts.gl) params.set("gl", opts.gl.toLowerCase());
137
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
145
+ const base = `https://www.google.com/maps/search/${encodeURIComponent(query)}`;
146
+ const at = opts.center ? `/@${opts.center.lat},${opts.center.lng},${Math.round(opts.center.zoom ?? 14)}z` : "";
147
+ return `${base}${at}?${params.toString()}`;
138
148
  }
139
149
  function expandQueries(opts) {
140
150
  if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
@@ -434,12 +444,14 @@ function plausible(digits) {
434
444
  return digits.length >= 8 && digits.length <= 15;
435
445
  }
436
446
  var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
447
+ var ASC_BY_DEFAULT = /* @__PURE__ */ new Set(["name", "distance"]);
437
448
  function sortLeads(leads, by, dir) {
438
449
  if (!by) return [...leads];
439
- const desc = dir ? dir === "desc" : by !== "name";
450
+ const desc = dir ? dir === "desc" : !ASC_BY_DEFAULT.has(by);
440
451
  const val = (l) => {
441
452
  if (by === "name") return l.name?.toLowerCase();
442
453
  if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
454
+ if (by === "distance") return l.distanceKm;
443
455
  return l[by];
444
456
  };
445
457
  return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
@@ -507,6 +519,43 @@ async function verifyEmails(leads, opts = {}) {
507
519
  return leads;
508
520
  }
509
521
 
522
+ // src/geo.ts
523
+ var R_EARTH_M = 63710088e-1;
524
+ var toRad = (deg) => deg * Math.PI / 180;
525
+ function haversineMeters(a, b) {
526
+ const dLat = toRad(b.lat - a.lat);
527
+ const dLng = toRad(b.lng - a.lng);
528
+ const lat1 = toRad(a.lat);
529
+ const lat2 = toRad(b.lat);
530
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
531
+ return 2 * R_EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
532
+ }
533
+ function parseLatLngPair(input) {
534
+ if (!input) return void 0;
535
+ const m = input.replace(/^@/, "").match(/(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)/);
536
+ if (!m) return void 0;
537
+ const lat = parseFloat(m[1]);
538
+ const lng = parseFloat(m[2]);
539
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void 0;
540
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return void 0;
541
+ return { lat, lng };
542
+ }
543
+ function parseDistance(input) {
544
+ if (!input) return void 0;
545
+ const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(km|m|mi|mile|miles|meters?|metres?)?$/);
546
+ if (!m) return void 0;
547
+ const value = parseFloat(m[1]);
548
+ if (!Number.isFinite(value) || value <= 0) return void 0;
549
+ const unit = m[2] ?? "m";
550
+ const metres = unit === "km" ? value * 1e3 : unit.startsWith("mi") ? value * 1609.344 : value;
551
+ return metres;
552
+ }
553
+ function zoomForRadius(radiusM, lat = 0) {
554
+ const mpp = 2 * Math.max(50, radiusM) / 600;
555
+ const zoom = Math.log2(156543.03392 * Math.cos(toRad(lat)) / mpp);
556
+ return Math.max(3, Math.min(19, Math.round(zoom)));
557
+ }
558
+
510
559
  // src/export.ts
511
560
  var import_csv = require("@lacspace/csv");
512
561
  var import_xlsx = require("@lacspace/xlsx");
@@ -532,6 +581,7 @@ var HEADERS = {
532
581
  plusCode: "Plus Code",
533
582
  latitude: "Latitude",
534
583
  longitude: "Longitude",
584
+ distanceKm: "Distance (km)",
535
585
  hours: "Hours",
536
586
  mapsUrl: "Maps URL"
537
587
  };
@@ -592,7 +642,7 @@ var FIELD_BY_KEY = (() => {
592
642
  }
593
643
  return m;
594
644
  })();
595
- var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
645
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude", "distanceKm"]);
596
646
  function rowsToLeads(rows) {
597
647
  return rows.map((row) => {
598
648
  const lead = {};
@@ -803,8 +853,13 @@ async function scrapeLeads(opts) {
803
853
  const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
804
854
  const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
805
855
  const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
856
+ const near = opts.near;
806
857
  const collect = new Set(fields);
807
858
  if (wantEnrich) collect.add("website");
859
+ if (near) {
860
+ collect.add("latitude");
861
+ collect.add("longitude");
862
+ }
808
863
  const detailOnly = ["name", "mapsUrl"];
809
864
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
810
865
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
@@ -818,6 +873,7 @@ async function scrapeLeads(opts) {
818
873
  const page = await context.newPage();
819
874
  const urlOpts = { hl: locale };
820
875
  if (opts.region) urlOpts.gl = opts.region;
876
+ if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? 2e3, near.lat) };
821
877
  await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
822
878
  await dismissConsent(page);
823
879
  await loadResults(page, limit, delayMs, onProgress, signal);
@@ -895,6 +951,19 @@ async function scrapeLeads(opts) {
895
951
  if (lead.website) lead.website = cleanWebsite(lead.website);
896
952
  }
897
953
  }
954
+ if (near) {
955
+ for (const lead of leads) {
956
+ if (typeof lead.latitude === "number" && typeof lead.longitude === "number") {
957
+ const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
958
+ lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
959
+ }
960
+ }
961
+ if (opts.radiusM !== void 0) {
962
+ const r = opts.radiusM;
963
+ leads = leads.filter((l) => l.distanceKm !== void 0 && l.distanceKm * 1e3 <= r);
964
+ onProgress?.(`within ${Math.round(r)} m: ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
965
+ }
966
+ }
898
967
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
899
968
  if (wantEnrich) {
900
969
  const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
@@ -935,6 +1004,16 @@ async function scrapeLeads(opts) {
935
1004
  if (opts.filters) leads = filterLeads(leads, opts.filters);
936
1005
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
937
1006
  if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
1007
+ if (near) {
1008
+ const keepLat = fields.has("latitude");
1009
+ const keepLng = fields.has("longitude");
1010
+ const keepDist = fields.has("distanceKm");
1011
+ for (const l of leads) {
1012
+ if (!keepLat) delete l.latitude;
1013
+ if (!keepLng) delete l.longitude;
1014
+ if (!keepDist) delete l.distanceKm;
1015
+ }
1016
+ }
938
1017
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
939
1018
  return leads;
940
1019
  } finally {
@@ -1108,10 +1187,13 @@ async function convertFile(input, opts = {}) {
1108
1187
  extractEmails,
1109
1188
  extractSocials,
1110
1189
  filterLeads,
1190
+ haversineMeters,
1111
1191
  mapsSearchUrl,
1112
1192
  normalizeFields,
1113
1193
  normalizePhone,
1194
+ parseDistance,
1114
1195
  parseLatLng,
1196
+ parseLatLngPair,
1115
1197
  parseRating,
1116
1198
  parseReviewCount,
1117
1199
  readRows,
@@ -1127,5 +1209,6 @@ async function convertFile(input, opts = {}) {
1127
1209
  sortLeads,
1128
1210
  toRows,
1129
1211
  verifyEmail,
1130
- verifyEmails
1212
+ verifyEmails,
1213
+ zoomForRadius
1131
1214
  });
package/dist/lib.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The fields a {@link Lead} can carry — request any subset via `fields`. */
2
- type LeadField = "name" | "category" | "rating" | "reviews" | "priceLevel" | "address" | "phone" | "website" | "email" | "facebook" | "instagram" | "whatsapp" | "linkedin" | "twitter" | "youtube" | "tiktok" | "telegram" | "emailStatus" | "plusCode" | "latitude" | "longitude" | "hours" | "mapsUrl";
2
+ type LeadField = "name" | "category" | "rating" | "reviews" | "priceLevel" | "address" | "phone" | "website" | "email" | "facebook" | "instagram" | "whatsapp" | "linkedin" | "twitter" | "youtube" | "tiktok" | "telegram" | "emailStatus" | "plusCode" | "latitude" | "longitude" | "distanceKm" | "hours" | "mapsUrl";
3
3
  /** Deliverability verdict for a discovered email (see `verifyEmails`). */
4
4
  type EmailStatus = "valid" | "no-mx" | "invalid-format" | "unknown";
5
5
  /** Every field, in a sensible column order for exports. */
@@ -53,6 +53,8 @@ interface Lead {
53
53
  latitude?: number;
54
54
  /** Longitude, parsed from the listing's Maps URL. */
55
55
  longitude?: number;
56
+ /** Distance in km from the `near` point (set only in radius search). */
57
+ distanceKm?: number;
56
58
  /** Opening-hours summary, when shown. */
57
59
  hours?: string;
58
60
  /** Canonical Google Maps URL for the listing. */
@@ -76,7 +78,7 @@ interface LeadFilters {
76
78
  /** Output formats the tool can write. */
77
79
  type OutputFormat = "json" | "ndjson" | "csv" | "xlsx";
78
80
  /** How to sort collected leads before export. */
79
- type SortKey$1 = "rating" | "reviews" | "name" | "priceLevel";
81
+ type SortKey$1 = "rating" | "reviews" | "name" | "priceLevel" | "distance";
80
82
  /** Options for a lead search. */
81
83
  interface SearchOptions {
82
84
  /** City, e.g. "Kathmandu". */
@@ -87,6 +89,16 @@ interface SearchOptions {
87
89
  type: string;
88
90
  /** A ready-made query, used verbatim instead of composing city/area/type. */
89
91
  query?: string;
92
+ /**
93
+ * Centre the search on a coordinate. Results are biased to this point, and —
94
+ * when `radiusM` is set — filtered to within it. Each lead gets `distanceKm`.
95
+ */
96
+ near?: {
97
+ lat: number;
98
+ lng: number;
99
+ };
100
+ /** Keep only leads within this many metres of `near`. Requires `near`. */
101
+ radiusM?: number;
90
102
  /** Max number of leads to collect. Default 60. */
91
103
  limit?: number;
92
104
  /** Fields to collect. Default: all of {@link ALL_FIELDS}. */
@@ -290,11 +302,17 @@ declare function composeQuery(opts: {
290
302
  }): string;
291
303
  /**
292
304
  * The Google Maps search URL for a query. `hl` (interface language) keeps the
293
- * scraped aria-labels predictable; `gl` biases results to a region.
305
+ * scraped aria-labels predictable; `gl` biases results to a region; `center`
306
+ * (`{lat,lng,zoom}`) points the map at a location for a radius search.
294
307
  */
295
308
  declare function mapsSearchUrl(query: string, opts?: {
296
309
  hl?: string;
297
310
  gl?: string;
311
+ center?: {
312
+ lat: number;
313
+ lng: number;
314
+ zoom?: number;
315
+ };
298
316
  }): string;
299
317
  /**
300
318
  * Expand a possibly-multi search request into individual `{type, city, area}`
@@ -359,10 +377,11 @@ declare function callingCode(country?: string): string | undefined;
359
377
  */
360
378
  declare function normalizePhone(raw?: string, country?: string): string | undefined;
361
379
  /** Keys a lead list can be sorted by. */
362
- type SortKey = "rating" | "reviews" | "name" | "priceLevel";
380
+ type SortKey = "rating" | "reviews" | "name" | "priceLevel" | "distance";
363
381
  /**
364
382
  * Sort leads by a key, missing values always last. Stable, pure, new array.
365
- * `dir` defaults to descending for numeric keys and ascending for `name`.
383
+ * `dir` defaults to descending for numeric quality keys, ascending for `name`
384
+ * and `distance` (nearest first).
366
385
  */
367
386
  declare function sortLeads(leads: Lead[], by?: SortKey, dir?: "asc" | "desc"): Lead[];
368
387
 
@@ -399,6 +418,34 @@ declare function enrichContacts(website: string, opts?: {
399
418
  contactPages?: boolean;
400
419
  }): Promise<Contacts>;
401
420
 
421
+ /**
422
+ * Geo helpers for radius search — parse a coordinate/radius, measure distance
423
+ * (haversine), and pick a Maps zoom that frames a radius. All pure, unit-tested.
424
+ */
425
+ /** A latitude/longitude point. */
426
+ interface LatLng {
427
+ lat: number;
428
+ lng: number;
429
+ }
430
+ /** Great-circle distance between two points, in metres. Pure. */
431
+ declare function haversineMeters(a: LatLng, b: LatLng): number;
432
+ /**
433
+ * Parse a `"lat,lng"` pair (also accepts a bare `@lat,lng` or whitespace).
434
+ * Returns `undefined` if it isn't two in-range numbers. Pure.
435
+ */
436
+ declare function parseLatLngPair(input: string | undefined): LatLng | undefined;
437
+ /**
438
+ * Parse a distance like `"2km"`, `"500m"`, `"1.5mi"` (or a bare number = metres)
439
+ * into metres. Returns `undefined` for junk or non-positive values. Pure.
440
+ */
441
+ declare function parseDistance(input: string | undefined): number | undefined;
442
+ /**
443
+ * A Google Maps zoom level (integer) that roughly frames a circle of `radiusM`
444
+ * at a given latitude in the map viewport. Clamped to 3–19. Pure/best-effort —
445
+ * Maps re-fits results anyway; this just centres the search sensibly.
446
+ */
447
+ declare function zoomForRadius(radiusM: number, lat?: number): number;
448
+
402
449
  /** An arbitrary tabular row. */
403
450
  type DataRow = Record<string, unknown>;
404
451
  /** Infer the format from a filename extension. Throws on an unknown extension. */
@@ -425,4 +472,4 @@ declare function convertFile(input: string, opts?: {
425
472
  count: number;
426
473
  }>;
427
474
 
428
- export { ALL_FIELDS, type BatchQuery, CALLING_CODES, type Contacts, DEFAULT_FIELDS, type DataRow, type DetailedResult, ENRICHED_FIELDS, type EmailStatus, FIELD_PRESETS, type Lead, type LeadField, type LeadFilters, type LeadStats, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, callingCode, cleanWebsite, columnsOf, composeQuery, computeStats, convertFile, dedupeLeads, defaultFilename, detectFormat, emailDomain, emailFormatValid, enrichContacts, expandQueries, extractEmails, extractSocials, filterLeads, mapsSearchUrl, normalizeFields, normalizePhone, parseLatLng, parseRating, parseReviewCount, readRows, resolvePreset, rowsToLeads, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails };
475
+ export { ALL_FIELDS, type BatchQuery, CALLING_CODES, type Contacts, DEFAULT_FIELDS, type DataRow, type DetailedResult, ENRICHED_FIELDS, type EmailStatus, FIELD_PRESETS, type LatLng, type Lead, type LeadField, type LeadFilters, type LeadStats, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, callingCode, cleanWebsite, columnsOf, composeQuery, computeStats, convertFile, dedupeLeads, defaultFilename, detectFormat, emailDomain, emailFormatValid, enrichContacts, expandQueries, extractEmails, extractSocials, filterLeads, haversineMeters, mapsSearchUrl, normalizeFields, normalizePhone, parseDistance, parseLatLng, parseLatLngPair, parseRating, parseReviewCount, readRows, resolvePreset, rowsToLeads, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails, zoomForRadius };
package/dist/lib.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The fields a {@link Lead} can carry — request any subset via `fields`. */
2
- type LeadField = "name" | "category" | "rating" | "reviews" | "priceLevel" | "address" | "phone" | "website" | "email" | "facebook" | "instagram" | "whatsapp" | "linkedin" | "twitter" | "youtube" | "tiktok" | "telegram" | "emailStatus" | "plusCode" | "latitude" | "longitude" | "hours" | "mapsUrl";
2
+ type LeadField = "name" | "category" | "rating" | "reviews" | "priceLevel" | "address" | "phone" | "website" | "email" | "facebook" | "instagram" | "whatsapp" | "linkedin" | "twitter" | "youtube" | "tiktok" | "telegram" | "emailStatus" | "plusCode" | "latitude" | "longitude" | "distanceKm" | "hours" | "mapsUrl";
3
3
  /** Deliverability verdict for a discovered email (see `verifyEmails`). */
4
4
  type EmailStatus = "valid" | "no-mx" | "invalid-format" | "unknown";
5
5
  /** Every field, in a sensible column order for exports. */
@@ -53,6 +53,8 @@ interface Lead {
53
53
  latitude?: number;
54
54
  /** Longitude, parsed from the listing's Maps URL. */
55
55
  longitude?: number;
56
+ /** Distance in km from the `near` point (set only in radius search). */
57
+ distanceKm?: number;
56
58
  /** Opening-hours summary, when shown. */
57
59
  hours?: string;
58
60
  /** Canonical Google Maps URL for the listing. */
@@ -76,7 +78,7 @@ interface LeadFilters {
76
78
  /** Output formats the tool can write. */
77
79
  type OutputFormat = "json" | "ndjson" | "csv" | "xlsx";
78
80
  /** How to sort collected leads before export. */
79
- type SortKey$1 = "rating" | "reviews" | "name" | "priceLevel";
81
+ type SortKey$1 = "rating" | "reviews" | "name" | "priceLevel" | "distance";
80
82
  /** Options for a lead search. */
81
83
  interface SearchOptions {
82
84
  /** City, e.g. "Kathmandu". */
@@ -87,6 +89,16 @@ interface SearchOptions {
87
89
  type: string;
88
90
  /** A ready-made query, used verbatim instead of composing city/area/type. */
89
91
  query?: string;
92
+ /**
93
+ * Centre the search on a coordinate. Results are biased to this point, and —
94
+ * when `radiusM` is set — filtered to within it. Each lead gets `distanceKm`.
95
+ */
96
+ near?: {
97
+ lat: number;
98
+ lng: number;
99
+ };
100
+ /** Keep only leads within this many metres of `near`. Requires `near`. */
101
+ radiusM?: number;
90
102
  /** Max number of leads to collect. Default 60. */
91
103
  limit?: number;
92
104
  /** Fields to collect. Default: all of {@link ALL_FIELDS}. */
@@ -290,11 +302,17 @@ declare function composeQuery(opts: {
290
302
  }): string;
291
303
  /**
292
304
  * The Google Maps search URL for a query. `hl` (interface language) keeps the
293
- * scraped aria-labels predictable; `gl` biases results to a region.
305
+ * scraped aria-labels predictable; `gl` biases results to a region; `center`
306
+ * (`{lat,lng,zoom}`) points the map at a location for a radius search.
294
307
  */
295
308
  declare function mapsSearchUrl(query: string, opts?: {
296
309
  hl?: string;
297
310
  gl?: string;
311
+ center?: {
312
+ lat: number;
313
+ lng: number;
314
+ zoom?: number;
315
+ };
298
316
  }): string;
299
317
  /**
300
318
  * Expand a possibly-multi search request into individual `{type, city, area}`
@@ -359,10 +377,11 @@ declare function callingCode(country?: string): string | undefined;
359
377
  */
360
378
  declare function normalizePhone(raw?: string, country?: string): string | undefined;
361
379
  /** Keys a lead list can be sorted by. */
362
- type SortKey = "rating" | "reviews" | "name" | "priceLevel";
380
+ type SortKey = "rating" | "reviews" | "name" | "priceLevel" | "distance";
363
381
  /**
364
382
  * Sort leads by a key, missing values always last. Stable, pure, new array.
365
- * `dir` defaults to descending for numeric keys and ascending for `name`.
383
+ * `dir` defaults to descending for numeric quality keys, ascending for `name`
384
+ * and `distance` (nearest first).
366
385
  */
367
386
  declare function sortLeads(leads: Lead[], by?: SortKey, dir?: "asc" | "desc"): Lead[];
368
387
 
@@ -399,6 +418,34 @@ declare function enrichContacts(website: string, opts?: {
399
418
  contactPages?: boolean;
400
419
  }): Promise<Contacts>;
401
420
 
421
+ /**
422
+ * Geo helpers for radius search — parse a coordinate/radius, measure distance
423
+ * (haversine), and pick a Maps zoom that frames a radius. All pure, unit-tested.
424
+ */
425
+ /** A latitude/longitude point. */
426
+ interface LatLng {
427
+ lat: number;
428
+ lng: number;
429
+ }
430
+ /** Great-circle distance between two points, in metres. Pure. */
431
+ declare function haversineMeters(a: LatLng, b: LatLng): number;
432
+ /**
433
+ * Parse a `"lat,lng"` pair (also accepts a bare `@lat,lng` or whitespace).
434
+ * Returns `undefined` if it isn't two in-range numbers. Pure.
435
+ */
436
+ declare function parseLatLngPair(input: string | undefined): LatLng | undefined;
437
+ /**
438
+ * Parse a distance like `"2km"`, `"500m"`, `"1.5mi"` (or a bare number = metres)
439
+ * into metres. Returns `undefined` for junk or non-positive values. Pure.
440
+ */
441
+ declare function parseDistance(input: string | undefined): number | undefined;
442
+ /**
443
+ * A Google Maps zoom level (integer) that roughly frames a circle of `radiusM`
444
+ * at a given latitude in the map viewport. Clamped to 3–19. Pure/best-effort —
445
+ * Maps re-fits results anyway; this just centres the search sensibly.
446
+ */
447
+ declare function zoomForRadius(radiusM: number, lat?: number): number;
448
+
402
449
  /** An arbitrary tabular row. */
403
450
  type DataRow = Record<string, unknown>;
404
451
  /** Infer the format from a filename extension. Throws on an unknown extension. */
@@ -425,4 +472,4 @@ declare function convertFile(input: string, opts?: {
425
472
  count: number;
426
473
  }>;
427
474
 
428
- export { ALL_FIELDS, type BatchQuery, CALLING_CODES, type Contacts, DEFAULT_FIELDS, type DataRow, type DetailedResult, ENRICHED_FIELDS, type EmailStatus, FIELD_PRESETS, type Lead, type LeadField, type LeadFilters, type LeadStats, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, callingCode, cleanWebsite, columnsOf, composeQuery, computeStats, convertFile, dedupeLeads, defaultFilename, detectFormat, emailDomain, emailFormatValid, enrichContacts, expandQueries, extractEmails, extractSocials, filterLeads, mapsSearchUrl, normalizeFields, normalizePhone, parseLatLng, parseRating, parseReviewCount, readRows, resolvePreset, rowsToLeads, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails };
475
+ export { ALL_FIELDS, type BatchQuery, CALLING_CODES, type Contacts, DEFAULT_FIELDS, type DataRow, type DetailedResult, ENRICHED_FIELDS, type EmailStatus, FIELD_PRESETS, type LatLng, type Lead, type LeadField, type LeadFilters, type LeadStats, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, callingCode, cleanWebsite, columnsOf, composeQuery, computeStats, convertFile, dedupeLeads, defaultFilename, detectFormat, emailDomain, emailFormatValid, enrichContacts, expandQueries, extractEmails, extractSocials, filterLeads, haversineMeters, mapsSearchUrl, normalizeFields, normalizePhone, parseDistance, parseLatLng, parseLatLngPair, parseRating, parseReviewCount, readRows, resolvePreset, rowsToLeads, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails, zoomForRadius };
package/dist/lib.js CHANGED
@@ -24,9 +24,11 @@ var ALL_FIELDS = [
24
24
  "plusCode",
25
25
  "latitude",
26
26
  "longitude",
27
+ "distanceKm",
27
28
  "hours",
28
29
  "mapsUrl"
29
30
  ];
31
+ var DERIVED_FIELDS = ["distanceKm"];
30
32
  var ENRICHED_FIELDS = [
31
33
  "email",
32
34
  "facebook",
@@ -49,11 +51,13 @@ var FIELD_PRESETS = {
49
51
  /** Map/geo columns for plotting. */
50
52
  geo: ["name", "address", "latitude", "longitude", "plusCode", "mapsUrl"],
51
53
  /** Everything Maps shows, no website enrichment. */
52
- full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f)),
54
+ full: ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)),
53
55
  /** Every field, including enriched ones. */
54
56
  everything: [...ALL_FIELDS]
55
57
  };
56
- var DEFAULT_FIELDS = ALL_FIELDS.filter((f) => !ENRICHED_FIELDS.includes(f));
58
+ var DEFAULT_FIELDS = ALL_FIELDS.filter(
59
+ (f) => !ENRICHED_FIELDS.includes(f) && !DERIVED_FIELDS.includes(f)
60
+ );
57
61
 
58
62
  // src/query.ts
59
63
  function composeQuery(opts) {
@@ -67,7 +71,9 @@ function mapsSearchUrl(query, opts = {}) {
67
71
  const hl = (opts.hl ?? "en").split("-")[0] || "en";
68
72
  const params = new URLSearchParams({ hl });
69
73
  if (opts.gl) params.set("gl", opts.gl.toLowerCase());
70
- return `https://www.google.com/maps/search/${encodeURIComponent(query)}?${params.toString()}`;
74
+ const base = `https://www.google.com/maps/search/${encodeURIComponent(query)}`;
75
+ const at = opts.center ? `/@${opts.center.lat},${opts.center.lng},${Math.round(opts.center.zoom ?? 14)}z` : "";
76
+ return `${base}${at}?${params.toString()}`;
71
77
  }
72
78
  function expandQueries(opts) {
73
79
  if (opts.query && opts.query.trim()) return [{ query: opts.query.trim() }];
@@ -367,12 +373,14 @@ function plausible(digits) {
367
373
  return digits.length >= 8 && digits.length <= 15;
368
374
  }
369
375
  var priceRank = (p) => p ? p.replace(/[^$€£₹¥₩]/g, "").length : 0;
376
+ var ASC_BY_DEFAULT = /* @__PURE__ */ new Set(["name", "distance"]);
370
377
  function sortLeads(leads, by, dir) {
371
378
  if (!by) return [...leads];
372
- const desc = dir ? dir === "desc" : by !== "name";
379
+ const desc = dir ? dir === "desc" : !ASC_BY_DEFAULT.has(by);
373
380
  const val = (l) => {
374
381
  if (by === "name") return l.name?.toLowerCase();
375
382
  if (by === "priceLevel") return l.priceLevel ? priceRank(l.priceLevel) : void 0;
383
+ if (by === "distance") return l.distanceKm;
376
384
  return l[by];
377
385
  };
378
386
  return [...leads].map((lead, i) => ({ lead, i })).sort((a, b) => {
@@ -440,6 +448,43 @@ async function verifyEmails(leads, opts = {}) {
440
448
  return leads;
441
449
  }
442
450
 
451
+ // src/geo.ts
452
+ var R_EARTH_M = 63710088e-1;
453
+ var toRad = (deg) => deg * Math.PI / 180;
454
+ function haversineMeters(a, b) {
455
+ const dLat = toRad(b.lat - a.lat);
456
+ const dLng = toRad(b.lng - a.lng);
457
+ const lat1 = toRad(a.lat);
458
+ const lat2 = toRad(b.lat);
459
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
460
+ return 2 * R_EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)));
461
+ }
462
+ function parseLatLngPair(input) {
463
+ if (!input) return void 0;
464
+ const m = input.replace(/^@/, "").match(/(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)/);
465
+ if (!m) return void 0;
466
+ const lat = parseFloat(m[1]);
467
+ const lng = parseFloat(m[2]);
468
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void 0;
469
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return void 0;
470
+ return { lat, lng };
471
+ }
472
+ function parseDistance(input) {
473
+ if (!input) return void 0;
474
+ const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(km|m|mi|mile|miles|meters?|metres?)?$/);
475
+ if (!m) return void 0;
476
+ const value = parseFloat(m[1]);
477
+ if (!Number.isFinite(value) || value <= 0) return void 0;
478
+ const unit = m[2] ?? "m";
479
+ const metres = unit === "km" ? value * 1e3 : unit.startsWith("mi") ? value * 1609.344 : value;
480
+ return metres;
481
+ }
482
+ function zoomForRadius(radiusM, lat = 0) {
483
+ const mpp = 2 * Math.max(50, radiusM) / 600;
484
+ const zoom = Math.log2(156543.03392 * Math.cos(toRad(lat)) / mpp);
485
+ return Math.max(3, Math.min(19, Math.round(zoom)));
486
+ }
487
+
443
488
  // src/export.ts
444
489
  import { stringify as csvStringify } from "@lacspace/csv";
445
490
  import { jsonToXlsx } from "@lacspace/xlsx";
@@ -465,6 +510,7 @@ var HEADERS = {
465
510
  plusCode: "Plus Code",
466
511
  latitude: "Latitude",
467
512
  longitude: "Longitude",
513
+ distanceKm: "Distance (km)",
468
514
  hours: "Hours",
469
515
  mapsUrl: "Maps URL"
470
516
  };
@@ -525,7 +571,7 @@ var FIELD_BY_KEY = (() => {
525
571
  }
526
572
  return m;
527
573
  })();
528
- var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude"]);
574
+ var NUMERIC_FIELDS = /* @__PURE__ */ new Set(["rating", "reviews", "latitude", "longitude", "distanceKm"]);
529
575
  function rowsToLeads(rows) {
530
576
  return rows.map((row) => {
531
577
  const lead = {};
@@ -736,8 +782,13 @@ async function scrapeLeads(opts) {
736
782
  const pauseMs = () => opts.jitter ? Math.round(delayMs * (0.6 + Math.random() * 0.8)) : delayMs;
737
783
  const wantVerify = Boolean(opts.verifyEmails) || Boolean(opts.filters?.hasValidEmail) || fields.has("emailStatus");
738
784
  const wantEnrich = Boolean(opts.enrich) || ENRICHED_FIELDS.some((f) => fields.has(f)) || Boolean(opts.filters?.hasEmail) || wantVerify;
785
+ const near = opts.near;
739
786
  const collect = new Set(fields);
740
787
  if (wantEnrich) collect.add("website");
788
+ if (near) {
789
+ collect.add("latitude");
790
+ collect.add("longitude");
791
+ }
741
792
  const detailOnly = ["name", "mapsUrl"];
742
793
  const wantDetails = (opts.details ?? true) && ([...collect].some((f) => !detailOnly.includes(f)) || wantEnrich);
743
794
  onProgress?.(`searching Google Maps for "${query}"\u2026`);
@@ -751,6 +802,7 @@ async function scrapeLeads(opts) {
751
802
  const page = await context.newPage();
752
803
  const urlOpts = { hl: locale };
753
804
  if (opts.region) urlOpts.gl = opts.region;
805
+ if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? 2e3, near.lat) };
754
806
  await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
755
807
  await dismissConsent(page);
756
808
  await loadResults(page, limit, delayMs, onProgress, signal);
@@ -828,6 +880,19 @@ async function scrapeLeads(opts) {
828
880
  if (lead.website) lead.website = cleanWebsite(lead.website);
829
881
  }
830
882
  }
883
+ if (near) {
884
+ for (const lead of leads) {
885
+ if (typeof lead.latitude === "number" && typeof lead.longitude === "number") {
886
+ const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
887
+ lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
888
+ }
889
+ }
890
+ if (opts.radiusM !== void 0) {
891
+ const r = opts.radiusM;
892
+ leads = leads.filter((l) => l.distanceKm !== void 0 && l.distanceKm * 1e3 <= r);
893
+ onProgress?.(`within ${Math.round(r)} m: ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
894
+ }
895
+ }
831
896
  leads = dedupeLeads(leads, opts.dedupe ?? "website");
832
897
  if (wantEnrich) {
833
898
  const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
@@ -868,6 +933,16 @@ async function scrapeLeads(opts) {
868
933
  if (opts.filters) leads = filterLeads(leads, opts.filters);
869
934
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
870
935
  if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
936
+ if (near) {
937
+ const keepLat = fields.has("latitude");
938
+ const keepLng = fields.has("longitude");
939
+ const keepDist = fields.has("distanceKm");
940
+ for (const l of leads) {
941
+ if (!keepLat) delete l.latitude;
942
+ if (!keepLng) delete l.longitude;
943
+ if (!keepDist) delete l.distanceKm;
944
+ }
945
+ }
871
946
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
872
947
  return leads;
873
948
  } finally {
@@ -1040,10 +1115,13 @@ export {
1040
1115
  extractEmails,
1041
1116
  extractSocials,
1042
1117
  filterLeads,
1118
+ haversineMeters,
1043
1119
  mapsSearchUrl,
1044
1120
  normalizeFields,
1045
1121
  normalizePhone,
1122
+ parseDistance,
1046
1123
  parseLatLng,
1124
+ parseLatLngPair,
1047
1125
  parseRating,
1048
1126
  parseReviewCount,
1049
1127
  readRows,
@@ -1059,5 +1137,6 @@ export {
1059
1137
  sortLeads,
1060
1138
  toRows,
1061
1139
  verifyEmail,
1062
- verifyEmails
1140
+ verifyEmails,
1141
+ zoomForRadius
1063
1142
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lacspace-leads",
3
- "version": "1.2.1",
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, verify emails by MX, normalise phones to E.164, accumulate into a master file, and enrich in parallel through an optional proxy. No API keys.",
3
+ "version": "1.3.0",
4
+ "description": "Free, open-source local-business lead finder — name a city/area/business type, or search by coordinates + radius, 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, verify emails by MX, normalise phones to E.164, accumulate into a master file, and enrich in parallel through an optional proxy. No API keys.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lacspace-leads": "dist/cli.js"
@@ -33,6 +33,8 @@
33
33
  "google-maps",
34
34
  "scraper",
35
35
  "local-business",
36
+ "radius-search",
37
+ "geosearch",
36
38
  "prospecting",
37
39
  "b2b",
38
40
  "email-finder",