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 +29 -0
- package/dist/cli.js +109 -2
- package/dist/lib.cjs +31 -0
- package/dist/lib.d.cts +52 -1
- package/dist/lib.d.ts +52 -1
- package/dist/lib.js +29 -0
- package/package.json +1 -1
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 === "--
|
|
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.
|
|
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": {
|