lacspace-leads 1.6.0 → 1.7.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
@@ -59,7 +59,12 @@ npx lacspace-leads [type] [options]
59
59
  | `--enrich-out <file>` | Also write `{ name, website, domain }` NDJSON, ready to feed [`lacspace-enrich`](https://www.npmjs.com/package/lacspace-enrich) |
60
60
  | `--resume` | Resume an interrupted **multi-search sweep** from its checkpoint file (`<out>.checkpoint.json`) |
61
61
  | `--sheet <name>` | Excel sheet name (default `Leads`) |
62
- | `-n, --limit <n>` | Max listings **per search** (default `60`) |
62
+ | `-n, --limit <n>` | Max listings **per search** (default `60`; Google itself stops near 120) |
63
+ | `--target <n>` | How many leads you want **in total** — keeps searching until it has them. Use this for 300, 500, 1000 |
64
+ | `--step <dist>` | Spacing between map tiles in a target sweep (default `2.5km`) |
65
+ | `--tiles <n>` | Most map tiles to try (default `49`) |
66
+ | `--split <key>` | Write one file per `city`, `area` or `type` alongside the main file |
67
+ | `--no-website` | Only businesses with **no website** — the "you need a site" pitch list |
63
68
  | `--total <n>` | Cap the merged result when sweeping several searches |
64
69
  | `--no-details` | Skip opening each listing — names + Maps URLs only, much faster |
65
70
  | `--sort <key>` | `rating` · `reviews` · `name` · `priceLevel` · `distance` (missing values last) |
@@ -111,6 +116,29 @@ npx lacspace-leads [type] [options]
111
116
 
112
117
  Run with no arguments for an interactive walkthrough.
113
118
 
119
+ ## Ask for a number and actually get it (`--target`)
120
+
121
+ One Google Maps search stops at roughly **120 results**, so `--limit 500` can only ever hand back about 114. `--target` is the honest version: say how many leads you want in total and the tool keeps going until it has them.
122
+
123
+ ```bash
124
+ # 500 restaurants, however many searches that takes
125
+ npx lacspace-leads restaurants --city Kathmandu --target 500 -f xlsx
126
+ ```
127
+
128
+ It expands coverage in this order, stopping the moment the target is met:
129
+
130
+ 1. **Every place you named** — each city x area x type is its own search.
131
+ 2. **Map tiles** — the same query re-centred on a grid of points around the city, walking outwards from the middle. No geocoding service and no API key: the centre comes from the map itself.
132
+
133
+ Every step is de-duplicated against everything collected so far, so the number you get is unique businesses, not rows. If the map runs dry before the target, it says so plainly instead of pretending:
134
+
135
+ ```
136
+ ! The map ran out of new results at 214 — that is everything Google lists here.
137
+ Widen it: more areas, more cities, a bigger --step, or related --types.
138
+ ```
139
+
140
+ Tune the grid with `--step` (tighter spacing finds more in dense cities) and `--tiles` (how far out to go).
141
+
114
142
  ## Sweep a whole city
115
143
 
116
144
  Comma-separate areas (and/or types) and `lacspace-leads` runs each search in turn, then **merges and de-duplicates** into a single list — sorted and filtered across the whole set:
@@ -123,6 +151,15 @@ npx lacspace-leads "coffee shop" \
123
151
 
124
152
  # Two business types at once, capped at 100 unique leads total
125
153
  npx lacspace-leads --type "gym,fitness studio" --city Pokhara --total 100 -f csv
154
+
155
+ # Several cities and several areas at once — 400 in total, one Excel file per city
156
+ npx lacspace-leads restaurants \
157
+ --cities "Kathmandu, Lalitpur, Bhaktapur" \
158
+ --areas "Baneshwor, Thamel, Patan" \
159
+ --target 400 --split city -f xlsx
160
+
161
+ # Everyone in town who has no website yet
162
+ npx lacspace-leads "beauty salon" --city Pokhara --target 200 --no-website --has-phone -f csv
126
163
  ```
127
164
 
128
165
  ## Search by radius
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { writeFileSync as writeFileSync2, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
5
- import { resolve as resolve2 } from "path";
5
+ import { resolve as resolve2, extname as extname2 } from "path";
6
6
  import { createInterface } from "readline/promises";
7
7
  import { stdin, stdout, stderr, argv, exit } from "process";
8
8
 
@@ -308,6 +308,7 @@ function filterLeads(leads, filters = {}) {
308
308
  if (filters.minReviews !== void 0 && !(typeof l.reviews === "number" && l.reviews >= filters.minReviews)) return false;
309
309
  if (filters.hasPhone && !l.phone) return false;
310
310
  if (filters.hasWebsite && !l.website) return false;
311
+ if (filters.noWebsite && l.website) return false;
311
312
  if (filters.hasEmail && !l.email) return false;
312
313
  if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
313
314
  if (filters.hasContact && !(l.phone || l.email || l.website)) return false;
@@ -950,11 +951,20 @@ async function scrapeLeads(opts) {
950
951
  const page = await context.newPage();
951
952
  const urlOpts = { hl: locale };
952
953
  if (opts.region) urlOpts.gl = opts.region;
953
- if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? 2e3, near.lat) };
954
+ if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? opts.zoomRadiusM ?? 2e3, near.lat) };
954
955
  await page.goto(mapsSearchUrl(query, urlOpts), { waitUntil: "domcontentloaded", timeout: 45e3 });
955
956
  await dismissConsent(page);
956
957
  await loadResults(page, limit, delayMs, onProgress, signal);
957
958
  if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
959
+ if (opts.onCenter) {
960
+ const at = parseLatLng(page.url());
961
+ if (at.latitude !== void 0 && at.longitude !== void 0) {
962
+ try {
963
+ opts.onCenter({ lat: at.latitude, lng: at.longitude });
964
+ } catch {
965
+ }
966
+ }
967
+ }
958
968
  const cards = await page.locator("a.hfpxzc").evaluateAll(
959
969
  (els, lim) => els.slice(0, lim).map((a) => ({
960
970
  href: a.href,
@@ -966,6 +976,22 @@ async function scrapeLeads(opts) {
966
976
  onProgress?.("no listings found (Google may have shown a CAPTCHA or an empty result).");
967
977
  return [];
968
978
  }
979
+ if (opts.skipListing) {
980
+ const all = cards;
981
+ const before = all.length;
982
+ const fresh = all.filter((card) => {
983
+ try {
984
+ return !opts.skipListing({ name: card.name, href: card.href });
985
+ } catch {
986
+ return true;
987
+ }
988
+ });
989
+ if (fresh.length !== before) {
990
+ onProgress?.(`${before - fresh.length} of ${before} already collected \u2014 skipping them.`);
991
+ all.splice(0, before, ...fresh);
992
+ }
993
+ if (all.length === 0) return [];
994
+ }
969
995
  let leads = [];
970
996
  if (!wantDetails) {
971
997
  leads = cards.map((c2) => {
@@ -1437,6 +1463,173 @@ function leadsToEnrichInput(leads) {
1437
1463
  return out;
1438
1464
  }
1439
1465
 
1466
+ // src/sweep.ts
1467
+ var MAX_PER_SEARCH = 120;
1468
+ var MIN_PER_STEP = 40;
1469
+ var M_PER_DEG_LAT = 111320;
1470
+ function gridPoints(center, stepM, rings) {
1471
+ const step = Math.max(100, stepM);
1472
+ const ringCount = Math.max(0, Math.trunc(rings));
1473
+ const dLat = step / M_PER_DEG_LAT;
1474
+ const cosLat = Math.cos(center.lat * Math.PI / 180);
1475
+ const dLng = step / (M_PER_DEG_LAT * (Math.abs(cosLat) < 0.01 ? 0.01 : cosLat));
1476
+ const out = [];
1477
+ for (let ring = 0; ring <= ringCount; ring++) {
1478
+ for (let x = -ring; x <= ring; x++) {
1479
+ for (let y = -ring; y <= ring; y++) {
1480
+ if (ring > 0 && Math.max(Math.abs(x), Math.abs(y)) !== ring) continue;
1481
+ out.push({
1482
+ lat: +(center.lat + y * dLat).toFixed(6),
1483
+ lng: +(center.lng + x * dLng).toFixed(6)
1484
+ });
1485
+ }
1486
+ }
1487
+ }
1488
+ return out;
1489
+ }
1490
+ function tilesForRings(rings) {
1491
+ const r = Math.max(0, Math.trunc(rings));
1492
+ return (2 * r + 1) ** 2;
1493
+ }
1494
+ function ringsForTiles(tiles) {
1495
+ const want = Math.max(1, Math.trunc(tiles));
1496
+ let rings = 0;
1497
+ while (tilesForRings(rings) < want) rings++;
1498
+ return rings;
1499
+ }
1500
+ function planNamedSteps(base) {
1501
+ return expandQueries(base).map((q) => ({ kind: "search", ...q }));
1502
+ }
1503
+ async function sweepLeads(opts) {
1504
+ const {
1505
+ target,
1506
+ stepM = 2500,
1507
+ maxTiles = 49,
1508
+ perSearch,
1509
+ onNotice,
1510
+ near,
1511
+ ...shared
1512
+ } = opts;
1513
+ const want = Math.max(1, Math.trunc(target));
1514
+ const cap = Math.min(MAX_PER_SEARCH, Math.max(1, Math.trunc(perSearch ?? MAX_PER_SEARCH)));
1515
+ const dedupeBy = shared.dedupe ?? "smart";
1516
+ let collected = [];
1517
+ const stats = { searches: 0, tiles: 0, unique: 0, saturated: false };
1518
+ const seenNames = /* @__PURE__ */ new Set();
1519
+ const seenUrls = /* @__PURE__ */ new Set();
1520
+ const remember = (leads) => {
1521
+ for (const l of leads) {
1522
+ if (l.name) seenNames.add(l.name.trim().toLowerCase());
1523
+ if (l.mapsUrl) seenUrls.add(l.mapsUrl);
1524
+ }
1525
+ };
1526
+ const skipListing = (listing) => seenUrls.has(listing.href) || !!listing.name && seenNames.has(listing.name.trim().toLowerCase());
1527
+ const merge = (found) => {
1528
+ const before = collected.length;
1529
+ collected = dedupeLeads([...collected, ...found], dedupeBy);
1530
+ remember(found);
1531
+ stats.unique = collected.length;
1532
+ return collected.length - before;
1533
+ };
1534
+ const enough = () => collected.length >= want;
1535
+ const stepLimit = () => Math.min(cap, Math.max(MIN_PER_STEP, (want - collected.length) * 2));
1536
+ let center = near ? { lat: near.lat, lng: near.lng } : void 0;
1537
+ const captureCenter = (c2) => {
1538
+ center ??= c2;
1539
+ };
1540
+ for (const step of planNamedSteps(shared)) {
1541
+ if (enough() || shared.signal?.aborted) break;
1542
+ const searchOpts = {
1543
+ ...shared,
1544
+ ...step,
1545
+ limit: stepLimit(),
1546
+ onCenter: captureCenter
1547
+ };
1548
+ const found = await scrapeLeads(searchOpts);
1549
+ stats.searches++;
1550
+ const added = merge(found);
1551
+ onNotice?.(
1552
+ `search ${stats.searches}: ${[step.area, step.city].filter(Boolean).join(", ") || step.query || step.type} \u2192 +${added} new (${collected.length}/${want})`
1553
+ );
1554
+ }
1555
+ if (!enough() && !shared.signal?.aborted) {
1556
+ if (!center) {
1557
+ onNotice?.("no map centre available, so tiling is not possible \u2014 pass --near lat,lng to sweep a radius");
1558
+ stats.saturated = true;
1559
+ } else {
1560
+ const points = gridPoints(center, stepM, ringsForTiles(maxTiles)).slice(0, Math.max(1, maxTiles));
1561
+ onNotice?.(
1562
+ `named searches gave ${collected.length} of ${want} \u2014 tiling the map around ${center.lat.toFixed(4)},${center.lng.toFixed(4)} every ${Math.round(stepM)}m`
1563
+ );
1564
+ let dry = 0;
1565
+ for (const point of points) {
1566
+ if (enough() || shared.signal?.aborted) break;
1567
+ const tileOpts = {
1568
+ ...shared,
1569
+ limit: stepLimit(),
1570
+ near: point,
1571
+ onCenter: captureCenter,
1572
+ skipListing
1573
+ };
1574
+ delete tileOpts.area;
1575
+ delete tileOpts.city;
1576
+ if (tileOpts.radiusM === void 0) tileOpts.zoomRadiusM = stepM;
1577
+ const found = await scrapeLeads(tileOpts);
1578
+ stats.tiles++;
1579
+ const added = merge(found);
1580
+ onNotice?.(`tile ${stats.tiles}/${points.length} \u2192 +${added} new (${collected.length}/${want})`);
1581
+ dry = added === 0 ? dry + 1 : 0;
1582
+ if (dry >= 4) {
1583
+ stats.saturated = true;
1584
+ break;
1585
+ }
1586
+ }
1587
+ if (!enough() && !stats.saturated) stats.saturated = stats.tiles >= points.length;
1588
+ }
1589
+ }
1590
+ const sorted = shared.sort ? sortLeads(collected, shared.sort, shared.sortDir) : collected;
1591
+ return { leads: sorted.slice(0, want), stats };
1592
+ }
1593
+
1594
+ // src/split.ts
1595
+ var SPLIT_KEYS = ["city", "area", "type"];
1596
+ function addressMentions(address, name) {
1597
+ const hay = (address ?? "").toLowerCase();
1598
+ const needle = name.trim().toLowerCase();
1599
+ if (!hay || !needle) return false;
1600
+ const at = hay.indexOf(needle);
1601
+ if (at === -1) return false;
1602
+ const before = at === 0 ? "" : hay[at - 1];
1603
+ const after = hay[at + needle.length] ?? "";
1604
+ const boundary = (ch) => ch === "" || !/[a-z0-9]/.test(ch);
1605
+ return boundary(before) && boundary(after);
1606
+ }
1607
+ function groupLeads(leads, key, requested = []) {
1608
+ const places = requested.map((p) => p.trim()).filter(Boolean);
1609
+ const groupOf = (lead) => {
1610
+ if (key === "type") return (lead.category ?? "").trim() || "other";
1611
+ const hit = places.find((p) => addressMentions(lead.address, p));
1612
+ return hit ?? "other";
1613
+ };
1614
+ const groups = /* @__PURE__ */ new Map();
1615
+ for (const lead of leads) {
1616
+ const g = groupOf(lead);
1617
+ const bucket = groups.get(g);
1618
+ if (bucket) bucket.push(lead);
1619
+ else groups.set(g, [lead]);
1620
+ }
1621
+ return new Map(
1622
+ [...groups].sort((a, b) => {
1623
+ if (a[0] === "other") return 1;
1624
+ if (b[0] === "other") return -1;
1625
+ return b[1].length - a[1].length;
1626
+ })
1627
+ );
1628
+ }
1629
+ function groupSlug(name) {
1630
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "other";
1631
+ }
1632
+
1440
1633
  // src/cli.ts
1441
1634
  var SOCIAL_FIELDS = ENRICHED_FIELDS.filter((f) => f !== "email");
1442
1635
  var OUTPUT_FORMATS = ["json", "ndjson", "csv", "xlsx"];
@@ -1465,6 +1658,7 @@ function parseArgs(list) {
1465
1658
  verifyEmails: false,
1466
1659
  hasPhone: false,
1467
1660
  hasWebsite: false,
1661
+ noWebsite: false,
1468
1662
  hasEmail: false,
1469
1663
  hasValidEmail: false,
1470
1664
  hasContact: false,
@@ -1491,6 +1685,11 @@ function parseArgs(list) {
1491
1685
  else if (arg === "-o" || arg === "--out") a.out = next();
1492
1686
  else if (arg === "--append") a.append = true;
1493
1687
  else if (arg === "-n" || arg === "--limit") a.limit = parseInt(next(), 10) || a.limit;
1688
+ else if (arg === "--target" || arg === "--want" || arg === "--goal") a.target = parseInt(next(), 10) || a.target;
1689
+ else if (arg === "--step" || arg === "--tile-step") a.step = next();
1690
+ else if (arg === "--tiles" || arg === "--max-tiles") a.tiles = parseInt(next(), 10) || a.tiles;
1691
+ else if (arg === "--split" || arg === "--split-by") a.split = next();
1692
+ else if (arg === "--no-website" || arg === "--without-website") a.noWebsite = true;
1494
1693
  else if (arg === "--total") a.total = parseInt(next(), 10) || a.total;
1495
1694
  else if (arg === "--headless") a.headless = true;
1496
1695
  else if (arg === "--no-details") a.details = false;
@@ -1564,7 +1763,14 @@ ${c("bold", "Search options")}
1564
1763
  --radius <dist> Keep only leads within this of --near, e.g. 2km, 500m, 1mi
1565
1764
  --fields <list> Columns: ${ALL_FIELDS.join(",")}
1566
1765
  --preset <name> Field bundle: ${Object.keys(FIELD_PRESETS).join(" | ")}
1567
- -n, --limit <n> Max listings per search (default 60)
1766
+ -n, --limit <n> Max listings per SEARCH (default 60, Google caps ~120)
1767
+ --target <n> How many leads you want IN TOTAL \u2014 keeps searching
1768
+ (every area you named, then tiles the map) until it has
1769
+ them. Use this one for 500+.
1770
+ --step <dist> Spacing between map tiles (default 2.5km)
1771
+ --tiles <n> Most map tiles to try (default 49)
1772
+ --split <key> Write one file per city | area | type
1773
+ --no-website Only businesses with NO website (the pitch list)
1568
1774
  --total <n> Cap the merged result (batch searches)
1569
1775
  --no-details Names + Maps URLs only (fast, no per-listing open)
1570
1776
 
@@ -1810,12 +2016,18 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1810
2016
  `);
1811
2017
  if (!args.query && !args.type && !args.yes) {
1812
2018
  args.type = await prompt(`${c("green", "?")} Business type ${c("dim", "(e.g. restaurants)")}: `);
1813
- args.city = args.city ?? await prompt(`${c("green", "?")} City ${c("dim", "(optional)")}: `);
1814
- args.area = args.area ?? await prompt(`${c("green", "?")} Area ${c("dim", "(optional)")}: `);
2019
+ args.city = args.city ?? await prompt(`${c("green", "?")} City ${c("dim", "(one, or several: Kathmandu, Pokhara)")}: `);
2020
+ args.area = args.area ?? await prompt(`${c("green", "?")} Area ${c("dim", "(optional, several allowed: Baneshwor, Thamel)")}: `);
1815
2021
  const fmt = await prompt(`${c("green", "?")} Format ${c("dim", "(json/csv/xlsx)")} ${c("dim", "[json]")}: `, "json");
1816
2022
  if (fmt === "csv" || fmt === "xlsx" || fmt === "json") args.format = fmt;
1817
- const lim = await prompt(`${c("green", "?")} How many ${c("dim", "[60]")}: `, "60");
1818
- args.limit = parseInt(lim, 10) || args.limit;
2023
+ const lim = await prompt(`${c("green", "?")} How many leads in total ${c("dim", "[60]")}: `, "60");
2024
+ const wanted2 = parseInt(lim, 10) || args.limit;
2025
+ if (wanted2 > MAX_PER_SEARCH) {
2026
+ args.target = wanted2;
2027
+ log(` ${c("dim", `${wanted2} is more than one search returns, so I'll sweep every area, then tile the map.`)}`);
2028
+ } else {
2029
+ args.limit = wanted2;
2030
+ }
1819
2031
  const em = await prompt(`${c("green", "?")} Also find emails from websites? ${c("dim", "(slower) [y/N]")} `);
1820
2032
  if (/^y/i.test(em)) args.emails = true;
1821
2033
  }
@@ -1841,6 +2053,12 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1841
2053
  return;
1842
2054
  }
1843
2055
  const nearPoint = args.near ? parseLatLngPair(args.near) : void 0;
2056
+ const tileStepM = args.step ? parseDistance(args.step) : void 0;
2057
+ if (!args.details && (args.split || args.noWebsite)) {
2058
+ const needs = [args.split ? `--split ${args.split}` : "", args.noWebsite ? "--no-website" : ""].filter(Boolean).join(" and ");
2059
+ log(c("yellow", ` ! ${needs} needs each listing's details, but --no-details is on \u2014 turning details back on.`));
2060
+ args.details = true;
2061
+ }
1844
2062
  if (args.near && !nearPoint) {
1845
2063
  log(c("red", `
1846
2064
  \u2717 --near must be "lat,lng", e.g. --near "27.7172,85.3240".`));
@@ -1884,6 +2102,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1884
2102
  if (args.minReviews !== void 0) filters.minReviews = args.minReviews;
1885
2103
  if (args.hasPhone) filters.hasPhone = true;
1886
2104
  if (args.hasWebsite) filters.hasWebsite = true;
2105
+ if (args.noWebsite) filters.noWebsite = true;
1887
2106
  if (args.hasEmail) filters.hasEmail = true;
1888
2107
  if (args.hasValidEmail) filters.hasValidEmail = true;
1889
2108
  if (args.hasContact) filters.hasContact = true;
@@ -1987,8 +2206,22 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
1987
2206
  }
1988
2207
  }
1989
2208
  let leads;
2209
+ let sweptShort = false;
1990
2210
  try {
1991
- if (isBatch) {
2211
+ if (args.target !== void 0) {
2212
+ const sweepOpts = {
2213
+ ...opts,
2214
+ target: args.target,
2215
+ onNotice: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
2216
+ };
2217
+ if (tileStepM !== void 0) sweepOpts.stepM = tileStepM;
2218
+ if (args.tiles !== void 0) sweepOpts.maxTiles = args.tiles;
2219
+ if (args.limit !== 60) sweepOpts.perSearch = args.limit;
2220
+ const swept = await sweepLeads(sweepOpts);
2221
+ leads = swept.leads;
2222
+ sweptShort = swept.stats.saturated && leads.length < args.target;
2223
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `${swept.stats.searches} search${swept.stats.searches === 1 ? "" : "es"} + ${swept.stats.tiles} map tile${swept.stats.tiles === 1 ? "" : "s"} \u2192 ${swept.stats.unique} unique`)}`);
2224
+ } else if (isBatch) {
1992
2225
  const batchOpts = { ...opts };
1993
2226
  if (args.total !== void 0) batchOpts.total = args.total;
1994
2227
  if (checkpoint && cpFile) {
@@ -2013,6 +2246,16 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2013
2246
  } finally {
2014
2247
  process.removeListener("SIGINT", onSig);
2015
2248
  }
2249
+ if (sweptShort) {
2250
+ log(c("yellow", `
2251
+ ! The map ran out of new results at ${leads.length} \u2014 that is everything Google lists here.`));
2252
+ log(c("dim", " Widen it: more areas (--areas a,b,c), more cities (--cities x,y), a bigger --step, or related --types.\n"));
2253
+ } else if (args.target === void 0 && args.limit > leads.length && leads.length >= MAX_PER_SEARCH - 20) {
2254
+ log(c("yellow", `
2255
+ ! Google stops a single search at about ${MAX_PER_SEARCH} results, so --limit ${args.limit} could only return ${leads.length}.`));
2256
+ log(c("dim", ` To really get ${args.limit}, ask for a total: --target ${args.limit} \u2014 it sweeps every area you name, then tiles the map.
2257
+ `));
2258
+ }
2016
2259
  if (leads.length === 0) {
2017
2260
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
2018
2261
  if (cpFile) clearCheckpoint(cpFile);
@@ -2072,6 +2315,24 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2072
2315
  printSummary();
2073
2316
  return;
2074
2317
  }
2318
+ if (args.split) {
2319
+ const key = args.split.trim().toLowerCase();
2320
+ if (!SPLIT_KEYS.includes(key)) {
2321
+ log(c("yellow", ` ! --split "${args.split}" is not one of ${SPLIT_KEYS.join(", ")} \u2014 writing a single file instead.`));
2322
+ } else {
2323
+ const requested = (key === "area" ? args.area : key === "city" ? args.city : "")?.split(",").map((x) => x.trim()).filter(Boolean) ?? [];
2324
+ const groups = groupLeads(leads, key, requested);
2325
+ const ext = extname2(out) || `.${args.format}`;
2326
+ const stem = out.slice(0, out.length - ext.length);
2327
+ log("");
2328
+ for (const [group, rows] of groups) {
2329
+ const file = `${stem}-${groupSlug(group)}${ext}`;
2330
+ const part = serialize(rows, args.format, fields, serOpts);
2331
+ writeFileSync2(file, part.binary ? Buffer.from(part.data) : part.data);
2332
+ log(` ${c("green", "\u2714")} ${c("bold", String(rows.length))} leads \u2192 ${c("cyan", file)}`);
2333
+ }
2334
+ }
2335
+ }
2075
2336
  writeFileSync2(out, binary ? Buffer.from(data) : data);
2076
2337
  if (cpFile) clearCheckpoint(cpFile);
2077
2338
  const s = computeStats(leads);