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 +100 -10
- package/dist/cli.js +131 -4
- package/dist/lib.cjs +45 -1
- package/dist/lib.d.cts +53 -2
- package/dist/lib.d.ts +53 -2
- package/dist/lib.js +43 -1
- 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,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
|
-
##
|
|
159
|
+
## Saved campaigns (`--config`)
|
|
158
160
|
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
npx lacspace-leads
|
|
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
|
-
#
|
|
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 === "--
|
|
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
|
+
"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": {
|