lacspace-leads 1.3.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,29 +156,117 @@ 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
 
157
- ## Convert anything (JSON ↔ CSV ↔ Excel)
159
+ ## Saved campaigns (`--config`)
158
160
 
159
- A general converter is built in it works on any tabular file, not just leads:
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
+ ```
160
179
 
161
180
  ```bash
162
- npx lacspace-leads convert leads.json -f xlsx # JSON → Excel
163
- npx lacspace-leads convert data.csv -o data.json # CSV → JSON
164
- npx lacspace-leads convert sheet.xlsx -f csv # Excel → CSV
181
+ npx lacspace-leads --config campaign.json
165
182
  ```
166
183
 
167
- Programmatically: `convertFile(input, { format, out, sheetName })`, or `readRows(file)` + `serializeRows(rows, format)`.
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
+
186
+ ## Convert your leads to any format
168
187
 
169
- ### Examples
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:
189
+
190
+ ```bash
191
+ npx lacspace-leads convert leads.json -f xlsx # JSON → Excel
192
+ npx lacspace-leads convert leads.json -o leads.csv # JSON → CSV (format from the -o extension)
193
+ npx lacspace-leads convert data.csv -o data.json # CSV → JSON
194
+ npx lacspace-leads convert sheet.xlsx -f csv # Excel → CSV
195
+ npx lacspace-leads convert leads.ndjson -f xlsx # NDJSON → Excel
196
+ npx lacspace-leads convert big.csv -f ndjson # CSV → NDJSON (stream-friendly)
197
+ ```
198
+
199
+ The output format comes from `-f` (or is inferred from the `-o` filename's extension); if you give neither, it defaults to JSON. Use `--sheet "My Leads"` to name the Excel tab.
200
+
201
+ **What converts to what** — every combination works, both ways:
202
+
203
+ | From \ To | JSON | NDJSON | CSV | Excel |
204
+ | --- | :---: | :---: | :---: | :---: |
205
+ | **JSON** | – | ✓ | ✓ | ✓ |
206
+ | **NDJSON** | ✓ | – | ✓ | ✓ |
207
+ | **CSV** | ✓ | ✓ | – | ✓ |
208
+ | **Excel** | ✓ | ✓ | ✓ | – |
209
+
210
+ **Sample.** `leads.json` in:
211
+
212
+ ```json
213
+ [
214
+ { "name": "Himalayan Java", "phone": "+9779801234567", "rating": 4.6, "website": "https://himalayanjava.com" },
215
+ { "name": "Cafe Soma", "phone": "+9779807654321", "rating": 4.4 }
216
+ ]
217
+ ```
218
+
219
+ `npx lacspace-leads convert leads.json -o leads.csv` → `leads.csv` out (missing cells become blank; `+` is escaped so spreadsheets don't treat it as a formula):
220
+
221
+ ```csv
222
+ name,phone,rating,website
223
+ Himalayan Java,'+9779801234567,4.6,https://himalayanjava.com
224
+ Cafe Soma,'+9779807654321,4.4,
225
+ ```
226
+
227
+ **In code** — convert a file, or turn any `Lead[]` into a downloadable buffer:
228
+
229
+ ```ts
230
+ import { convertFile, readRows, serializeRows, serialize } from "lacspace-leads";
231
+
232
+ // 1. Convert a file on disk (returns { out, format, count }).
233
+ await convertFile("leads.json", { format: "xlsx", sheetName: "Prospects" });
234
+
235
+ // 2. Read rows from any format, transform, write to another.
236
+ const rows = await readRows("leads.csv"); // → array of objects
237
+ const filtered = rows.filter((r) => Number(r.Rating) >= 4.5);
238
+ const { data, binary } = serializeRows(filtered, "xlsx"); // → bytes for xlsx
239
+ // (write `data` yourself, or in a server route send it as a download)
240
+
241
+ // 3. Serialize Lead[] straight from a search — pick the columns and order.
242
+ const { data: csv } = serialize(leads, "csv", ["name", "phone", "email"]);
243
+ ```
244
+
245
+ ## Recipes
170
246
 
171
247
  ```bash
172
248
  # Excel of restaurants in a specific area
173
249
  npx lacspace-leads restaurants --city Kathmandu --area Baneshwor -f xlsx
174
250
 
175
- # Just the essentials for outreach, as CSV
176
- npx lacspace-leads --type "dental clinic" --city Pokhara --fields name,phone,website -f csv -n 40
251
+ # Outreach list: name/phone/email/website/address, MX-verified, deliverable only
252
+ npx lacspace-leads "dental clinic" --city Pokhara --preset outreach \
253
+ --verify-emails --has-valid-email --country NP -f csv
254
+
255
+ # Cover a whole city, sorted best-reviewed first, into one Excel file
256
+ npx lacspace-leads cafes --city Kathmandu --area "Thamel,Baneshwor,Patan" \
257
+ --sort reviews --desc -f xlsx
177
258
 
178
- # Fast name + URL sweep, no per-listing opening
259
+ # Everything within 1.5 km of a point, nearest first
260
+ npx lacspace-leads salons --near "27.7172,85.3240" --radius 1.5km -f csv
261
+
262
+ # Build a master list you top up daily (accumulate + dedupe)
263
+ npx lacspace-leads gyms --city Lalitpur -o gyms-master.xlsx --append
264
+
265
+ # Fast name + Maps-URL sweep, no per-listing opening
179
266
  npx lacspace-leads gyms --city Lalitpur --no-details -n 100
267
+
268
+ # Pipe straight into jq (JSON on stdout, logs on stderr)
269
+ npx lacspace-leads bakeries --city Pokhara -f json -o - -y | jq '.[].phone'
180
270
  ```
181
271
 
182
272
  ## Library
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";
@@ -1021,9 +1048,10 @@ import { jsonToXlsx as jsonToXlsx2, xlsxToJson } from "@lacspace/xlsx";
1021
1048
  function detectFormat(file) {
1022
1049
  const ext = extname(file).toLowerCase().replace(/^\./, "");
1023
1050
  if (ext === "json") return "json";
1051
+ if (ext === "ndjson" || ext === "jsonl") return "ndjson";
1024
1052
  if (ext === "csv" || ext === "tsv") return "csv";
1025
1053
  if (ext === "xlsx" || ext === "xls") return "xlsx";
1026
- throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|csv|xlsx).`);
1054
+ throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|ndjson|csv|xlsx).`);
1027
1055
  }
1028
1056
  async function readRows(file, format) {
1029
1057
  const fmt = format ?? detectFormat(file);
@@ -1035,6 +1063,15 @@ async function readRows(file, format) {
1035
1063
  if (fmt === "csv") {
1036
1064
  return csvParse(text, { header: true });
1037
1065
  }
1066
+ if (fmt === "ndjson") {
1067
+ return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l, i) => {
1068
+ try {
1069
+ return JSON.parse(l);
1070
+ } catch {
1071
+ throw new Error(`Invalid NDJSON on line ${i + 1}.`);
1072
+ }
1073
+ });
1074
+ }
1038
1075
  const parsed = JSON.parse(text);
1039
1076
  if (Array.isArray(parsed)) return parsed;
1040
1077
  if (parsed && typeof parsed === "object") return [parsed];
@@ -1057,6 +1094,9 @@ function serializeRows(rows, format, opts = {}) {
1057
1094
  if (format === "json") {
1058
1095
  return { data: JSON.stringify(rows, null, 2), binary: false };
1059
1096
  }
1097
+ if (format === "ndjson") {
1098
+ return { data: rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : ""), binary: false };
1099
+ }
1060
1100
  const cols = columnsOf(rows);
1061
1101
  const flat = rows.map((row) => {
1062
1102
  const out = {};
@@ -1117,6 +1157,7 @@ function parseArgs(list) {
1117
1157
  hasWebsite: false,
1118
1158
  hasEmail: false,
1119
1159
  hasValidEmail: false,
1160
+ hasContact: false,
1120
1161
  cleanUrls: true,
1121
1162
  jitter: false,
1122
1163
  yes: false,
@@ -1163,7 +1204,10 @@ function parseArgs(list) {
1163
1204
  a.hasValidEmail = true;
1164
1205
  a.verifyEmails = true;
1165
1206
  a.emails = true;
1166
- } 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();
1167
1211
  else if (arg === "--sort") a.sort = next();
1168
1212
  else if (arg === "--desc") a.desc = true;
1169
1213
  else if (arg === "--asc") a.desc = false;
@@ -1186,7 +1230,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 free loca
1186
1230
 
1187
1231
  ${c("bold", "Usage")}
1188
1232
  npx lacspace-leads [type] [options]
1189
- npx lacspace-leads convert <file> [-f json|csv|xlsx] [-o out]
1233
+ npx lacspace-leads convert <file> [-f json|ndjson|csv|xlsx] [-o out]
1190
1234
 
1191
1235
  ${c("bold", "Search options")}
1192
1236
  -t, --type <text> Business type/keyword. Comma-separate for several,
@@ -1223,11 +1267,17 @@ ${c("bold", "Filters & order")}
1223
1267
  --has-website Keep only leads with a website
1224
1268
  --has-email Keep only leads with an email (implies --emails)
1225
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
1226
1272
  --dedupe <key> website | phone | name | smart | none (default website;
1227
1273
  --append uses smart: website\u2192phone\u2192name)
1228
1274
  --sort <key> rating | reviews | name | priceLevel | distance
1229
1275
  --desc / --asc Sort direction (distance defaults nearest-first)
1230
1276
 
1277
+ ${c("bold", "Campaigns")}
1278
+ --config <file> Run a saved JSON campaign: { "searches":[\u2026], shared options,
1279
+ "out", "format", "append" }. Repeatable, schedulable.
1280
+
1231
1281
  ${c("bold", "Output")}
1232
1282
  -f, --format <fmt> json | ndjson | csv | xlsx (default json)
1233
1283
  -o, --out <file> Output file, or "-" for stdout (default: slug + date)
@@ -1277,6 +1327,13 @@ async function runConvert(rest) {
1277
1327
  exit(1);
1278
1328
  return;
1279
1329
  }
1330
+ if ((rest.includes("-f") || rest.includes("--format")) && !OUTPUT_FORMATS.includes(a.format)) {
1331
+ log(c("red", `
1332
+ \u2717 Unknown format "${a.format}". Use: ${OUTPUT_FORMATS.join(", ")}.
1333
+ `));
1334
+ exit(1);
1335
+ return;
1336
+ }
1280
1337
  log(`
1281
1338
  ${c("bold", c("magenta", "\u25C6 lacspace-leads convert"))}
1282
1339
  `);
@@ -1295,6 +1352,70 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads convert"))}
1295
1352
  exit(1);
1296
1353
  }
1297
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
+ }
1298
1419
  async function main() {
1299
1420
  const raw = argv.slice(2);
1300
1421
  if (raw[0] === "convert") {
@@ -1306,6 +1427,10 @@ async function main() {
1306
1427
  stdout.write(HELP + "\n");
1307
1428
  return;
1308
1429
  }
1430
+ if (args.config) {
1431
+ await runConfigFile(args);
1432
+ return;
1433
+ }
1309
1434
  log(`
1310
1435
  ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Maps \u2192 JSON/CSV/Excel, free")}
1311
1436
  `);
@@ -1380,6 +1505,8 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
1380
1505
  if (args.hasWebsite) filters.hasWebsite = true;
1381
1506
  if (args.hasEmail) filters.hasEmail = true;
1382
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);
1383
1510
  const hasFilters = Object.keys(filters).length > 0;
1384
1511
  const queries = expandQueries({
1385
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");
@@ -1098,9 +1127,10 @@ var import_xlsx2 = require("@lacspace/xlsx");
1098
1127
  function detectFormat(file) {
1099
1128
  const ext = (0, import_node_path.extname)(file).toLowerCase().replace(/^\./, "");
1100
1129
  if (ext === "json") return "json";
1130
+ if (ext === "ndjson" || ext === "jsonl") return "ndjson";
1101
1131
  if (ext === "csv" || ext === "tsv") return "csv";
1102
1132
  if (ext === "xlsx" || ext === "xls") return "xlsx";
1103
- throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|csv|xlsx).`);
1133
+ throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|ndjson|csv|xlsx).`);
1104
1134
  }
1105
1135
  async function readRows(file, format) {
1106
1136
  const fmt = format ?? detectFormat(file);
@@ -1112,6 +1142,15 @@ async function readRows(file, format) {
1112
1142
  if (fmt === "csv") {
1113
1143
  return (0, import_csv2.parse)(text, { header: true });
1114
1144
  }
1145
+ if (fmt === "ndjson") {
1146
+ return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l, i) => {
1147
+ try {
1148
+ return JSON.parse(l);
1149
+ } catch {
1150
+ throw new Error(`Invalid NDJSON on line ${i + 1}.`);
1151
+ }
1152
+ });
1153
+ }
1115
1154
  const parsed = JSON.parse(text);
1116
1155
  if (Array.isArray(parsed)) return parsed;
1117
1156
  if (parsed && typeof parsed === "object") return [parsed];
@@ -1134,6 +1173,9 @@ function serializeRows(rows, format, opts = {}) {
1134
1173
  if (format === "json") {
1135
1174
  return { data: JSON.stringify(rows, null, 2), binary: false };
1136
1175
  }
1176
+ if (format === "ndjson") {
1177
+ return { data: rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : ""), binary: false };
1178
+ }
1137
1179
  const cols = columnsOf(rows);
1138
1180
  const flat = rows.map((row) => {
1139
1181
  const out = {};
@@ -1171,6 +1213,7 @@ async function convertFile(input, opts = {}) {
1171
1213
  ENRICHED_FIELDS,
1172
1214
  FIELD_PRESETS,
1173
1215
  LeadsError,
1216
+ assertConfig,
1174
1217
  callingCode,
1175
1218
  cleanWebsite,
1176
1219
  columnsOf,
@@ -1199,6 +1242,7 @@ async function convertFile(input, opts = {}) {
1199
1242
  readRows,
1200
1243
  resolvePreset,
1201
1244
  rowsToLeads,
1245
+ runConfig,
1202
1246
  scrapeLeads,
1203
1247
  searchLeads,
1204
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. */
@@ -450,7 +501,7 @@ declare function zoomForRadius(radiusM: number, lat?: number): number;
450
501
  type DataRow = Record<string, unknown>;
451
502
  /** Infer the format from a filename extension. Throws on an unknown extension. */
452
503
  declare function detectFormat(file: string): OutputFormat;
453
- /** Read a JSON / CSV / Excel file into an array of rows. */
504
+ /** Read a JSON / NDJSON / CSV / Excel file into an array of rows. */
454
505
  declare function readRows(file: string, format?: OutputFormat): Promise<DataRow[]>;
455
506
  /** The union of keys across all rows, in first-seen order — the column set. */
456
507
  declare function columnsOf(rows: DataRow[]): string[];
@@ -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. */
@@ -450,7 +501,7 @@ declare function zoomForRadius(radiusM: number, lat?: number): number;
450
501
  type DataRow = Record<string, unknown>;
451
502
  /** Infer the format from a filename extension. Throws on an unknown extension. */
452
503
  declare function detectFormat(file: string): OutputFormat;
453
- /** Read a JSON / CSV / Excel file into an array of rows. */
504
+ /** Read a JSON / NDJSON / CSV / Excel file into an array of rows. */
454
505
  declare function readRows(file: string, format?: OutputFormat): Promise<DataRow[]>;
455
506
  /** The union of keys across all rows, in first-seen order — the column set. */
456
507
  declare function columnsOf(rows: DataRow[]): string[];
@@ -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";
@@ -1027,9 +1054,10 @@ import { jsonToXlsx as jsonToXlsx2, xlsxToJson } from "@lacspace/xlsx";
1027
1054
  function detectFormat(file) {
1028
1055
  const ext = extname(file).toLowerCase().replace(/^\./, "");
1029
1056
  if (ext === "json") return "json";
1057
+ if (ext === "ndjson" || ext === "jsonl") return "ndjson";
1030
1058
  if (ext === "csv" || ext === "tsv") return "csv";
1031
1059
  if (ext === "xlsx" || ext === "xls") return "xlsx";
1032
- throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|csv|xlsx).`);
1060
+ throw new Error(`Cannot infer a format from ".${ext}" \u2014 pass an explicit --format (json|ndjson|csv|xlsx).`);
1033
1061
  }
1034
1062
  async function readRows(file, format) {
1035
1063
  const fmt = format ?? detectFormat(file);
@@ -1041,6 +1069,15 @@ async function readRows(file, format) {
1041
1069
  if (fmt === "csv") {
1042
1070
  return csvParse(text, { header: true });
1043
1071
  }
1072
+ if (fmt === "ndjson") {
1073
+ return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l, i) => {
1074
+ try {
1075
+ return JSON.parse(l);
1076
+ } catch {
1077
+ throw new Error(`Invalid NDJSON on line ${i + 1}.`);
1078
+ }
1079
+ });
1080
+ }
1044
1081
  const parsed = JSON.parse(text);
1045
1082
  if (Array.isArray(parsed)) return parsed;
1046
1083
  if (parsed && typeof parsed === "object") return [parsed];
@@ -1063,6 +1100,9 @@ function serializeRows(rows, format, opts = {}) {
1063
1100
  if (format === "json") {
1064
1101
  return { data: JSON.stringify(rows, null, 2), binary: false };
1065
1102
  }
1103
+ if (format === "ndjson") {
1104
+ return { data: rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : ""), binary: false };
1105
+ }
1066
1106
  const cols = columnsOf(rows);
1067
1107
  const flat = rows.map((row) => {
1068
1108
  const out = {};
@@ -1099,6 +1139,7 @@ export {
1099
1139
  ENRICHED_FIELDS,
1100
1140
  FIELD_PRESETS,
1101
1141
  LeadsError,
1142
+ assertConfig,
1102
1143
  callingCode,
1103
1144
  cleanWebsite,
1104
1145
  columnsOf,
@@ -1127,6 +1168,7 @@ export {
1127
1168
  readRows,
1128
1169
  resolvePreset,
1129
1170
  rowsToLeads,
1171
+ runConfig,
1130
1172
  scrapeLeads,
1131
1173
  scrapeLeads as searchLeads,
1132
1174
  searchLeadsBatch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lacspace-leads",
3
- "version": "1.3.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": {