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 +18 -3
- package/dist/cli.js +113 -7
- package/dist/lib.cjs +90 -7
- package/dist/lib.d.cts +53 -6
- package/dist/lib.d.ts +53 -6
- package/dist/lib.js +85 -6
- package/package.json +4 -2
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
|
-
| `--
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Free, open-source local-business lead finder — name a city,
|
|
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",
|