lacspace-leads 0.1.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 ADDED
@@ -0,0 +1,98 @@
1
+ # lacspace-leads
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** and more, then exports to **JSON, CSV or Excel**. No API keys, no paid services.
4
+
5
+ ```bash
6
+ npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
7
+ ```
8
+
9
+ That opens a browser, searches Maps for *"restaurants in Baneshwor, Kathmandu"*, reads the listings, and writes `restaurants-in-baneshwor-kathmandu-YYYY-MM-DD.xlsx`.
10
+
11
+ ## Why it's different
12
+
13
+ - **Free & keyless** — uses a real browser (via [Playwright](https://playwright.dev)), not a paid Places API.
14
+ - **Any format** — JSON, CSV or Excel out of the box (Excel/CSV via `@lacspace/xlsx` + `@lacspace/csv`).
15
+ - **Pick your fields** — only collect what you need.
16
+ - **Permission-first** — it tells you what it's about to do and asks before opening a browser.
17
+ - **Library too** — `import { searchLeads } from "lacspace-leads"`.
18
+
19
+ ## Install
20
+
21
+ Needs **Node 20+** and a Chromium-based browser. It uses your installed **Google Chrome** or **Microsoft Edge** automatically; if you have neither, run:
22
+
23
+ ```bash
24
+ npx playwright install chromium
25
+ ```
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ npx lacspace-leads [type] [options]
31
+ ```
32
+
33
+ | Option | Meaning |
34
+ | --- | --- |
35
+ | `-t, --type <text>` | Business type / keyword, e.g. `"dental clinic"` |
36
+ | `--city <text>` | City, e.g. `Kathmandu` |
37
+ | `--area <text>` | Area / neighbourhood, e.g. `Baneshwor` |
38
+ | `-q, --query <text>` | Raw query, used verbatim (overrides city/area/type) |
39
+ | `--fields <list>` | Comma list: `name,category,rating,reviews,address,phone,website,plusCode,hours,mapsUrl` |
40
+ | `-f, --format <fmt>` | `json` · `csv` · `xlsx` (default `json`) |
41
+ | `-o, --out <file>` | Output file (default: a slug + date) |
42
+ | `-n, --limit <n>` | Max listings to collect (default `60`) |
43
+ | `--no-details` | Skip opening each listing — names + Maps URLs only, much faster |
44
+ | `--delay <ms>` | Pause between listings (default `700`) |
45
+ | `--headless` | Run the browser without a visible window |
46
+ | `-y, --yes` | Skip prompts and the browser-open confirmation |
47
+
48
+ Run with no arguments for an interactive walkthrough.
49
+
50
+ ### Examples
51
+
52
+ ```bash
53
+ # Excel of restaurants in a specific area
54
+ npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
55
+
56
+ # Just the essentials for outreach, as CSV
57
+ npx lacspace-leads --type "dental clinic" --city Pokhara --fields name,phone,website -f csv -n 40
58
+
59
+ # Fast name + URL sweep, no per-listing opening
60
+ npx lacspace-leads gyms --city Lalitpur --no-details -n 100
61
+ ```
62
+
63
+ ## Library
64
+
65
+ ```ts
66
+ import { searchLeads, serialize } from "lacspace-leads";
67
+ import { writeFileSync } from "node:fs";
68
+
69
+ const leads = await searchLeads({
70
+ city: "Kathmandu",
71
+ area: "Baneshwor",
72
+ type: "restaurants",
73
+ fields: ["name", "phone", "website", "rating"],
74
+ limit: 40,
75
+ headless: true,
76
+ onProgress: (m) => console.log(m),
77
+ });
78
+
79
+ const { data, binary } = serialize(leads, "xlsx");
80
+ writeFileSync("leads.xlsx", binary ? Buffer.from(data as Uint8Array) : data);
81
+ ```
82
+
83
+ | Export | Purpose |
84
+ | --- | --- |
85
+ | `searchLeads(options)` | Run the search, resolve to `Lead[]`. |
86
+ | `serialize(leads, format, fields?)` | Serialize to `{ data, binary }` for `json` / `csv` / `xlsx`. |
87
+ | `toRows(leads, fields?)` | Header-keyed rows, for your own exporter. |
88
+ | `composeQuery` / `mapsSearchUrl` / `normalizeFields` / `defaultFilename` | Query + helper utilities. |
89
+
90
+ ## Please use it responsibly
91
+
92
+ Automated scraping of Google Maps is **against Google's Terms of Service**, and heavy use can trigger CAPTCHAs or temporary blocks. This tool is meant for **small, human-scale** collection of **public business information**. You are responsible for how you use it: keep volumes modest, add delays, respect local data-protection law (and don't collect or contact people in ways that break it), and don't resell scraped data as your own. If you need high volume or guaranteed reliability, use the official Google Places API.
93
+
94
+ ## Licence
95
+
96
+ Free under the **[Lacspace Free Licence](https://developer.lacspace.com/licenses/lacspace-free-1.0)** — permissive freedoms, personal and commercial use.
97
+
98
+ Part of the [Lacspace developer platform](https://developer.lacspace.com).
package/dist/cli.js ADDED
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { writeFileSync } from "fs";
5
+ import { resolve } from "path";
6
+ import { createInterface } from "readline/promises";
7
+ import { stdin, stdout, stderr, argv, exit } from "process";
8
+
9
+ // src/scrape.ts
10
+ import { chromium } from "playwright-core";
11
+
12
+ // src/types.ts
13
+ var ALL_FIELDS = [
14
+ "name",
15
+ "category",
16
+ "rating",
17
+ "reviews",
18
+ "address",
19
+ "phone",
20
+ "website",
21
+ "plusCode",
22
+ "hours",
23
+ "mapsUrl"
24
+ ];
25
+
26
+ // src/query.ts
27
+ function composeQuery(opts) {
28
+ if (opts.query && opts.query.trim()) return opts.query.trim();
29
+ const type = (opts.type ?? "").trim();
30
+ const where = [opts.area, opts.city].map((s) => (s ?? "").trim()).filter(Boolean).join(", ");
31
+ if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
32
+ return where ? `${type} in ${where}` : type;
33
+ }
34
+ function mapsSearchUrl(query) {
35
+ return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
36
+ }
37
+ function normalizeFields(input) {
38
+ if (!input) return [...ALL_FIELDS];
39
+ const raw = typeof input === "string" ? input.split(",") : input;
40
+ const want = new Set(
41
+ raw.map((f) => String(f).trim().toLowerCase()).filter(Boolean)
42
+ );
43
+ const picked = ALL_FIELDS.filter((f) => want.has(f.toLowerCase()));
44
+ return picked.length > 0 ? picked : [...ALL_FIELDS];
45
+ }
46
+ function defaultFilename(query, format) {
47
+ const slug = query.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "leads";
48
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
49
+ return `${slug}-${stamp}.${format}`;
50
+ }
51
+
52
+ // src/scrape.ts
53
+ var LeadsError = class extends Error {
54
+ code;
55
+ cause;
56
+ constructor(message, code, cause) {
57
+ super(message);
58
+ this.name = "LeadsError";
59
+ if (code !== void 0) this.code = code;
60
+ if (cause !== void 0) this.cause = cause;
61
+ }
62
+ };
63
+ function stripPrefix(value, prefix) {
64
+ if (!value) return void 0;
65
+ const v = value.startsWith(prefix) ? value.slice(prefix.length) : value;
66
+ const trimmed = v.trim();
67
+ return trimmed || void 0;
68
+ }
69
+ function parseReviewCount(label) {
70
+ if (!label) return void 0;
71
+ const digits = label.replace(/[^0-9]/g, "");
72
+ if (!digits) return void 0;
73
+ const n = parseInt(digits, 10);
74
+ return Number.isFinite(n) ? n : void 0;
75
+ }
76
+ function parseRating(text) {
77
+ if (!text) return void 0;
78
+ const m = text.replace(",", ".").match(/\d+(\.\d+)?/);
79
+ if (!m) return void 0;
80
+ const n = parseFloat(m[0]);
81
+ return Number.isFinite(n) && n >= 0 && n <= 5 ? n : void 0;
82
+ }
83
+ async function launchBrowser(headless) {
84
+ let lastErr;
85
+ for (const channel of ["chrome", "msedge"]) {
86
+ try {
87
+ return await chromium.launch({ headless, channel });
88
+ } catch (e) {
89
+ lastErr = e;
90
+ }
91
+ }
92
+ try {
93
+ return await chromium.launch({ headless });
94
+ } catch (e) {
95
+ lastErr = e;
96
+ }
97
+ throw new LeadsError(
98
+ "Could not launch a browser. Install Google Chrome (or Microsoft Edge), or run `npx playwright install chromium`.",
99
+ "NO_BROWSER",
100
+ lastErr
101
+ );
102
+ }
103
+ async function dismissConsent(page) {
104
+ try {
105
+ const btn = page.locator(
106
+ 'button[aria-label*="Accept all"], button[aria-label*="Accept the use"], form[action*="consent"] button, button:has-text("Accept all")'
107
+ ).first();
108
+ if (await btn.count()) {
109
+ await btn.click({ timeout: 3e3 }).catch(() => {
110
+ });
111
+ await page.waitForTimeout(500);
112
+ }
113
+ } catch {
114
+ }
115
+ }
116
+ async function loadResults(page, limit, delayMs, onProgress, signal) {
117
+ const feed = page.locator('div[role="feed"]');
118
+ await feed.waitFor({ timeout: 15e3 }).catch(() => {
119
+ });
120
+ let prev = 0;
121
+ let stable = 0;
122
+ for (let i = 0; i < 60; i++) {
123
+ if (signal?.aborted) return;
124
+ const count = await page.locator("a.hfpxzc").count();
125
+ onProgress?.(`loaded ${count} listing${count === 1 ? "" : "s"}\u2026`);
126
+ if (count >= limit) return;
127
+ if (await page.locator('span:has-text("reached the end")').count()) return;
128
+ if (count === prev) {
129
+ if (++stable >= 3) return;
130
+ } else {
131
+ stable = 0;
132
+ }
133
+ prev = count;
134
+ await feed.evaluate((el) => el.scrollBy(0, el.scrollHeight)).catch(() => {
135
+ });
136
+ await page.waitForTimeout(Math.max(400, delayMs));
137
+ }
138
+ }
139
+ async function extractDetail(page, fields, fallbackName) {
140
+ const lead = {};
141
+ const text = async (sel) => {
142
+ const loc = page.locator(sel).first();
143
+ if (await loc.count()) {
144
+ const t = (await loc.innerText().catch(() => "")).trim();
145
+ return t || void 0;
146
+ }
147
+ return void 0;
148
+ };
149
+ const aria = async (sel) => {
150
+ const loc = page.locator(sel).first();
151
+ if (await loc.count()) {
152
+ return await loc.getAttribute("aria-label").catch(() => null) ?? void 0;
153
+ }
154
+ return void 0;
155
+ };
156
+ if (fields.has("name")) lead.name = await text("h1.DUwDvf") ?? fallbackName;
157
+ else if (fallbackName) lead.name = fallbackName;
158
+ if (fields.has("category")) {
159
+ lead.category = await text('button[jsaction*="category"]') ?? void 0;
160
+ }
161
+ if (fields.has("rating") || fields.has("reviews")) {
162
+ const box = page.locator("div.F7nice").first();
163
+ if (await box.count()) {
164
+ if (fields.has("rating")) {
165
+ const rt = await box.locator('span[aria-hidden="true"]').first().innerText().catch(() => "");
166
+ lead.rating = parseRating(rt);
167
+ }
168
+ if (fields.has("reviews")) {
169
+ const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
170
+ lead.reviews = parseReviewCount(rv);
171
+ }
172
+ }
173
+ }
174
+ if (fields.has("address")) {
175
+ lead.address = stripPrefix(await aria('button[data-item-id="address"]'), "Address:");
176
+ }
177
+ if (fields.has("phone")) {
178
+ lead.phone = stripPrefix(await aria('button[data-item-id^="phone"]'), "Phone:");
179
+ }
180
+ if (fields.has("website")) {
181
+ const site = page.locator('a[data-item-id="authority"]').first();
182
+ if (await site.count()) {
183
+ lead.website = await site.getAttribute("href").catch(() => null) ?? void 0;
184
+ }
185
+ }
186
+ if (fields.has("plusCode")) {
187
+ lead.plusCode = stripPrefix(await aria('button[data-item-id="oloc"]'), "Plus code:");
188
+ }
189
+ if (fields.has("hours")) {
190
+ lead.hours = stripPrefix(await aria('div[jsaction*="openhours"]'), "") ?? await text('div[jsaction*="openhours"]');
191
+ }
192
+ if (fields.has("mapsUrl")) lead.mapsUrl = page.url();
193
+ return lead;
194
+ }
195
+ async function scrapeLeads(opts) {
196
+ const query = composeQuery(opts);
197
+ const limit = Math.max(1, Math.trunc(opts.limit ?? 60));
198
+ const fields = new Set(normalizeFields(opts.fields ?? ALL_FIELDS));
199
+ const wantDetails = opts.details ?? true;
200
+ const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
201
+ const headless = opts.headless ?? false;
202
+ const onProgress = opts.onProgress;
203
+ const signal = opts.signal;
204
+ onProgress?.(`searching Google Maps for "${query}"\u2026`);
205
+ const browser = await launchBrowser(headless);
206
+ try {
207
+ const context = await browser.newContext({
208
+ viewport: { width: 1280, height: 900 },
209
+ locale: "en-US"
210
+ });
211
+ const page = await context.newPage();
212
+ await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
213
+ await dismissConsent(page);
214
+ await loadResults(page, limit, delayMs, onProgress, signal);
215
+ if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
216
+ const cards = await page.locator("a.hfpxzc").evaluateAll(
217
+ (els, lim) => els.slice(0, lim).map((a) => ({
218
+ href: a.href,
219
+ name: a.getAttribute("aria-label") ?? void 0
220
+ })),
221
+ limit
222
+ ).catch(() => []);
223
+ if (cards.length === 0) {
224
+ onProgress?.("no listings found (Google may have shown a CAPTCHA or an empty result).");
225
+ return [];
226
+ }
227
+ if (!wantDetails) {
228
+ return cards.map((c2) => {
229
+ const lead = {};
230
+ if (fields.has("name")) lead.name = c2.name;
231
+ if (fields.has("mapsUrl")) lead.mapsUrl = c2.href;
232
+ return lead;
233
+ });
234
+ }
235
+ const leads = [];
236
+ for (let i = 0; i < cards.length; i++) {
237
+ if (signal?.aborted) break;
238
+ const card = cards[i];
239
+ onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
240
+ try {
241
+ await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
242
+ await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
243
+ });
244
+ leads.push(await extractDetail(page, fields, card.name));
245
+ } catch {
246
+ if (card.name && (fields.has("name") || fields.has("mapsUrl"))) {
247
+ const partial = {};
248
+ if (fields.has("name")) partial.name = card.name;
249
+ if (fields.has("mapsUrl")) partial.mapsUrl = card.href;
250
+ leads.push(partial);
251
+ }
252
+ }
253
+ if (delayMs) await page.waitForTimeout(delayMs);
254
+ }
255
+ onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
256
+ return leads;
257
+ } finally {
258
+ await browser.close().catch(() => {
259
+ });
260
+ }
261
+ }
262
+
263
+ // src/export.ts
264
+ import { stringify as csvStringify } from "@lacspace/csv";
265
+ import { jsonToXlsx } from "@lacspace/xlsx";
266
+ var HEADERS = {
267
+ name: "Name",
268
+ category: "Category",
269
+ rating: "Rating",
270
+ reviews: "Reviews",
271
+ address: "Address",
272
+ phone: "Phone",
273
+ website: "Website",
274
+ plusCode: "Plus Code",
275
+ hours: "Hours",
276
+ mapsUrl: "Maps URL"
277
+ };
278
+ function toRows(leads, fields = ALL_FIELDS) {
279
+ return leads.map((lead) => {
280
+ const row = {};
281
+ for (const f of fields) {
282
+ const v = lead[f];
283
+ row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
284
+ }
285
+ return row;
286
+ });
287
+ }
288
+ function serialize(leads, format, fields = ALL_FIELDS) {
289
+ if (format === "json") {
290
+ const picked = leads.map((lead) => {
291
+ const o = {};
292
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
293
+ return o;
294
+ });
295
+ return { data: JSON.stringify(picked, null, 2), binary: false };
296
+ }
297
+ const rows = toRows(leads, fields);
298
+ if (format === "csv") {
299
+ const csvRows = rows;
300
+ return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
301
+ }
302
+ return { data: jsonToXlsx(rows, { sheetName: "Leads" }), binary: true };
303
+ }
304
+
305
+ // src/cli.ts
306
+ var C = {
307
+ reset: "\x1B[0m",
308
+ bold: "\x1B[1m",
309
+ dim: "\x1B[2m",
310
+ green: "\x1B[32m",
311
+ cyan: "\x1B[36m",
312
+ yellow: "\x1B[33m",
313
+ red: "\x1B[31m",
314
+ magenta: "\x1B[35m"
315
+ };
316
+ var c = (k, s) => `${C[k]}${s}${C.reset}`;
317
+ var log = (s = "") => void stderr.write(s + "\n");
318
+ function parseArgs(list) {
319
+ const a = { format: "json", limit: 60, headless: false, details: true, delay: 700, yes: false, help: false };
320
+ for (let i = 0; i < list.length; i++) {
321
+ const arg = list[i];
322
+ const next = () => list[++i] ?? "";
323
+ if (arg === "--city") a.city = next();
324
+ else if (arg === "--area") a.area = next();
325
+ else if (arg === "-t" || arg === "--type") a.type = next();
326
+ else if (arg === "-q" || arg === "--query") a.query = next();
327
+ else if (arg === "--fields") a.fields = next();
328
+ else if (arg === "-f" || arg === "--format") a.format = next();
329
+ else if (arg === "-o" || arg === "--out") a.out = next();
330
+ else if (arg === "-n" || arg === "--limit") a.limit = parseInt(next(), 10) || a.limit;
331
+ else if (arg === "--headless") a.headless = true;
332
+ else if (arg === "--no-details") a.details = false;
333
+ else if (arg === "--delay") a.delay = parseInt(next(), 10) || a.delay;
334
+ else if (arg === "-y" || arg === "--yes") a.yes = true;
335
+ else if (arg === "-h" || arg === "--help") a.help = true;
336
+ else if (arg.startsWith("--")) {
337
+ } else if (!a.type && !a.query) a.type = arg;
338
+ }
339
+ return a;
340
+ }
341
+ var HELP = `
342
+ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 free local-business lead finder (Google Maps, no API keys)")}
343
+
344
+ ${c("bold", "Usage")}
345
+ npx lacspace-leads [type] [options]
346
+
347
+ ${c("bold", "Options")}
348
+ -t, --type <text> Business type / keyword, e.g. "restaurants"
349
+ --city <text> City, e.g. "Kathmandu"
350
+ --area <text> Area / neighbourhood, e.g. "Baneshwor"
351
+ -q, --query <text> Use a raw query verbatim (overrides city/area/type)
352
+ --fields <list> Comma list: ${ALL_FIELDS.join(",")}
353
+ -f, --format <fmt> json | csv | xlsx (default json)
354
+ -o, --out <file> Output file (default: a slug + date)
355
+ -n, --limit <n> Max listings to collect (default 60)
356
+ --no-details Skip opening each listing (names + URLs only, fast)
357
+ --delay <ms> Pause between listings (default 700)
358
+ --headless Run the browser without a window
359
+ -y, --yes Skip prompts + the browser-open confirmation
360
+ -h, --help Show this help
361
+
362
+ ${c("bold", "Examples")}
363
+ npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
364
+ npx lacspace-leads --type "dental clinic" --city Pokhara --fields name,phone,website -n 40
365
+
366
+ ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
367
+ ${c("dim", "Service and local data-protection law, and only use public business data")}
368
+ ${c("dim", "you have a lawful basis to use.")}
369
+ `;
370
+ async function prompt(q, fallback = "") {
371
+ const rl = createInterface({ input: stdin, output: stderr });
372
+ try {
373
+ const ans = (await rl.question(q)).trim();
374
+ return ans || fallback;
375
+ } finally {
376
+ rl.close();
377
+ }
378
+ }
379
+ async function main() {
380
+ const args = parseArgs(argv.slice(2));
381
+ if (args.help) {
382
+ stdout.write(HELP + "\n");
383
+ return;
384
+ }
385
+ log(`
386
+ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Maps \u2192 JSON/CSV/Excel, free")}
387
+ `);
388
+ if (!args.query && !args.type && !args.yes) {
389
+ args.type = await prompt(`${c("green", "?")} Business type ${c("dim", "(e.g. restaurants)")}: `);
390
+ args.city = args.city ?? await prompt(`${c("green", "?")} City ${c("dim", "(optional)")}: `);
391
+ args.area = args.area ?? await prompt(`${c("green", "?")} Area ${c("dim", "(optional)")}: `);
392
+ const fmt = await prompt(`${c("green", "?")} Format ${c("dim", "(json/csv/xlsx)")} ${c("dim", "[json]")}: `, "json");
393
+ if (fmt === "csv" || fmt === "xlsx" || fmt === "json") args.format = fmt;
394
+ const lim = await prompt(`${c("green", "?")} How many ${c("dim", "[60]")}: `, "60");
395
+ args.limit = parseInt(lim, 10) || args.limit;
396
+ }
397
+ let query;
398
+ try {
399
+ query = composeQuery(args);
400
+ } catch (err) {
401
+ log(c("red", `
402
+ \u2717 ${err.message}`));
403
+ exit(1);
404
+ return;
405
+ }
406
+ const fields = normalizeFields(args.fields);
407
+ const out = resolve(args.out ?? defaultFilename(query, args.format));
408
+ log(`
409
+ ${c("dim", "search")} ${c("bold", query)}`);
410
+ log(` ${c("dim", "fields")} ${fields.join(", ")}`);
411
+ log(` ${c("dim", "limit")} ${args.limit} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${out}`);
412
+ if (!args.yes) {
413
+ const ok = await prompt(`
414
+ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c("dim", "[y/N]")} `);
415
+ if (!/^y(es)?$/i.test(ok)) {
416
+ log(c("dim", "\n cancelled.\n"));
417
+ return;
418
+ }
419
+ }
420
+ log("");
421
+ const controller = new AbortController();
422
+ const onSig = () => {
423
+ controller.abort();
424
+ };
425
+ process.once("SIGINT", onSig);
426
+ const opts = {
427
+ type: args.type ?? "",
428
+ query: args.query ?? "",
429
+ city: args.city ?? "",
430
+ area: args.area ?? "",
431
+ limit: args.limit,
432
+ fields,
433
+ headless: args.headless,
434
+ details: args.details,
435
+ delayMs: args.delay,
436
+ signal: controller.signal,
437
+ onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
438
+ };
439
+ let leads;
440
+ try {
441
+ leads = await scrapeLeads(opts);
442
+ } catch (err) {
443
+ log(c("red", `
444
+ \u2717 ${err.message}`));
445
+ exit(1);
446
+ return;
447
+ } finally {
448
+ process.removeListener("SIGINT", onSig);
449
+ }
450
+ if (leads.length === 0) {
451
+ log(c("yellow", "\n No leads collected. Try a broader area, fewer fields, or a smaller --limit.\n"));
452
+ return;
453
+ }
454
+ const { data, binary } = serialize(leads, args.format, fields);
455
+ writeFileSync(out, binary ? Buffer.from(data) : data);
456
+ const withPhone = leads.filter((l) => l.phone).length;
457
+ const withSite = leads.filter((l) => l.website).length;
458
+ log(`
459
+ ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
460
+ log(` ${c("dim", `${withPhone} with a phone \xB7 ${withSite} with a website`)}
461
+ `);
462
+ }
463
+ main().catch((err) => {
464
+ log(c("red", `
465
+ \u2717 ${err instanceof Error ? err.message : String(err)}
466
+ `));
467
+ exit(1);
468
+ });