lacspace-leads 1.4.0 → 1.5.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
@@ -93,6 +93,8 @@ npx lacspace-leads [type] [options]
93
93
  | `--has-website` | Only leads with a website |
94
94
  | `--has-email` | Only leads with an email (implies `--emails`) |
95
95
  | `--has-valid-email` | Only leads whose email passed **MX verification** (implies `--verify-emails`) |
96
+ | `--has-contact` | Only leads reachable by **phone, email or website** |
97
+ | `--name-exclude <list>` | Drop leads whose name contains any of these terms (comma-separated) |
96
98
  | `--dedupe <key>` | `website` · `phone` · `name` · `smart` · `none` (default `website`; `--append` uses `smart` = website→phone→name) |
97
99
 
98
100
  Run with no arguments for an interactive walkthrough.
@@ -154,6 +156,33 @@ npx lacspace-leads cafes --city Kathmandu --area "Thamel,Baneshwor,Patan" \
154
156
 
155
157
  `--verify-emails` does a DNS **MX lookup** on each email's domain (no message is sent) and adds an `emailStatus` column of `valid` · `no-mx` · `invalid-format`. `--append` reads the existing file back, merges, and de-duplicates with the **`smart`** key (website → phone → name) so even website-less businesses don't pile up on re-runs.
156
158
 
159
+ ## Saved campaigns (`--config`)
160
+
161
+ Describe a repeatable set of searches plus shared options in one JSON file, and run them all with a single command — ideal for the same city sweeps every week (pair it with cron or CI):
162
+
163
+ ```jsonc
164
+ // campaign.json
165
+ {
166
+ "searches": [
167
+ { "type": "cafes", "city": "Kathmandu", "area": "Thamel,Baneshwor,Patan" },
168
+ { "type": "gyms", "city": "Pokhara" }
169
+ ],
170
+ "country": "NP",
171
+ "verifyEmails": true,
172
+ "sort": "reviews",
173
+ "filters": { "hasContact": true, "excludeNames": ["closed"] },
174
+ "out": "master.xlsx",
175
+ "format": "xlsx",
176
+ "append": true
177
+ }
178
+ ```
179
+
180
+ ```bash
181
+ npx lacspace-leads --config campaign.json
182
+ ```
183
+
184
+ Every search is run, merged and de-duplicated; the shared options, filters and output settings apply across the whole campaign. Programmatically: `runConfig(config)`.
185
+
157
186
  ## Convert your leads to any format
158
187
 
159
188
  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:
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { writeFileSync, existsSync } from "fs";
4
+ import { writeFileSync, existsSync, readFileSync } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
  import { createInterface } from "readline/promises";
7
7
  import { stdin, stdout, stderr, argv, exit } from "process";
@@ -240,6 +240,11 @@ function filterLeads(leads, filters = {}) {
240
240
  if (filters.hasWebsite && !l.website) return false;
241
241
  if (filters.hasEmail && !l.email) return false;
242
242
  if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
243
+ if (filters.hasContact && !(l.phone || l.email || l.website)) return false;
244
+ if (filters.excludeNames && filters.excludeNames.length) {
245
+ const name = (l.name ?? "").toLowerCase();
246
+ if (filters.excludeNames.some((t) => t && name.includes(t.toLowerCase()))) return false;
247
+ }
243
248
  return true;
244
249
  });
245
250
  }
@@ -1013,6 +1018,28 @@ async function searchLeadsBatch(queries, opts = {}) {
1013
1018
  return merged;
1014
1019
  }
1015
1020
 
1021
+ // src/config.ts
1022
+ async function runConfig(config, hooks = {}) {
1023
+ const { searches, filters, total, out, format, append, sheet, ...shared } = config;
1024
+ void out;
1025
+ void format;
1026
+ void append;
1027
+ void sheet;
1028
+ const opts = { ...shared };
1029
+ if (filters) opts.filters = filters;
1030
+ if (total !== void 0) opts.total = total;
1031
+ if (hooks.onProgress) opts.onProgress = hooks.onProgress;
1032
+ if (hooks.signal) opts.signal = hooks.signal;
1033
+ return searchLeadsBatch(Array.isArray(searches) ? searches : [], opts);
1034
+ }
1035
+ function assertConfig(value) {
1036
+ if (!value || typeof value !== "object") throw new Error("Config must be a JSON object.");
1037
+ const searches = value.searches;
1038
+ if (!Array.isArray(searches) || searches.length === 0) {
1039
+ throw new Error('Config needs a non-empty "searches" array, e.g. [{ "type": "cafes", "city": "Kathmandu" }].');
1040
+ }
1041
+ }
1042
+
1016
1043
  // src/convert.ts
1017
1044
  import { readFile, writeFile } from "fs/promises";
1018
1045
  import { extname, resolve } from "path";
@@ -1130,6 +1157,7 @@ function parseArgs(list) {
1130
1157
  hasWebsite: false,
1131
1158
  hasEmail: false,
1132
1159
  hasValidEmail: false,
1160
+ hasContact: false,
1133
1161
  cleanUrls: true,
1134
1162
  jitter: false,
1135
1163
  yes: false,
@@ -1176,7 +1204,10 @@ function parseArgs(list) {
1176
1204
  a.hasValidEmail = true;
1177
1205
  a.verifyEmails = true;
1178
1206
  a.emails = true;
1179
- } else if (arg === "--dedupe") a.dedupe = next();
1207
+ } else if (arg === "--has-contact") a.hasContact = true;
1208
+ else if (arg === "--name-exclude" || arg === "--exclude-names") a.nameExclude = next();
1209
+ else if (arg === "--config") a.config = next();
1210
+ else if (arg === "--dedupe") a.dedupe = next();
1180
1211
  else if (arg === "--sort") a.sort = next();
1181
1212
  else if (arg === "--desc") a.desc = true;
1182
1213
  else if (arg === "--asc") a.desc = false;
@@ -1236,11 +1267,17 @@ ${c("bold", "Filters & order")}
1236
1267
  --has-website Keep only leads with a website
1237
1268
  --has-email Keep only leads with an email (implies --emails)
1238
1269
  --has-valid-email Keep only leads with an MX-verified email (implies --verify-emails)
1270
+ --has-contact Keep only leads reachable by phone, email OR website
1271
+ --name-exclude <list> Drop leads whose name contains any of these terms
1239
1272
  --dedupe <key> website | phone | name | smart | none (default website;
1240
1273
  --append uses smart: website\u2192phone\u2192name)
1241
1274
  --sort <key> rating | reviews | name | priceLevel | distance
1242
1275
  --desc / --asc Sort direction (distance defaults nearest-first)
1243
1276
 
1277
+ ${c("bold", "Campaigns")}
1278
+ --config <file> Run a saved JSON campaign: { "searches":[\u2026], shared options,
1279
+ "out", "format", "append" }. Repeatable, schedulable.
1280
+
1244
1281
  ${c("bold", "Output")}
1245
1282
  -f, --format <fmt> json | ndjson | csv | xlsx (default json)
1246
1283
  -o, --out <file> Output file, or "-" for stdout (default: slug + date)
@@ -1315,6 +1352,70 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads convert"))}
1315
1352
  exit(1);
1316
1353
  }
1317
1354
  }
1355
+ async function runConfigFile(args) {
1356
+ log(`
1357
+ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 running a saved campaign")}
1358
+ `);
1359
+ let config;
1360
+ try {
1361
+ config = JSON.parse(readFileSync(resolve2(args.config), "utf8"));
1362
+ assertConfig(config);
1363
+ } catch (err) {
1364
+ log(c("red", `
1365
+ \u2717 Could not load --config: ${err.message}
1366
+ `));
1367
+ exit(1);
1368
+ return;
1369
+ }
1370
+ const format = config.format ?? args.format;
1371
+ if (!OUTPUT_FORMATS.includes(format)) {
1372
+ log(c("red", `
1373
+ \u2717 Unknown format "${format}".`));
1374
+ exit(1);
1375
+ return;
1376
+ }
1377
+ log(` ${c("dim", "searches")} ${config.searches.length} ${c("dim", "format")} ${format}${config.append ? c("dim", " (append)") : ""}
1378
+ `);
1379
+ const controller = new AbortController();
1380
+ const onSig = () => controller.abort();
1381
+ process.once("SIGINT", onSig);
1382
+ let leads;
1383
+ try {
1384
+ leads = await runConfig(config, { signal: controller.signal, onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`) });
1385
+ } catch (err) {
1386
+ log(c("red", `
1387
+ \u2717 ${err.message}`));
1388
+ exit(1);
1389
+ return;
1390
+ } finally {
1391
+ process.removeListener("SIGINT", onSig);
1392
+ }
1393
+ if (!leads.length) {
1394
+ log(c("yellow", "\n No leads collected.\n"));
1395
+ return;
1396
+ }
1397
+ const fields = config.fields ? normalizeFields(config.fields) : ALL_FIELDS.filter((f) => leads.some((l) => l[f] !== void 0));
1398
+ let out = resolve2(args.out ?? config.out ?? defaultFilename("leads-campaign", format));
1399
+ if ((args.append || config.append) && existsSync(out)) {
1400
+ try {
1401
+ const existing = rowsToLeads(await readRows(out));
1402
+ const before = existing.length;
1403
+ leads = dedupeLeads([...existing, ...leads], config.dedupe ?? "smart");
1404
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${leads.length} total`)}`);
1405
+ } catch (err) {
1406
+ log(c("yellow", ` ! couldn't append (${err.message}); overwriting`));
1407
+ }
1408
+ }
1409
+ const serOpts = {};
1410
+ if (args.sheet ?? config.sheet) serOpts.sheetName = args.sheet ?? config.sheet;
1411
+ const { data, binary } = serialize(leads, format, fields, serOpts);
1412
+ writeFileSync(out, binary ? Buffer.from(data) : data);
1413
+ const s = computeStats(leads);
1414
+ log(`
1415
+ ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
1416
+ log(` ${c("dim", `${s.withPhone} with a phone \xB7 ${s.withWebsite} with a website \xB7 ${s.withEmail} with an email`)}
1417
+ `);
1418
+ }
1318
1419
  async function main() {
1319
1420
  const raw = argv.slice(2);
1320
1421
  if (raw[0] === "convert") {
@@ -1326,6 +1427,10 @@ async function main() {
1326
1427
  stdout.write(HELP + "\n");
1327
1428
  return;
1328
1429
  }
1430
+ if (args.config) {
1431
+ await runConfigFile(args);
1432
+ return;
1433
+ }
1329
1434
  log(`
1330
1435
  ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Maps \u2192 JSON/CSV/Excel, free")}
1331
1436
  `);
@@ -1400,6 +1505,8 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1400
1505
  if (args.hasWebsite) filters.hasWebsite = true;
1401
1506
  if (args.hasEmail) filters.hasEmail = true;
1402
1507
  if (args.hasValidEmail) filters.hasValidEmail = true;
1508
+ if (args.hasContact) filters.hasContact = true;
1509
+ if (args.nameExclude) filters.excludeNames = args.nameExclude.split(",").map((s2) => s2.trim()).filter(Boolean);
1403
1510
  const hasFilters = Object.keys(filters).length > 0;
1404
1511
  const queries = expandQueries({
1405
1512
  type: args.type ?? "",
package/dist/lib.cjs CHANGED
@@ -26,6 +26,7 @@ __export(lib_exports, {
26
26
  ENRICHED_FIELDS: () => ENRICHED_FIELDS,
27
27
  FIELD_PRESETS: () => FIELD_PRESETS,
28
28
  LeadsError: () => LeadsError,
29
+ assertConfig: () => assertConfig,
29
30
  callingCode: () => callingCode,
30
31
  cleanWebsite: () => cleanWebsite,
31
32
  columnsOf: () => columnsOf,
@@ -54,6 +55,7 @@ __export(lib_exports, {
54
55
  readRows: () => readRows,
55
56
  resolvePreset: () => resolvePreset,
56
57
  rowsToLeads: () => rowsToLeads,
58
+ runConfig: () => runConfig,
57
59
  scrapeLeads: () => scrapeLeads,
58
60
  searchLeads: () => scrapeLeads,
59
61
  searchLeadsBatch: () => searchLeadsBatch,
@@ -303,6 +305,11 @@ function filterLeads(leads, filters = {}) {
303
305
  if (filters.hasWebsite && !l.website) return false;
304
306
  if (filters.hasEmail && !l.email) return false;
305
307
  if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
308
+ if (filters.hasContact && !(l.phone || l.email || l.website)) return false;
309
+ if (filters.excludeNames && filters.excludeNames.length) {
310
+ const name = (l.name ?? "").toLowerCase();
311
+ if (filters.excludeNames.some((t) => t && name.includes(t.toLowerCase()))) return false;
312
+ }
306
313
  return true;
307
314
  });
308
315
  }
@@ -1090,6 +1097,28 @@ async function searchLeadsMulti(opts) {
1090
1097
  return searchLeadsBatch(queries, opts);
1091
1098
  }
1092
1099
 
1100
+ // src/config.ts
1101
+ async function runConfig(config, hooks = {}) {
1102
+ const { searches, filters, total, out, format, append, sheet, ...shared } = config;
1103
+ void out;
1104
+ void format;
1105
+ void append;
1106
+ void sheet;
1107
+ const opts = { ...shared };
1108
+ if (filters) opts.filters = filters;
1109
+ if (total !== void 0) opts.total = total;
1110
+ if (hooks.onProgress) opts.onProgress = hooks.onProgress;
1111
+ if (hooks.signal) opts.signal = hooks.signal;
1112
+ return searchLeadsBatch(Array.isArray(searches) ? searches : [], opts);
1113
+ }
1114
+ function assertConfig(value) {
1115
+ if (!value || typeof value !== "object") throw new Error("Config must be a JSON object.");
1116
+ const searches = value.searches;
1117
+ if (!Array.isArray(searches) || searches.length === 0) {
1118
+ throw new Error('Config needs a non-empty "searches" array, e.g. [{ "type": "cafes", "city": "Kathmandu" }].');
1119
+ }
1120
+ }
1121
+
1093
1122
  // src/convert.ts
1094
1123
  var import_promises2 = require("fs/promises");
1095
1124
  var import_node_path = require("path");
@@ -1184,6 +1213,7 @@ async function convertFile(input, opts = {}) {
1184
1213
  ENRICHED_FIELDS,
1185
1214
  FIELD_PRESETS,
1186
1215
  LeadsError,
1216
+ assertConfig,
1187
1217
  callingCode,
1188
1218
  cleanWebsite,
1189
1219
  columnsOf,
@@ -1212,6 +1242,7 @@ async function convertFile(input, opts = {}) {
1212
1242
  readRows,
1213
1243
  resolvePreset,
1214
1244
  rowsToLeads,
1245
+ runConfig,
1215
1246
  scrapeLeads,
1216
1247
  searchLeads,
1217
1248
  searchLeadsBatch,
package/dist/lib.d.cts CHANGED
@@ -74,6 +74,10 @@ interface LeadFilters {
74
74
  hasEmail?: boolean;
75
75
  /** Keep only leads whose email passed MX verification (implies `verifyEmails`). */
76
76
  hasValidEmail?: boolean;
77
+ /** Keep only leads reachable by at least one of phone, email or website. */
78
+ hasContact?: boolean;
79
+ /** Drop leads whose name contains any of these (case-insensitive) terms. */
80
+ excludeNames?: string[];
77
81
  }
78
82
  /** Output formats the tool can write. */
79
83
  type OutputFormat = "json" | "ndjson" | "csv" | "xlsx";
@@ -249,6 +253,53 @@ declare function searchLeadsMulti(opts: SearchOptions & {
249
253
  total?: number;
250
254
  }): Promise<Lead[]>;
251
255
 
256
+ /**
257
+ * Config-file campaigns — describe a repeatable set of searches plus shared
258
+ * options in one JSON file and run them together. Ideal for agencies running
259
+ * the same city sweeps on a schedule.
260
+ *
261
+ * ```json
262
+ * {
263
+ * "searches": [
264
+ * { "type": "cafes", "city": "Kathmandu", "area": "Thamel,Baneshwor" },
265
+ * { "type": "gyms", "city": "Pokhara" }
266
+ * ],
267
+ * "country": "NP", "verifyEmails": true, "sort": "reviews",
268
+ * "filters": { "hasContact": true },
269
+ * "out": "master.xlsx", "format": "xlsx", "append": true
270
+ * }
271
+ * ```
272
+ */
273
+
274
+ /** A leads campaign config (the JSON file passed to `--config`). */
275
+ interface LeadsConfig extends Partial<Omit<SearchOptions, "type" | "city" | "area" | "query" | "onProgress" | "signal" | "filters">> {
276
+ /** The searches to run and merge. */
277
+ searches: BatchQuery[];
278
+ /** Post-collection filters applied across the merged set. */
279
+ filters?: LeadFilters;
280
+ /** Cap the merged result. */
281
+ total?: number;
282
+ /** Output file (used by the CLI). */
283
+ out?: string;
284
+ /** Output format (used by the CLI). */
285
+ format?: OutputFormat;
286
+ /** Append/merge into an existing `out` file (used by the CLI). */
287
+ append?: boolean;
288
+ /** Excel sheet name (used by the CLI). */
289
+ sheet?: string;
290
+ }
291
+ /**
292
+ * Run a {@link LeadsConfig}: expand its `searches`, apply the shared options and
293
+ * filters, and return the merged, de-duplicated leads. Output settings (`out`,
294
+ * `format`, `append`, `sheet`) are ignored here — the CLI uses them to write.
295
+ */
296
+ declare function runConfig(config: LeadsConfig, hooks?: {
297
+ onProgress?: (m: string) => void;
298
+ signal?: AbortSignal;
299
+ }): Promise<Lead[]>;
300
+ /** Validate a parsed object is a usable config. Throws a helpful error if not. */
301
+ declare function assertConfig(value: unknown): asserts value is LeadsConfig;
302
+
252
303
  /** Project leads onto exactly the requested fields, in order, as header-keyed rows. */
253
304
  declare function toRows(leads: Lead[], fields?: LeadField[]): Record<string, string | number>[];
254
305
  /** Serialize leads to a UTF-8 string or bytes in the chosen format. */
@@ -472,4 +523,4 @@ declare function convertFile(input: string, opts?: {
472
523
  count: number;
473
524
  }>;
474
525
 
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 };
526
+ 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, type LeadsConfig, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, assertConfig, 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, runConfig, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails, zoomForRadius };
package/dist/lib.d.ts CHANGED
@@ -74,6 +74,10 @@ interface LeadFilters {
74
74
  hasEmail?: boolean;
75
75
  /** Keep only leads whose email passed MX verification (implies `verifyEmails`). */
76
76
  hasValidEmail?: boolean;
77
+ /** Keep only leads reachable by at least one of phone, email or website. */
78
+ hasContact?: boolean;
79
+ /** Drop leads whose name contains any of these (case-insensitive) terms. */
80
+ excludeNames?: string[];
77
81
  }
78
82
  /** Output formats the tool can write. */
79
83
  type OutputFormat = "json" | "ndjson" | "csv" | "xlsx";
@@ -249,6 +253,53 @@ declare function searchLeadsMulti(opts: SearchOptions & {
249
253
  total?: number;
250
254
  }): Promise<Lead[]>;
251
255
 
256
+ /**
257
+ * Config-file campaigns — describe a repeatable set of searches plus shared
258
+ * options in one JSON file and run them together. Ideal for agencies running
259
+ * the same city sweeps on a schedule.
260
+ *
261
+ * ```json
262
+ * {
263
+ * "searches": [
264
+ * { "type": "cafes", "city": "Kathmandu", "area": "Thamel,Baneshwor" },
265
+ * { "type": "gyms", "city": "Pokhara" }
266
+ * ],
267
+ * "country": "NP", "verifyEmails": true, "sort": "reviews",
268
+ * "filters": { "hasContact": true },
269
+ * "out": "master.xlsx", "format": "xlsx", "append": true
270
+ * }
271
+ * ```
272
+ */
273
+
274
+ /** A leads campaign config (the JSON file passed to `--config`). */
275
+ interface LeadsConfig extends Partial<Omit<SearchOptions, "type" | "city" | "area" | "query" | "onProgress" | "signal" | "filters">> {
276
+ /** The searches to run and merge. */
277
+ searches: BatchQuery[];
278
+ /** Post-collection filters applied across the merged set. */
279
+ filters?: LeadFilters;
280
+ /** Cap the merged result. */
281
+ total?: number;
282
+ /** Output file (used by the CLI). */
283
+ out?: string;
284
+ /** Output format (used by the CLI). */
285
+ format?: OutputFormat;
286
+ /** Append/merge into an existing `out` file (used by the CLI). */
287
+ append?: boolean;
288
+ /** Excel sheet name (used by the CLI). */
289
+ sheet?: string;
290
+ }
291
+ /**
292
+ * Run a {@link LeadsConfig}: expand its `searches`, apply the shared options and
293
+ * filters, and return the merged, de-duplicated leads. Output settings (`out`,
294
+ * `format`, `append`, `sheet`) are ignored here — the CLI uses them to write.
295
+ */
296
+ declare function runConfig(config: LeadsConfig, hooks?: {
297
+ onProgress?: (m: string) => void;
298
+ signal?: AbortSignal;
299
+ }): Promise<Lead[]>;
300
+ /** Validate a parsed object is a usable config. Throws a helpful error if not. */
301
+ declare function assertConfig(value: unknown): asserts value is LeadsConfig;
302
+
252
303
  /** Project leads onto exactly the requested fields, in order, as header-keyed rows. */
253
304
  declare function toRows(leads: Lead[], fields?: LeadField[]): Record<string, string | number>[];
254
305
  /** Serialize leads to a UTF-8 string or bytes in the chosen format. */
@@ -472,4 +523,4 @@ declare function convertFile(input: string, opts?: {
472
523
  count: number;
473
524
  }>;
474
525
 
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 };
526
+ 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, type LeadsConfig, LeadsError, type OutputFormat, type SearchOptions, type SortKey$1 as SortKey, assertConfig, 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, runConfig, scrapeLeads, scrapeLeads as searchLeads, searchLeadsBatch, searchLeadsDetailed, searchLeadsMulti, serialize, serializeRows, sortLeads, toRows, verifyEmail, verifyEmails, zoomForRadius };
package/dist/lib.js CHANGED
@@ -232,6 +232,11 @@ function filterLeads(leads, filters = {}) {
232
232
  if (filters.hasWebsite && !l.website) return false;
233
233
  if (filters.hasEmail && !l.email) return false;
234
234
  if (filters.hasValidEmail && l.emailStatus !== "valid") return false;
235
+ if (filters.hasContact && !(l.phone || l.email || l.website)) return false;
236
+ if (filters.excludeNames && filters.excludeNames.length) {
237
+ const name = (l.name ?? "").toLowerCase();
238
+ if (filters.excludeNames.some((t) => t && name.includes(t.toLowerCase()))) return false;
239
+ }
235
240
  return true;
236
241
  });
237
242
  }
@@ -1019,6 +1024,28 @@ async function searchLeadsMulti(opts) {
1019
1024
  return searchLeadsBatch(queries, opts);
1020
1025
  }
1021
1026
 
1027
+ // src/config.ts
1028
+ async function runConfig(config, hooks = {}) {
1029
+ const { searches, filters, total, out, format, append, sheet, ...shared } = config;
1030
+ void out;
1031
+ void format;
1032
+ void append;
1033
+ void sheet;
1034
+ const opts = { ...shared };
1035
+ if (filters) opts.filters = filters;
1036
+ if (total !== void 0) opts.total = total;
1037
+ if (hooks.onProgress) opts.onProgress = hooks.onProgress;
1038
+ if (hooks.signal) opts.signal = hooks.signal;
1039
+ return searchLeadsBatch(Array.isArray(searches) ? searches : [], opts);
1040
+ }
1041
+ function assertConfig(value) {
1042
+ if (!value || typeof value !== "object") throw new Error("Config must be a JSON object.");
1043
+ const searches = value.searches;
1044
+ if (!Array.isArray(searches) || searches.length === 0) {
1045
+ throw new Error('Config needs a non-empty "searches" array, e.g. [{ "type": "cafes", "city": "Kathmandu" }].');
1046
+ }
1047
+ }
1048
+
1022
1049
  // src/convert.ts
1023
1050
  import { readFile, writeFile } from "fs/promises";
1024
1051
  import { extname, resolve } from "path";
@@ -1112,6 +1139,7 @@ export {
1112
1139
  ENRICHED_FIELDS,
1113
1140
  FIELD_PRESETS,
1114
1141
  LeadsError,
1142
+ assertConfig,
1115
1143
  callingCode,
1116
1144
  cleanWebsite,
1117
1145
  columnsOf,
@@ -1140,6 +1168,7 @@ export {
1140
1168
  readRows,
1141
1169
  resolvePreset,
1142
1170
  rowsToLeads,
1171
+ runConfig,
1143
1172
  scrapeLeads,
1144
1173
  scrapeLeads as searchLeads,
1145
1174
  searchLeadsBatch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lacspace-leads",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
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": {