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/dist/lib.cjs ADDED
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/lib.ts
21
+ var lib_exports = {};
22
+ __export(lib_exports, {
23
+ ALL_FIELDS: () => ALL_FIELDS,
24
+ LeadsError: () => LeadsError,
25
+ composeQuery: () => composeQuery,
26
+ defaultFilename: () => defaultFilename,
27
+ mapsSearchUrl: () => mapsSearchUrl,
28
+ normalizeFields: () => normalizeFields,
29
+ parseRating: () => parseRating,
30
+ parseReviewCount: () => parseReviewCount,
31
+ scrapeLeads: () => scrapeLeads,
32
+ searchLeads: () => scrapeLeads,
33
+ serialize: () => serialize,
34
+ toRows: () => toRows
35
+ });
36
+ module.exports = __toCommonJS(lib_exports);
37
+
38
+ // src/scrape.ts
39
+ var import_playwright_core = require("playwright-core");
40
+
41
+ // src/types.ts
42
+ var ALL_FIELDS = [
43
+ "name",
44
+ "category",
45
+ "rating",
46
+ "reviews",
47
+ "address",
48
+ "phone",
49
+ "website",
50
+ "plusCode",
51
+ "hours",
52
+ "mapsUrl"
53
+ ];
54
+
55
+ // src/query.ts
56
+ function composeQuery(opts) {
57
+ if (opts.query && opts.query.trim()) return opts.query.trim();
58
+ const type = (opts.type ?? "").trim();
59
+ const where = [opts.area, opts.city].map((s) => (s ?? "").trim()).filter(Boolean).join(", ");
60
+ if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
61
+ return where ? `${type} in ${where}` : type;
62
+ }
63
+ function mapsSearchUrl(query) {
64
+ return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
65
+ }
66
+ function normalizeFields(input) {
67
+ if (!input) return [...ALL_FIELDS];
68
+ const raw = typeof input === "string" ? input.split(",") : input;
69
+ const want = new Set(
70
+ raw.map((f) => String(f).trim().toLowerCase()).filter(Boolean)
71
+ );
72
+ const picked = ALL_FIELDS.filter((f) => want.has(f.toLowerCase()));
73
+ return picked.length > 0 ? picked : [...ALL_FIELDS];
74
+ }
75
+ function defaultFilename(query, format) {
76
+ const slug = query.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "leads";
77
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
78
+ return `${slug}-${stamp}.${format}`;
79
+ }
80
+
81
+ // src/scrape.ts
82
+ var LeadsError = class extends Error {
83
+ code;
84
+ cause;
85
+ constructor(message, code, cause) {
86
+ super(message);
87
+ this.name = "LeadsError";
88
+ if (code !== void 0) this.code = code;
89
+ if (cause !== void 0) this.cause = cause;
90
+ }
91
+ };
92
+ function stripPrefix(value, prefix) {
93
+ if (!value) return void 0;
94
+ const v = value.startsWith(prefix) ? value.slice(prefix.length) : value;
95
+ const trimmed = v.trim();
96
+ return trimmed || void 0;
97
+ }
98
+ function parseReviewCount(label) {
99
+ if (!label) return void 0;
100
+ const digits = label.replace(/[^0-9]/g, "");
101
+ if (!digits) return void 0;
102
+ const n = parseInt(digits, 10);
103
+ return Number.isFinite(n) ? n : void 0;
104
+ }
105
+ function parseRating(text) {
106
+ if (!text) return void 0;
107
+ const m = text.replace(",", ".").match(/\d+(\.\d+)?/);
108
+ if (!m) return void 0;
109
+ const n = parseFloat(m[0]);
110
+ return Number.isFinite(n) && n >= 0 && n <= 5 ? n : void 0;
111
+ }
112
+ async function launchBrowser(headless) {
113
+ let lastErr;
114
+ for (const channel of ["chrome", "msedge"]) {
115
+ try {
116
+ return await import_playwright_core.chromium.launch({ headless, channel });
117
+ } catch (e) {
118
+ lastErr = e;
119
+ }
120
+ }
121
+ try {
122
+ return await import_playwright_core.chromium.launch({ headless });
123
+ } catch (e) {
124
+ lastErr = e;
125
+ }
126
+ throw new LeadsError(
127
+ "Could not launch a browser. Install Google Chrome (or Microsoft Edge), or run `npx playwright install chromium`.",
128
+ "NO_BROWSER",
129
+ lastErr
130
+ );
131
+ }
132
+ async function dismissConsent(page) {
133
+ try {
134
+ const btn = page.locator(
135
+ 'button[aria-label*="Accept all"], button[aria-label*="Accept the use"], form[action*="consent"] button, button:has-text("Accept all")'
136
+ ).first();
137
+ if (await btn.count()) {
138
+ await btn.click({ timeout: 3e3 }).catch(() => {
139
+ });
140
+ await page.waitForTimeout(500);
141
+ }
142
+ } catch {
143
+ }
144
+ }
145
+ async function loadResults(page, limit, delayMs, onProgress, signal) {
146
+ const feed = page.locator('div[role="feed"]');
147
+ await feed.waitFor({ timeout: 15e3 }).catch(() => {
148
+ });
149
+ let prev = 0;
150
+ let stable = 0;
151
+ for (let i = 0; i < 60; i++) {
152
+ if (signal?.aborted) return;
153
+ const count = await page.locator("a.hfpxzc").count();
154
+ onProgress?.(`loaded ${count} listing${count === 1 ? "" : "s"}\u2026`);
155
+ if (count >= limit) return;
156
+ if (await page.locator('span:has-text("reached the end")').count()) return;
157
+ if (count === prev) {
158
+ if (++stable >= 3) return;
159
+ } else {
160
+ stable = 0;
161
+ }
162
+ prev = count;
163
+ await feed.evaluate((el) => el.scrollBy(0, el.scrollHeight)).catch(() => {
164
+ });
165
+ await page.waitForTimeout(Math.max(400, delayMs));
166
+ }
167
+ }
168
+ async function extractDetail(page, fields, fallbackName) {
169
+ const lead = {};
170
+ const text = async (sel) => {
171
+ const loc = page.locator(sel).first();
172
+ if (await loc.count()) {
173
+ const t = (await loc.innerText().catch(() => "")).trim();
174
+ return t || void 0;
175
+ }
176
+ return void 0;
177
+ };
178
+ const aria = async (sel) => {
179
+ const loc = page.locator(sel).first();
180
+ if (await loc.count()) {
181
+ return await loc.getAttribute("aria-label").catch(() => null) ?? void 0;
182
+ }
183
+ return void 0;
184
+ };
185
+ if (fields.has("name")) lead.name = await text("h1.DUwDvf") ?? fallbackName;
186
+ else if (fallbackName) lead.name = fallbackName;
187
+ if (fields.has("category")) {
188
+ lead.category = await text('button[jsaction*="category"]') ?? void 0;
189
+ }
190
+ if (fields.has("rating") || fields.has("reviews")) {
191
+ const box = page.locator("div.F7nice").first();
192
+ if (await box.count()) {
193
+ if (fields.has("rating")) {
194
+ const rt = await box.locator('span[aria-hidden="true"]').first().innerText().catch(() => "");
195
+ lead.rating = parseRating(rt);
196
+ }
197
+ if (fields.has("reviews")) {
198
+ const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
199
+ lead.reviews = parseReviewCount(rv);
200
+ }
201
+ }
202
+ }
203
+ if (fields.has("address")) {
204
+ lead.address = stripPrefix(await aria('button[data-item-id="address"]'), "Address:");
205
+ }
206
+ if (fields.has("phone")) {
207
+ lead.phone = stripPrefix(await aria('button[data-item-id^="phone"]'), "Phone:");
208
+ }
209
+ if (fields.has("website")) {
210
+ const site = page.locator('a[data-item-id="authority"]').first();
211
+ if (await site.count()) {
212
+ lead.website = await site.getAttribute("href").catch(() => null) ?? void 0;
213
+ }
214
+ }
215
+ if (fields.has("plusCode")) {
216
+ lead.plusCode = stripPrefix(await aria('button[data-item-id="oloc"]'), "Plus code:");
217
+ }
218
+ if (fields.has("hours")) {
219
+ lead.hours = stripPrefix(await aria('div[jsaction*="openhours"]'), "") ?? await text('div[jsaction*="openhours"]');
220
+ }
221
+ if (fields.has("mapsUrl")) lead.mapsUrl = page.url();
222
+ return lead;
223
+ }
224
+ async function scrapeLeads(opts) {
225
+ const query = composeQuery(opts);
226
+ const limit = Math.max(1, Math.trunc(opts.limit ?? 60));
227
+ const fields = new Set(normalizeFields(opts.fields ?? ALL_FIELDS));
228
+ const wantDetails = opts.details ?? true;
229
+ const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
230
+ const headless = opts.headless ?? false;
231
+ const onProgress = opts.onProgress;
232
+ const signal = opts.signal;
233
+ onProgress?.(`searching Google Maps for "${query}"\u2026`);
234
+ const browser = await launchBrowser(headless);
235
+ try {
236
+ const context = await browser.newContext({
237
+ viewport: { width: 1280, height: 900 },
238
+ locale: "en-US"
239
+ });
240
+ const page = await context.newPage();
241
+ await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
242
+ await dismissConsent(page);
243
+ await loadResults(page, limit, delayMs, onProgress, signal);
244
+ if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
245
+ const cards = await page.locator("a.hfpxzc").evaluateAll(
246
+ (els, lim) => els.slice(0, lim).map((a) => ({
247
+ href: a.href,
248
+ name: a.getAttribute("aria-label") ?? void 0
249
+ })),
250
+ limit
251
+ ).catch(() => []);
252
+ if (cards.length === 0) {
253
+ onProgress?.("no listings found (Google may have shown a CAPTCHA or an empty result).");
254
+ return [];
255
+ }
256
+ if (!wantDetails) {
257
+ return cards.map((c) => {
258
+ const lead = {};
259
+ if (fields.has("name")) lead.name = c.name;
260
+ if (fields.has("mapsUrl")) lead.mapsUrl = c.href;
261
+ return lead;
262
+ });
263
+ }
264
+ const leads = [];
265
+ for (let i = 0; i < cards.length; i++) {
266
+ if (signal?.aborted) break;
267
+ const card = cards[i];
268
+ onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
269
+ try {
270
+ await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
271
+ await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
272
+ });
273
+ leads.push(await extractDetail(page, fields, card.name));
274
+ } catch {
275
+ if (card.name && (fields.has("name") || fields.has("mapsUrl"))) {
276
+ const partial = {};
277
+ if (fields.has("name")) partial.name = card.name;
278
+ if (fields.has("mapsUrl")) partial.mapsUrl = card.href;
279
+ leads.push(partial);
280
+ }
281
+ }
282
+ if (delayMs) await page.waitForTimeout(delayMs);
283
+ }
284
+ onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
285
+ return leads;
286
+ } finally {
287
+ await browser.close().catch(() => {
288
+ });
289
+ }
290
+ }
291
+
292
+ // src/export.ts
293
+ var import_csv = require("@lacspace/csv");
294
+ var import_xlsx = require("@lacspace/xlsx");
295
+ var HEADERS = {
296
+ name: "Name",
297
+ category: "Category",
298
+ rating: "Rating",
299
+ reviews: "Reviews",
300
+ address: "Address",
301
+ phone: "Phone",
302
+ website: "Website",
303
+ plusCode: "Plus Code",
304
+ hours: "Hours",
305
+ mapsUrl: "Maps URL"
306
+ };
307
+ function toRows(leads, fields = ALL_FIELDS) {
308
+ return leads.map((lead) => {
309
+ const row = {};
310
+ for (const f of fields) {
311
+ const v = lead[f];
312
+ row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
313
+ }
314
+ return row;
315
+ });
316
+ }
317
+ function serialize(leads, format, fields = ALL_FIELDS) {
318
+ if (format === "json") {
319
+ const picked = leads.map((lead) => {
320
+ const o = {};
321
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
322
+ return o;
323
+ });
324
+ return { data: JSON.stringify(picked, null, 2), binary: false };
325
+ }
326
+ const rows = toRows(leads, fields);
327
+ if (format === "csv") {
328
+ const csvRows = rows;
329
+ return { data: (0, import_csv.stringify)(csvRows, { escapeFormulas: true }), binary: false };
330
+ }
331
+ return { data: (0, import_xlsx.jsonToXlsx)(rows, { sheetName: "Leads" }), binary: true };
332
+ }
333
+ // Annotate the CommonJS export names for ESM import in node:
334
+ 0 && (module.exports = {
335
+ ALL_FIELDS,
336
+ LeadsError,
337
+ composeQuery,
338
+ defaultFilename,
339
+ mapsSearchUrl,
340
+ normalizeFields,
341
+ parseRating,
342
+ parseReviewCount,
343
+ scrapeLeads,
344
+ searchLeads,
345
+ serialize,
346
+ toRows
347
+ });
package/dist/lib.d.cts ADDED
@@ -0,0 +1,105 @@
1
+ /** The fields a {@link Lead} can carry — request any subset via `fields`. */
2
+ type LeadField = "name" | "category" | "rating" | "reviews" | "address" | "phone" | "website" | "plusCode" | "hours" | "mapsUrl";
3
+ /** Every field, in a sensible column order for exports. */
4
+ declare const ALL_FIELDS: LeadField[];
5
+ /** A single collected business lead. Every field is optional — Maps listings vary. */
6
+ interface Lead {
7
+ name?: string;
8
+ category?: string;
9
+ /** Star rating, 0–5. */
10
+ rating?: number;
11
+ /** Number of reviews. */
12
+ reviews?: number;
13
+ address?: string;
14
+ phone?: string;
15
+ website?: string;
16
+ /** Google Plus Code, when shown. */
17
+ plusCode?: string;
18
+ /** Opening-hours summary, when shown. */
19
+ hours?: string;
20
+ /** Canonical Google Maps URL for the listing. */
21
+ mapsUrl?: string;
22
+ }
23
+ /** Output formats the tool can write. */
24
+ type OutputFormat = "json" | "csv" | "xlsx";
25
+ /** Options for a lead search. */
26
+ interface SearchOptions {
27
+ /** City, e.g. "Kathmandu". */
28
+ city?: string;
29
+ /** Area / neighbourhood, e.g. "Baneshwor". */
30
+ area?: string;
31
+ /** Business type / keyword, e.g. "restaurants", "dental clinic". */
32
+ type: string;
33
+ /** A ready-made query, used verbatim instead of composing city/area/type. */
34
+ query?: string;
35
+ /** Max number of leads to collect. Default 60. */
36
+ limit?: number;
37
+ /** Fields to collect. Default: all of {@link ALL_FIELDS}. */
38
+ fields?: LeadField[];
39
+ /** Run the browser without a visible window. Default false (visible). */
40
+ headless?: boolean;
41
+ /**
42
+ * Open each listing to pull phone/website/plus-code/hours (slower but far
43
+ * richer). When false, only what the results list shows is captured.
44
+ * Default true.
45
+ */
46
+ details?: boolean;
47
+ /** Milliseconds to pause between listing opens (politeness). Default 700. */
48
+ delayMs?: number;
49
+ /** Called with a short progress message as the search runs. */
50
+ onProgress?: (message: string) => void;
51
+ /** An AbortSignal to cancel a running search. */
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ /** Error thrown by the scraper. Carries a machine `code` and optional cause. */
56
+ declare class LeadsError extends Error {
57
+ code?: string;
58
+ cause?: unknown;
59
+ constructor(message: string, code?: string, cause?: unknown);
60
+ }
61
+ /**
62
+ * Parse a "N reviews" / "N,234 reviews" aria-label into a count. Exported for
63
+ * testing the messy number formats Google uses.
64
+ */
65
+ declare function parseReviewCount(label: string | null | undefined): number | undefined;
66
+ /** Parse a rating like "4.5" or "4,5" into a number in 0–5. */
67
+ declare function parseRating(text: string | null | undefined): number | undefined;
68
+ /**
69
+ * Run the Google Maps search in a real browser and collect leads. This is the
70
+ * scraping engine behind {@link searchLeads}; prefer that wrapper.
71
+ *
72
+ * @throws {LeadsError} when no browser can be launched, or the search is aborted.
73
+ */
74
+ declare function scrapeLeads(opts: SearchOptions): Promise<Lead[]>;
75
+
76
+ /** Project leads onto exactly the requested fields, in order, as header-keyed rows. */
77
+ declare function toRows(leads: Lead[], fields?: LeadField[]): Record<string, string | number>[];
78
+ /** Serialize leads to a UTF-8 string or bytes in the chosen format. */
79
+ declare function serialize(leads: Lead[], format: OutputFormat, fields?: LeadField[]): {
80
+ data: string | Uint8Array;
81
+ binary: boolean;
82
+ };
83
+
84
+ /**
85
+ * Compose the human search query from options: an explicit `query` wins,
86
+ * otherwise "<type> in <area>, <city>" with the empty parts dropped.
87
+ */
88
+ declare function composeQuery(opts: {
89
+ query?: string;
90
+ type?: string;
91
+ area?: string;
92
+ city?: string;
93
+ }): string;
94
+ /** The Google Maps search URL for a query. `hl=en` keeps labels predictable. */
95
+ declare function mapsSearchUrl(query: string): string;
96
+ /**
97
+ * Normalise a fields request (array or comma string) into known {@link LeadField}s
98
+ * in canonical order, de-duplicated. Unknown names are ignored; an empty result
99
+ * falls back to all fields.
100
+ */
101
+ declare function normalizeFields(input?: readonly string[] | string): LeadField[];
102
+ /** A safe, timestamped default output filename for a query + format. */
103
+ declare function defaultFilename(query: string, format: string): string;
104
+
105
+ export { ALL_FIELDS, type Lead, type LeadField, LeadsError, type OutputFormat, type SearchOptions, composeQuery, defaultFilename, mapsSearchUrl, normalizeFields, parseRating, parseReviewCount, scrapeLeads, scrapeLeads as searchLeads, serialize, toRows };
package/dist/lib.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ /** The fields a {@link Lead} can carry — request any subset via `fields`. */
2
+ type LeadField = "name" | "category" | "rating" | "reviews" | "address" | "phone" | "website" | "plusCode" | "hours" | "mapsUrl";
3
+ /** Every field, in a sensible column order for exports. */
4
+ declare const ALL_FIELDS: LeadField[];
5
+ /** A single collected business lead. Every field is optional — Maps listings vary. */
6
+ interface Lead {
7
+ name?: string;
8
+ category?: string;
9
+ /** Star rating, 0–5. */
10
+ rating?: number;
11
+ /** Number of reviews. */
12
+ reviews?: number;
13
+ address?: string;
14
+ phone?: string;
15
+ website?: string;
16
+ /** Google Plus Code, when shown. */
17
+ plusCode?: string;
18
+ /** Opening-hours summary, when shown. */
19
+ hours?: string;
20
+ /** Canonical Google Maps URL for the listing. */
21
+ mapsUrl?: string;
22
+ }
23
+ /** Output formats the tool can write. */
24
+ type OutputFormat = "json" | "csv" | "xlsx";
25
+ /** Options for a lead search. */
26
+ interface SearchOptions {
27
+ /** City, e.g. "Kathmandu". */
28
+ city?: string;
29
+ /** Area / neighbourhood, e.g. "Baneshwor". */
30
+ area?: string;
31
+ /** Business type / keyword, e.g. "restaurants", "dental clinic". */
32
+ type: string;
33
+ /** A ready-made query, used verbatim instead of composing city/area/type. */
34
+ query?: string;
35
+ /** Max number of leads to collect. Default 60. */
36
+ limit?: number;
37
+ /** Fields to collect. Default: all of {@link ALL_FIELDS}. */
38
+ fields?: LeadField[];
39
+ /** Run the browser without a visible window. Default false (visible). */
40
+ headless?: boolean;
41
+ /**
42
+ * Open each listing to pull phone/website/plus-code/hours (slower but far
43
+ * richer). When false, only what the results list shows is captured.
44
+ * Default true.
45
+ */
46
+ details?: boolean;
47
+ /** Milliseconds to pause between listing opens (politeness). Default 700. */
48
+ delayMs?: number;
49
+ /** Called with a short progress message as the search runs. */
50
+ onProgress?: (message: string) => void;
51
+ /** An AbortSignal to cancel a running search. */
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ /** Error thrown by the scraper. Carries a machine `code` and optional cause. */
56
+ declare class LeadsError extends Error {
57
+ code?: string;
58
+ cause?: unknown;
59
+ constructor(message: string, code?: string, cause?: unknown);
60
+ }
61
+ /**
62
+ * Parse a "N reviews" / "N,234 reviews" aria-label into a count. Exported for
63
+ * testing the messy number formats Google uses.
64
+ */
65
+ declare function parseReviewCount(label: string | null | undefined): number | undefined;
66
+ /** Parse a rating like "4.5" or "4,5" into a number in 0–5. */
67
+ declare function parseRating(text: string | null | undefined): number | undefined;
68
+ /**
69
+ * Run the Google Maps search in a real browser and collect leads. This is the
70
+ * scraping engine behind {@link searchLeads}; prefer that wrapper.
71
+ *
72
+ * @throws {LeadsError} when no browser can be launched, or the search is aborted.
73
+ */
74
+ declare function scrapeLeads(opts: SearchOptions): Promise<Lead[]>;
75
+
76
+ /** Project leads onto exactly the requested fields, in order, as header-keyed rows. */
77
+ declare function toRows(leads: Lead[], fields?: LeadField[]): Record<string, string | number>[];
78
+ /** Serialize leads to a UTF-8 string or bytes in the chosen format. */
79
+ declare function serialize(leads: Lead[], format: OutputFormat, fields?: LeadField[]): {
80
+ data: string | Uint8Array;
81
+ binary: boolean;
82
+ };
83
+
84
+ /**
85
+ * Compose the human search query from options: an explicit `query` wins,
86
+ * otherwise "<type> in <area>, <city>" with the empty parts dropped.
87
+ */
88
+ declare function composeQuery(opts: {
89
+ query?: string;
90
+ type?: string;
91
+ area?: string;
92
+ city?: string;
93
+ }): string;
94
+ /** The Google Maps search URL for a query. `hl=en` keeps labels predictable. */
95
+ declare function mapsSearchUrl(query: string): string;
96
+ /**
97
+ * Normalise a fields request (array or comma string) into known {@link LeadField}s
98
+ * in canonical order, de-duplicated. Unknown names are ignored; an empty result
99
+ * falls back to all fields.
100
+ */
101
+ declare function normalizeFields(input?: readonly string[] | string): LeadField[];
102
+ /** A safe, timestamped default output filename for a query + format. */
103
+ declare function defaultFilename(query: string, format: string): string;
104
+
105
+ export { ALL_FIELDS, type Lead, type LeadField, LeadsError, type OutputFormat, type SearchOptions, composeQuery, defaultFilename, mapsSearchUrl, normalizeFields, parseRating, parseReviewCount, scrapeLeads, scrapeLeads as searchLeads, serialize, toRows };