lacspace-leads 1.5.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
@@ -1,6 +1,6 @@
1
1
  # lacspace-leads
2
2
 
3
- **Free, open-source local-business lead finder.** Name a city, area and business type — it drives a real browser over Google Maps and collects each listing's **name, category, rating, reviews, address, phone, website, email and social links**, then exports to **JSON, NDJSON, CSV or Excel**. No API keys, no paid services.
3
+ **Free, open-source local-business lead finder.** Name a city, area and business type — it drives a real browser over Google Maps and collects each listing's **name, category & tags, rating, reviews, price level, opening hours, open-now/business status, address, phone, website, email and social links**, then exports to **JSON, NDJSON, CSV or Excel**. No API keys, no paid services.
4
4
 
5
5
  ```bash
6
6
  npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
@@ -19,7 +19,11 @@ That opens a browser, searches Maps for *"restaurants in Baneshwor, Kathmandu"*,
19
19
  - **CRM-ready data** — normalise phones to **E.164** (`--country NP` → `+9779…`), and tidy website URLs (unwrap Google redirects, strip `utm_*`/`fbclid`).
20
20
  - **Any format** — JSON, NDJSON, CSV or Excel; write to a file or pipe to `stdout` with `-o -`.
21
21
  - **Pick your fields** — choose exactly what you collect, or a ready-made **preset** (`--preset outreach`).
22
- - **Sort & filter** — `--sort reviews --desc`, `--min-rating`, `--has-valid-email`, and more.
22
+ - **Sort & filter** — `--sort reviews --desc`, `--min-rating`, `--open-now`, `--price 2`, `--category coffee`, `--business-status operational`, `--has-valid-email`, and more.
23
+ - **Resumable sweeps** — `--resume` checkpoints a long city sweep after every search, so a crash or CAPTCHA doesn't lose the run — just re-run and it continues.
24
+ - **Run summary** — `--summary` prints rating bands, contactability (% with phone/website/email) and the top categories after a run.
25
+ - **Cross-file dedupe** — `--dedupe-across master.csv` writes only leads you don't already have.
26
+ - **Pipe to enrich** — `--enrich-out sites.ndjson` (or the `pipeToEnrich` hook) hands websites straight to [`lacspace-enrich`](https://www.npmjs.com/package/lacspace-enrich) for full company profiles — no hard dependency.
23
27
  - **Robust & polite** — `--proxy`, `--retries`, `--jitter`, per-listing delays and a permission-first prompt before it opens a browser.
24
28
  - **Library too** — `import { searchLeads, searchLeadsBatch, searchLeadsDetailed } from "lacspace-leads"`.
25
29
 
@@ -45,13 +49,22 @@ npx lacspace-leads [type] [options]
45
49
  | `-q, --query <text>` | Raw query, used verbatim (overrides city/area/type) |
46
50
  | `--near <lat,lng>` | Centre the search on a coordinate (radius search) |
47
51
  | `--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` |
52
+ | `--fields <list>` | Columns: `name,category,categories,rating,reviews,priceLevel,businessStatus,claimed,openNow,address,phone,website,email,facebook,instagram,whatsapp,linkedin,twitter,youtube,tiktok,telegram,emailStatus,plusCode,latitude,longitude,distanceKm,hours,mapsUrl` |
49
53
  | `--preset <name>` | Field bundle: `minimal` · `outreach` · `contact` · `geo` · `full` · `everything` |
50
54
  | `-f, --format <fmt>` | `json` · `ndjson` · `csv` · `xlsx` (default `json`) |
51
55
  | `-o, --out <file>` | Output file, or `-` for **stdout** (default: a slug + date) |
52
56
  | `--append` | Merge into an existing output file — **accumulate + dedupe** across runs |
57
+ | `--dedupe-across <file>` | Drop leads already present in an existing master file before writing (only new ones) |
58
+ | `--summary` | Print run stats after collecting — rating bands, % with phone/website/email, top categories |
59
+ | `--enrich-out <file>` | Also write `{ name, website, domain }` NDJSON, ready to feed [`lacspace-enrich`](https://www.npmjs.com/package/lacspace-enrich) |
60
+ | `--resume` | Resume an interrupted **multi-search sweep** from its checkpoint file (`<out>.checkpoint.json`) |
53
61
  | `--sheet <name>` | Excel sheet name (default `Leads`) |
54
- | `-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 |
55
68
  | `--total <n>` | Cap the merged result when sweeping several searches |
56
69
  | `--no-details` | Skip opening each listing — names + Maps URLs only, much faster |
57
70
  | `--sort <key>` | `rating` · `reviews` · `name` · `priceLevel` · `distance` (missing values last) |
@@ -94,11 +107,38 @@ npx lacspace-leads [type] [options]
94
107
  | `--has-email` | Only leads with an email (implies `--emails`) |
95
108
  | `--has-valid-email` | Only leads whose email passed **MX verification** (implies `--verify-emails`) |
96
109
  | `--has-contact` | Only leads reachable by **phone, email or website** |
110
+ | `--open-now` | Only leads **open at scrape time** (from the hours widget) |
111
+ | `--price <1-4>` | Only leads at this price tier — `$`=1 … `$$$$`=4 |
112
+ | `--category <text>` | Only leads whose **primary or tag category** contains this text (case-insensitive) |
113
+ | `--business-status <s>` | Only this status — `operational` · `closed` · `temporarily-closed` |
97
114
  | `--name-exclude <list>` | Drop leads whose name contains any of these terms (comma-separated) |
98
115
  | `--dedupe <key>` | `website` · `phone` · `name` · `smart` · `none` (default `website`; `--append` uses `smart` = website→phone→name) |
99
116
 
100
117
  Run with no arguments for an interactive walkthrough.
101
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
+
102
142
  ## Sweep a whole city
103
143
 
104
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:
@@ -111,6 +151,15 @@ npx lacspace-leads "coffee shop" \
111
151
 
112
152
  # Two business types at once, capped at 100 unique leads total
113
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
114
163
  ```
115
164
 
116
165
  ## Search by radius
@@ -141,6 +190,8 @@ Skip spelling out `--fields` with a ready-made bundle:
141
190
  npx lacspace-leads salons --city Pokhara --preset outreach --country NP -f csv
142
191
  ```
143
192
 
193
+ > **Honest note on the extra listing fields.** `priceLevel`, `businessStatus`, `claimed`, `openNow`, `hours` and category `categories` are read from whatever Google renders on the listing panel — they're **best-effort** and simply left blank when Maps doesn't show them (`claimed`/`businessStatus` fall back to `true`/`operational` for a normally-loaded listing). The parsing itself is pure and unit-tested, but Google's markup shifts over time; treat these as helpful hints, not guarantees. `--open-now` reflects the state *at scrape time*.
194
+
144
195
  ## Verify emails & build a master list
145
196
 
146
197
  Chase down deliverable contacts and accumulate them over time:
@@ -183,6 +234,75 @@ npx lacspace-leads --config campaign.json
183
234
 
184
235
  Every search is run, merged and de-duplicated; the shared options, filters and output settings apply across the whole campaign. Programmatically: `runConfig(config)`.
185
236
 
237
+ ## Resume a long sweep (`--resume`)
238
+
239
+ Sweeping a whole city one neighbourhood at a time can take a while, and Google may throw a CAPTCHA halfway through. Add `--resume` and every completed sub-search is checkpointed to `<out>.checkpoint.json` — the leads gathered so far plus which searches are done. Re-run the exact same command and it **skips the areas already collected** and picks up where it stopped; when the sweep finishes cleanly the checkpoint is deleted.
240
+
241
+ ```bash
242
+ npx lacspace-leads cafes --city Kathmandu \
243
+ --area "Thamel,Baneshwor,Patan,Jhamsikhel,Boudha,Kirtipur" \
244
+ --resume -o sweep.csv
245
+ # crashes after Patan? just run it again — Thamel/Baneshwor/Patan are skipped.
246
+ ```
247
+
248
+ `--resume` applies to multi-search sweeps and `--config` campaigns (it's a no-op for a single search). The checkpoint key normalises case/whitespace, so the same area sweep always lines up.
249
+
250
+ ## Run summary (`--summary`)
251
+
252
+ Add `--summary` for a quick read on what you got — coverage, rating bands and the top categories — printed after the run:
253
+
254
+ ```bash
255
+ npx lacspace-leads bars --city Pokhara --open-now --price 2 --summary
256
+ ```
257
+
258
+ ```text
259
+ Summary — 43 leads
260
+ contactable: 88% phone · 51% website · 23% email (12 with a social link)
261
+ avg rating: 4.3 ★
262
+ rating bands: 4.5+ ×14 · 4.0–4.4 ×19 · 3.0–3.9 ×8 · unrated ×2
263
+ open now: 43
264
+ top categories: Bar (18), Restaurant (11), Pub (7), Night club (4)
265
+ ```
266
+
267
+ In code, `summarize(leads)` returns the structured stats and `formatSummary(summary)` renders the text.
268
+
269
+ ## Only what's new (`--dedupe-across`)
270
+
271
+ Already have a master list and only want the businesses you *don't* have yet? Point `--dedupe-across` at it and any lead whose identity (by `--dedupe` key, default `smart`) is already in that file is dropped **before** writing:
272
+
273
+ ```bash
274
+ # Collect gyms, but write only the ones missing from master.csv, then append them
275
+ npx lacspace-leads gyms --city Lalitpur --dedupe-across master.csv -o new.csv --append
276
+ ```
277
+
278
+ This differs from `--append` (which merges everything into one file): `--dedupe-across` filters *this run* against a **separate** reference file, so `new.csv` holds only genuinely new prospects. In code: `subtractLeads(leads, master, "smart")`.
279
+
280
+ ## Pipe into `lacspace-enrich`
281
+
282
+ lacspace-leads finds businesses and their websites; its sibling [`lacspace-enrich`](https://www.npmjs.com/package/lacspace-enrich) turns a domain into a full company + contact + tech-stack profile. Bridge the two **without a hard dependency**:
283
+
284
+ ```bash
285
+ # Write one { name, website, domain } object per line, ready for enrich
286
+ npx lacspace-leads clinics --city Pokhara --enrich-out sites.ndjson -o clinics.csv
287
+ ```
288
+
289
+ Or stream websites into your own enrich pipeline as leads are found, using the `onLead`-compatible `pipeToEnrich` factory:
290
+
291
+ ```ts
292
+ import { searchLeads, pipeToEnrich, leadsToEnrichInput } from "lacspace-leads";
293
+ // import { enrich } from "lacspace-enrich"; // install separately, optional
294
+
295
+ const queue: { website: string; domain?: string }[] = [];
296
+ const leads = await searchLeads({
297
+ type: "cafes", city: "Kathmandu",
298
+ onLead: pipeToEnrich((input) => queue.push(input)), // { name, website, domain }
299
+ });
300
+
301
+ // or after the fact — one unique input per domain:
302
+ const inputs = leadsToEnrichInput(leads);
303
+ // for (const { domain } of inputs) await enrich(domain);
304
+ ```
305
+
186
306
  ## Convert your leads to any format
187
307
 
188
308
  Collected leads as JSON and now need them in Excel? A general converter is built in — it reads and writes **JSON · NDJSON · CSV · Excel** in any direction, and works on *any* tabular file, not just leads:
@@ -318,7 +438,11 @@ const leads = await searchLeadsBatch(
318
438
  | `enrichContacts(website)` / `extractEmails` / `extractSocials` | Website enrichment, on tap. |
319
439
  | `cleanWebsite` / `normalizePhone` / `sortLeads` | Pure data-cleaning helpers (unit-tested). |
320
440
  | `haversineMeters` / `parseLatLngPair` / `parseDistance` | Pure geo helpers for radius search. |
321
- | `filterLeads` / `dedupeLeads` | Pure post-processing over any `Lead[]`. |
441
+ | `filterLeads` / `dedupeLeads` / `subtractLeads` | Pure post-processing — filter, in-list dedupe, and cross-file dedupe. |
442
+ | `summarize(leads)` / `formatSummary(s)` | Rating bands, contact coverage %, top categories — the `--summary` engine. |
443
+ | `parsePriceLevel` / `priceLevelValue` / `parseBusinessStatus` / `parseClaimed` / `parseOpenNow` / `parseCategoryTags` | Pure field parsers (unit-tested against HTML/aria snippets). |
444
+ | `pipeToEnrich(handler)` / `leadsToEnrichInput` / `leadDomains` / `leadDomain` | Bridge collected leads to `lacspace-enrich` (no hard dep). |
445
+ | `queryKey` / `pendingQueries` / `recordQuery` / `loadCheckpoint` / `saveCheckpoint` / `clearCheckpoint` | Resume/checkpoint primitives; pass `skip`/`seedLeads`/`onQueryDone` to `searchLeadsBatch`. |
322
446
  | `expandQueries` / `resolvePreset` / `FIELD_PRESETS` | Batch expansion + field presets. |
323
447
  | `composeQuery` / `mapsSearchUrl` / `normalizeFields` / `defaultFilename` | Query + helper utilities. |
324
448