lacspace-leads 1.8.0 → 1.9.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 +67 -0
- package/dist/cli.js +357 -2
- package/dist/lib.cjs +281 -1
- package/dist/lib.d.cts +152 -1
- package/dist/lib.d.ts +152 -1
- package/dist/lib.js +273 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -208,6 +208,8 @@ npx lacspace-leads [type] [options]
|
|
|
208
208
|
| `--region <cc>` | Region bias for results, e.g. `np`, `us` |
|
|
209
209
|
| `--headless` | Run the browser without a visible window |
|
|
210
210
|
| `-y, --yes` | Skip prompts and the browser-open confirmation |
|
|
211
|
+
| `--push <url>` | Also stream finished leads to an https endpoint (CRM, Lacspace Mail) — the file is still written. See [Push leads](#push-leads-to-your-crm--lacspace-mail) |
|
|
212
|
+
| `--push-token <t>` | Bearer token for `--push` (prefer the `LACSPACE_LEADS_PUSH_TOKEN` env var) |
|
|
211
213
|
|
|
212
214
|
**Enrichment** (visits each business website — slower, opt-in):
|
|
213
215
|
|
|
@@ -381,6 +383,70 @@ Ask for 100 leads and the file gets row 1 as soon as the first business is read,
|
|
|
381
383
|
|
|
382
384
|
When the run ends the file is rewritten once more in its final order (`--sort`, `--append` merges). Press **Ctrl-C** and it stops cleanly, saving everything collected. Then run the same command with `--resume`: it keeps the rows in the file, skips those listings and fills up to the `--target`. `--no-live` turns this off and writes only at the end.
|
|
383
385
|
|
|
386
|
+
## Push leads to your CRM / Lacspace Mail
|
|
387
|
+
|
|
388
|
+
Add `--push <url>` and every finished lead is also POSTed to that endpoint while the run goes on — for example straight into the **Leads board of [Lacspace Mail](https://mail.lacspace.com)**:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
export LACSPACE_LEADS_PUSH_TOKEN="…your token…"
|
|
392
|
+
npx lacspace-leads restaurants --city Kathmandu --target 300 --emails --verify-emails --push https://api.lacspace.com/api/webmail/leads/import
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**The file is always written too.** `--push` is in addition to the normal output, never instead of it, so if the endpoint is down, slow or rejects the token you still have every lead on disk (and `--resume` works as usual).
|
|
396
|
+
|
|
397
|
+
**Token.** Prefer the `LACSPACE_LEADS_PUSH_TOKEN` environment variable over `--push-token`, because a token on the command line can be seen in your shell history and in the process list (`ps`). Precedence is `--push-token`, then the config file, then the env var. The token is only ever sent in the `Authorization: Bearer …` header: it is never printed (echoes show `***`) and never written to the output or checkpoint files.
|
|
398
|
+
|
|
399
|
+
**URL.** Only `https://` is accepted, except `http://localhost`, `http://127.0.0.1` and `http://[::1]` for local development. Anything else stops with a clear error before the browser opens.
|
|
400
|
+
|
|
401
|
+
**In a campaign file** (`--config`), add a `push` block (CLI flags still win):
|
|
402
|
+
|
|
403
|
+
```jsonc
|
|
404
|
+
{
|
|
405
|
+
"searches": [{ "type": "cafes", "city": "Kathmandu" }],
|
|
406
|
+
"push": { "url": "https://api.lacspace.com/api/webmail/leads/import" } // token: env var, or "token": "…"
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**How it sends.** Finished leads (after enrichment, verification and filters, the same rows the file gets) are batched and flushed **every 10 leads or every 3 seconds**, whichever comes first, in the background, with one request in flight at a time, so pushing never slows the scrape. If the endpoint is slow, waiting leads merge into the next request; at most 2,000 wait in memory, and any beyond that are skipped for the push (with one warning) but still saved to the file. Each request is a `POST` with `Content-Type: application/json`, `Authorization: Bearer <token>` (when a token is set), `User-Agent: lacspace-leads/<version>` and a 15-second timeout:
|
|
411
|
+
|
|
412
|
+
```jsonc
|
|
413
|
+
{
|
|
414
|
+
"leads": [ { "name": "Himalayan Java", "phone": "+97714422519", "website": "https://himalayanjava.com", … } ],
|
|
415
|
+
"search": { "type": "restaurants", "city": "Kathmandu", "area": null, "target": 300 },
|
|
416
|
+
"run": "6f1c…-…", // one random UUID per run, the same on every request
|
|
417
|
+
"seq": 1, // batch number, from 1
|
|
418
|
+
"done": false
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
When the run ends (including a Ctrl-C), the rest is flushed and one final request is sent, bounded to a few seconds so Ctrl-C still exits quickly:
|
|
423
|
+
|
|
424
|
+
```jsonc
|
|
425
|
+
{ "leads": [], "search": { … }, "run": "6f1c…", "seq": 31, "done": true,
|
|
426
|
+
"stats": { "total": 300, "withPhone": 281, "withWebsite": 190, "withEmail": 122, "withValidEmail": 117, "withSocial": 0, "avgRating": 4.3 } }
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Failures never cost you the run:**
|
|
430
|
+
|
|
431
|
+
| Endpoint answers | What happens |
|
|
432
|
+
| --- | --- |
|
|
433
|
+
| network error / timeout / `5xx` | retried 3 times, after 1s, 3s and 9s; then the batch is counted as failed and pushing carries on |
|
|
434
|
+
| `429` | waits for `Retry-After` (seconds or an HTTP date, capped at 60s), still at most 3 retries |
|
|
435
|
+
| `401` / `403` | one error, `push rejected: token invalid or expired, leads are still being saved to <file>`; pushing stops for the rest of the run, scraping and the file carry on |
|
|
436
|
+
| any other `4xx` | one warning per status code, that batch is dropped, pushing carries on |
|
|
437
|
+
|
|
438
|
+
The run ends with one line such as `push: sent 300 leads in 30 batches, failed 0, not rejected`.
|
|
439
|
+
|
|
440
|
+
Programmatically, `createPusher({ url, token })` gives you `push(lead)`, `finish(stats)` and `stats()`. Wire it to `onResult`:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { searchLeads, createPusher, computeStats, formatPushSummary } from "lacspace-leads";
|
|
444
|
+
|
|
445
|
+
const pusher = createPusher({ url: "https://crm.example.com/leads", token: process.env.CRM_TOKEN, search: { type: "cafes", city: "Kathmandu" } });
|
|
446
|
+
const leads = await searchLeads({ type: "cafes", city: "Kathmandu", onResult: (l) => pusher.push(l) });
|
|
447
|
+
console.log(formatPushSummary(await pusher.finish(computeStats(leads))));
|
|
448
|
+
```
|
|
449
|
+
|
|
384
450
|
## Resume a long sweep (`--resume`)
|
|
385
451
|
|
|
386
452
|
Sweeping a whole city one neighbourhood at a time can take a while, and Google may throw a CAPTCHA halfway through. Add `--resume` and every completed sub-search is checkpointed to `<out>.checkpoint.json` — the leads gathered so far plus which searches are done. Re-run the exact same command and it **skips the areas already collected** and picks up where it stopped; when the sweep finishes cleanly the checkpoint is deleted.
|
|
@@ -587,6 +653,7 @@ const leads = await searchLeadsBatch(
|
|
|
587
653
|
| `haversineMeters` / `parseLatLngPair` / `parseDistance` | Pure geo helpers for radius search. |
|
|
588
654
|
| `filterLeads` / `dedupeLeads` / `subtractLeads` / `dedupeKey` | Pure post-processing — filter, in-list dedupe, and cross-file dedupe. |
|
|
589
655
|
| `createLiveWriter(options)` | Write leads to csv / ndjson / json / xlsx (or stdout) one by one as they arrive. |
|
|
656
|
+
| `createPusher(options)` / `validatePushUrl` / `resolvePush` / `parseRetryAfter` / `formatPushSummary` | Stream finished leads to an HTTP endpoint in batches, with retries (1.9.0). |
|
|
590
657
|
| `summarize(leads)` / `formatSummary(s)` | Rating bands, contact coverage %, top categories — the `--summary` engine. |
|
|
591
658
|
| `parsePriceLevel` / `priceLevelValue` / `parseBusinessStatus` / `parseClaimed` / `parseOpenNow` / `parseCategoryTags` | Pure field parsers (unit-tested against HTML/aria snippets). |
|
|
592
659
|
| `pipeToEnrich(handler)` / `leadsToEnrichInput` / `leadDomains` / `leadDomain` | Bridge collected leads to `lacspace-enrich` (no hard dep). |
|
package/dist/cli.js
CHANGED
|
@@ -1234,16 +1234,18 @@ async function searchLeadsBatch(queries, opts = {}) {
|
|
|
1234
1234
|
|
|
1235
1235
|
// src/config.ts
|
|
1236
1236
|
async function runConfig(config, hooks = {}) {
|
|
1237
|
-
const { searches, filters, total, out, format, append, sheet, ...shared } = config;
|
|
1237
|
+
const { searches, filters, total, out, format, append, sheet, push, ...shared } = config;
|
|
1238
1238
|
void out;
|
|
1239
1239
|
void format;
|
|
1240
1240
|
void append;
|
|
1241
1241
|
void sheet;
|
|
1242
|
+
void push;
|
|
1242
1243
|
const opts = { ...shared };
|
|
1243
1244
|
if (filters) opts.filters = filters;
|
|
1244
1245
|
if (total !== void 0) opts.total = total;
|
|
1245
1246
|
if (hooks.onProgress) opts.onProgress = hooks.onProgress;
|
|
1246
1247
|
if (hooks.signal) opts.signal = hooks.signal;
|
|
1248
|
+
if (hooks.onResult) opts.onResult = hooks.onResult;
|
|
1247
1249
|
if (hooks.skip) opts.skip = hooks.skip;
|
|
1248
1250
|
if (hooks.seedLeads) opts.seedLeads = hooks.seedLeads;
|
|
1249
1251
|
if (hooks.onQueryDone) opts.onQueryDone = hooks.onQueryDone;
|
|
@@ -1255,6 +1257,12 @@ function assertConfig(value) {
|
|
|
1255
1257
|
if (!Array.isArray(searches) || searches.length === 0) {
|
|
1256
1258
|
throw new Error('Config needs a non-empty "searches" array, e.g. [{ "type": "cafes", "city": "Kathmandu" }].');
|
|
1257
1259
|
}
|
|
1260
|
+
const push = value.push;
|
|
1261
|
+
if (push !== void 0) {
|
|
1262
|
+
if (!push || typeof push !== "object" || Array.isArray(push)) throw new Error('Config "push" must be an object: { "url": "https://\u2026", "token": "\u2026" }.');
|
|
1263
|
+
if (push.url !== void 0 && typeof push.url !== "string") throw new Error('Config "push.url" must be a string.');
|
|
1264
|
+
if (push.token !== void 0 && typeof push.token !== "string") throw new Error('Config "push.token" must be a string.');
|
|
1265
|
+
}
|
|
1258
1266
|
}
|
|
1259
1267
|
|
|
1260
1268
|
// src/convert.ts
|
|
@@ -1653,6 +1661,262 @@ function leadsToEnrichInput(leads) {
|
|
|
1653
1661
|
return out;
|
|
1654
1662
|
}
|
|
1655
1663
|
|
|
1664
|
+
// src/push.ts
|
|
1665
|
+
var VERSION = "1.9.0";
|
|
1666
|
+
var PUSH_TOKEN_ENV = "LACSPACE_LEADS_PUSH_TOKEN";
|
|
1667
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1668
|
+
function validatePushUrl(raw) {
|
|
1669
|
+
const s = (raw ?? "").trim();
|
|
1670
|
+
if (!s) throw new Error("--push needs a URL, e.g. --push https://api.example.com/leads");
|
|
1671
|
+
let u;
|
|
1672
|
+
try {
|
|
1673
|
+
u = new URL(s);
|
|
1674
|
+
} catch {
|
|
1675
|
+
throw new Error(`--push URL is not valid: "${maskUrl(s)}"`);
|
|
1676
|
+
}
|
|
1677
|
+
if (u.protocol === "https:") return u;
|
|
1678
|
+
if (u.protocol === "http:" && LOCAL_HOSTS.has(u.hostname.toLowerCase())) return u;
|
|
1679
|
+
if (u.protocol === "http:") throw new Error(`--push must use https:// (plain http is only allowed for localhost, 127.0.0.1 or [::1]): ${maskUrl(s)}`);
|
|
1680
|
+
throw new Error(`--push must be an https:// URL, not ${u.protocol}`);
|
|
1681
|
+
}
|
|
1682
|
+
function maskUrl(raw) {
|
|
1683
|
+
try {
|
|
1684
|
+
const u = new URL(raw);
|
|
1685
|
+
return `${u.protocol}//${u.host}${u.pathname}${u.search ? "?\u2026" : ""}`;
|
|
1686
|
+
} catch {
|
|
1687
|
+
return String(raw).replace(/\/\/[^@/]+@/, "//***@").replace(/\?.*$/, "?\u2026");
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
function resolvePush(input) {
|
|
1691
|
+
const url = input.flagUrl ?? input.config?.url;
|
|
1692
|
+
if (url === void 0) return void 0;
|
|
1693
|
+
const token = input.flagToken || input.config?.token || input.env?.[PUSH_TOKEN_ENV] || void 0;
|
|
1694
|
+
return token ? { url, token } : { url };
|
|
1695
|
+
}
|
|
1696
|
+
function parseRetryAfter(value, capMs = 6e4, now = Date.now()) {
|
|
1697
|
+
if (value == null) return void 0;
|
|
1698
|
+
const v = value.trim();
|
|
1699
|
+
if (!v) return void 0;
|
|
1700
|
+
let ms;
|
|
1701
|
+
if (/^\d+(\.\d+)?$/.test(v)) ms = parseFloat(v) * 1e3;
|
|
1702
|
+
else {
|
|
1703
|
+
const t = Date.parse(v);
|
|
1704
|
+
if (Number.isNaN(t)) return void 0;
|
|
1705
|
+
ms = t - now;
|
|
1706
|
+
}
|
|
1707
|
+
return Math.min(Math.max(0, ms), capMs);
|
|
1708
|
+
}
|
|
1709
|
+
function formatPushSummary(s) {
|
|
1710
|
+
const bits = [`sent ${s.sent} lead${s.sent === 1 ? "" : "s"} in ${s.batches} batch${s.batches === 1 ? "" : "es"}`, `failed ${s.failed}`];
|
|
1711
|
+
if (s.dropped) bits.push(`dropped ${s.dropped} (queue full)`);
|
|
1712
|
+
bits.push(s.rejected ? "REJECTED (401/403)" : "not rejected");
|
|
1713
|
+
return `push: ${bits.join(", ")}`;
|
|
1714
|
+
}
|
|
1715
|
+
var randomId = () => {
|
|
1716
|
+
const c2 = globalThis.crypto;
|
|
1717
|
+
if (c2?.randomUUID) return c2.randomUUID();
|
|
1718
|
+
const h = (n) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
1719
|
+
return `${h(8)}-${h(4)}-4${h(3)}-${"89ab"[Math.floor(Math.random() * 4)]}${h(3)}-${h(12)}`;
|
|
1720
|
+
};
|
|
1721
|
+
function createPusher(opts) {
|
|
1722
|
+
const url = validatePushUrl(opts.url).toString();
|
|
1723
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
1724
|
+
const batchSize = Math.max(1, opts.batchSize ?? 10);
|
|
1725
|
+
const intervalMs = Math.max(1, opts.intervalMs ?? 3e3);
|
|
1726
|
+
const maxQueue = Math.max(1, opts.maxQueue ?? 2e3);
|
|
1727
|
+
const maxBatch = Math.max(batchSize, opts.maxBatch ?? 500);
|
|
1728
|
+
const timeoutMs = opts.timeoutMs ?? 15e3;
|
|
1729
|
+
const delays = opts.retryDelaysMs ?? [1e3, 3e3, 9e3];
|
|
1730
|
+
const maxRetryAfterMs = opts.maxRetryAfterMs ?? 6e4;
|
|
1731
|
+
const warn = (m) => {
|
|
1732
|
+
try {
|
|
1733
|
+
opts.onWarn?.(m);
|
|
1734
|
+
} catch {
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
const run = opts.run ?? randomId();
|
|
1738
|
+
const s = opts.search ?? {};
|
|
1739
|
+
const search = {
|
|
1740
|
+
type: s.type || null,
|
|
1741
|
+
city: s.city || null,
|
|
1742
|
+
area: s.area || null,
|
|
1743
|
+
target: typeof s.target === "number" && Number.isFinite(s.target) ? s.target : null
|
|
1744
|
+
};
|
|
1745
|
+
const headers = {
|
|
1746
|
+
"Content-Type": "application/json",
|
|
1747
|
+
"User-Agent": `lacspace-leads/${VERSION}`
|
|
1748
|
+
};
|
|
1749
|
+
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
1750
|
+
const pending = [];
|
|
1751
|
+
const st = { sent: 0, batches: 0, failed: 0, dropped: 0, rejected: false, doneSent: false };
|
|
1752
|
+
let seq = 0;
|
|
1753
|
+
let timer;
|
|
1754
|
+
let due = false;
|
|
1755
|
+
let finishing = false;
|
|
1756
|
+
let finished = false;
|
|
1757
|
+
let sending;
|
|
1758
|
+
let warnedDrop = false;
|
|
1759
|
+
const warned4xx = /* @__PURE__ */ new Set();
|
|
1760
|
+
const stop = new AbortController();
|
|
1761
|
+
const sleep = (ms) => new Promise((res) => {
|
|
1762
|
+
if (stop.signal.aborted) return res();
|
|
1763
|
+
const t = setTimeout(done, ms);
|
|
1764
|
+
function done() {
|
|
1765
|
+
clearTimeout(t);
|
|
1766
|
+
stop.signal.removeEventListener("abort", done);
|
|
1767
|
+
res();
|
|
1768
|
+
}
|
|
1769
|
+
stop.signal.addEventListener("abort", done);
|
|
1770
|
+
});
|
|
1771
|
+
const post = async (payload) => {
|
|
1772
|
+
const body = JSON.stringify(payload);
|
|
1773
|
+
for (let attempt = 0; ; attempt++) {
|
|
1774
|
+
if (stop.signal.aborted) return "stopped";
|
|
1775
|
+
const ac = new AbortController();
|
|
1776
|
+
const onStop = () => ac.abort();
|
|
1777
|
+
stop.signal.addEventListener("abort", onStop);
|
|
1778
|
+
const to = setTimeout(() => ac.abort(), timeoutMs);
|
|
1779
|
+
let status = 0;
|
|
1780
|
+
let retryAfter = null;
|
|
1781
|
+
try {
|
|
1782
|
+
const res = await doFetch(url, { method: "POST", headers, body, signal: ac.signal });
|
|
1783
|
+
status = res.status;
|
|
1784
|
+
retryAfter = res.headers.get("retry-after");
|
|
1785
|
+
try {
|
|
1786
|
+
await res.arrayBuffer();
|
|
1787
|
+
} catch {
|
|
1788
|
+
}
|
|
1789
|
+
} catch {
|
|
1790
|
+
status = 0;
|
|
1791
|
+
} finally {
|
|
1792
|
+
clearTimeout(to);
|
|
1793
|
+
stop.signal.removeEventListener("abort", onStop);
|
|
1794
|
+
}
|
|
1795
|
+
if (stop.signal.aborted) return status >= 200 && status < 300 ? "ok" : "stopped";
|
|
1796
|
+
if (status >= 200 && status < 300) return "ok";
|
|
1797
|
+
if (status === 401 || status === 403) return "rejected";
|
|
1798
|
+
const retryable = status === 0 || status === 429 || status >= 500;
|
|
1799
|
+
if (!retryable) {
|
|
1800
|
+
if (!warned4xx.has(status)) {
|
|
1801
|
+
warned4xx.add(status);
|
|
1802
|
+
warn(`push: endpoint answered HTTP ${status}; dropping that batch and carrying on`);
|
|
1803
|
+
}
|
|
1804
|
+
return "dropped";
|
|
1805
|
+
}
|
|
1806
|
+
if (attempt >= delays.length) return "failed";
|
|
1807
|
+
let wait = delays[attempt] ?? 1e3;
|
|
1808
|
+
if (status === 429) wait = parseRetryAfter(retryAfter, maxRetryAfterMs) ?? wait;
|
|
1809
|
+
await sleep(wait);
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
const reject = (lost) => {
|
|
1813
|
+
st.rejected = true;
|
|
1814
|
+
st.failed += lost + pending.length;
|
|
1815
|
+
pending.length = 0;
|
|
1816
|
+
if (timer) {
|
|
1817
|
+
clearTimeout(timer);
|
|
1818
|
+
timer = void 0;
|
|
1819
|
+
}
|
|
1820
|
+
warn(`push rejected: token invalid or expired, leads are still being saved to ${opts.file ?? "the output file"}`);
|
|
1821
|
+
};
|
|
1822
|
+
const sendLeads = async (leads) => {
|
|
1823
|
+
const outcome = await post({ leads, search, run, seq: ++seq, done: false });
|
|
1824
|
+
if (outcome === "ok") {
|
|
1825
|
+
st.sent += leads.length;
|
|
1826
|
+
st.batches++;
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (outcome === "rejected") {
|
|
1830
|
+
reject(leads.length);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
st.failed += leads.length;
|
|
1834
|
+
if (outcome === "failed") warn(`push: a batch of ${leads.length} lead${leads.length === 1 ? "" : "s"} could not be delivered after retries (still in the file)`);
|
|
1835
|
+
};
|
|
1836
|
+
const schedule = () => {
|
|
1837
|
+
if (timer || !pending.length || st.rejected || finishing) return;
|
|
1838
|
+
timer = setTimeout(() => {
|
|
1839
|
+
timer = void 0;
|
|
1840
|
+
due = true;
|
|
1841
|
+
void drain();
|
|
1842
|
+
}, intervalMs);
|
|
1843
|
+
timer.unref?.();
|
|
1844
|
+
};
|
|
1845
|
+
const drain = () => {
|
|
1846
|
+
if (sending) return sending;
|
|
1847
|
+
sending = (async () => {
|
|
1848
|
+
try {
|
|
1849
|
+
while (!st.rejected && !stop.signal.aborted && pending.length && (due || finishing || pending.length >= batchSize)) {
|
|
1850
|
+
due = false;
|
|
1851
|
+
if (timer) {
|
|
1852
|
+
clearTimeout(timer);
|
|
1853
|
+
timer = void 0;
|
|
1854
|
+
}
|
|
1855
|
+
await sendLeads(pending.splice(0, maxBatch));
|
|
1856
|
+
}
|
|
1857
|
+
} catch {
|
|
1858
|
+
} finally {
|
|
1859
|
+
sending = void 0;
|
|
1860
|
+
}
|
|
1861
|
+
schedule();
|
|
1862
|
+
})();
|
|
1863
|
+
return sending;
|
|
1864
|
+
};
|
|
1865
|
+
const snapshot = () => ({ ...st, pending: pending.length });
|
|
1866
|
+
return {
|
|
1867
|
+
push(lead) {
|
|
1868
|
+
if (st.rejected || finished || stop.signal.aborted) return;
|
|
1869
|
+
if (pending.length >= maxQueue) {
|
|
1870
|
+
st.dropped++;
|
|
1871
|
+
if (!warnedDrop) {
|
|
1872
|
+
warnedDrop = true;
|
|
1873
|
+
warn(`push: the endpoint is too slow, ${maxQueue} leads are already waiting; extra leads are not pushed (they are still in the file)`);
|
|
1874
|
+
}
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
pending.push(lead);
|
|
1878
|
+
if (pending.length >= batchSize) void drain();
|
|
1879
|
+
else schedule();
|
|
1880
|
+
},
|
|
1881
|
+
async finish(stats, fopts) {
|
|
1882
|
+
if (finished) return snapshot();
|
|
1883
|
+
finishing = true;
|
|
1884
|
+
if (timer) {
|
|
1885
|
+
clearTimeout(timer);
|
|
1886
|
+
timer = void 0;
|
|
1887
|
+
}
|
|
1888
|
+
const limit = fopts?.timeoutMs ?? opts.finishTimeoutMs ?? 5e3;
|
|
1889
|
+
let deadline;
|
|
1890
|
+
const work = (async () => {
|
|
1891
|
+
await drain();
|
|
1892
|
+
await drain();
|
|
1893
|
+
if (st.rejected || stop.signal.aborted) return;
|
|
1894
|
+
const payload = { leads: [], search, run, seq: ++seq, done: true };
|
|
1895
|
+
if (stats) payload.stats = stats;
|
|
1896
|
+
const outcome = await post(payload);
|
|
1897
|
+
if (outcome === "ok") st.doneSent = true;
|
|
1898
|
+
else if (outcome === "rejected") reject(0);
|
|
1899
|
+
})();
|
|
1900
|
+
await Promise.race([
|
|
1901
|
+
work.catch(() => void 0),
|
|
1902
|
+
new Promise((res) => {
|
|
1903
|
+
deadline = setTimeout(res, limit);
|
|
1904
|
+
})
|
|
1905
|
+
]);
|
|
1906
|
+
if (deadline) clearTimeout(deadline);
|
|
1907
|
+
stop.abort();
|
|
1908
|
+
await Promise.race([work.catch(() => void 0), new Promise((r) => setTimeout(r, 50))]);
|
|
1909
|
+
if (pending.length) {
|
|
1910
|
+
st.failed += pending.length;
|
|
1911
|
+
pending.length = 0;
|
|
1912
|
+
}
|
|
1913
|
+
finished = true;
|
|
1914
|
+
return snapshot();
|
|
1915
|
+
},
|
|
1916
|
+
stats: snapshot
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1656
1920
|
// src/sweep.ts
|
|
1657
1921
|
var MAX_PER_SEARCH = 120;
|
|
1658
1922
|
var MIN_PER_STEP = 40;
|
|
@@ -1942,6 +2206,8 @@ function parseArgs(list) {
|
|
|
1942
2206
|
else if (arg === "--dedupe-across") a.dedupeAcross = next();
|
|
1943
2207
|
else if (arg === "--enrich-out") a.enrichOut = next();
|
|
1944
2208
|
else if (arg === "--config") a.config = next();
|
|
2209
|
+
else if (arg === "--push") a.push = next();
|
|
2210
|
+
else if (arg === "--push-token") a.pushToken = next();
|
|
1945
2211
|
else if (arg === "--dedupe") a.dedupe = next();
|
|
1946
2212
|
else if (arg === "--sort") a.sort = next();
|
|
1947
2213
|
else if (arg === "--desc") a.desc = true;
|
|
@@ -2040,6 +2306,12 @@ ${c("bold", "Output")}
|
|
|
2040
2306
|
append, json stays valid, xlsx is rewritten), so stopping
|
|
2041
2307
|
early never loses what was collected
|
|
2042
2308
|
|
|
2309
|
+
${c("bold", "Push to a CRM / Lacspace Mail")}
|
|
2310
|
+
--push <url> Also POST finished leads (batches of 10 / every 3s) to this
|
|
2311
|
+
https endpoint while the file is written as usual
|
|
2312
|
+
--push-token <t> Bearer token for --push. Prefer the env var
|
|
2313
|
+
LACSPACE_LEADS_PUSH_TOKEN (shell history can't see it)
|
|
2314
|
+
|
|
2043
2315
|
${c("bold", "Runtime")}
|
|
2044
2316
|
--delay <ms> Pause between listings (default 700)
|
|
2045
2317
|
--jitter Randomise the delay \xB140% (more human)
|
|
@@ -2066,6 +2338,7 @@ ${c("bold", "Examples")}
|
|
|
2066
2338
|
npx lacspace-leads gyms --city Lalitpur --dedupe-across master.csv -o new.csv --append
|
|
2067
2339
|
npx lacspace-leads clinics --city Pokhara --enrich-out sites.ndjson # feed lacspace-enrich
|
|
2068
2340
|
npx lacspace-leads convert leads.json -f xlsx
|
|
2341
|
+
LACSPACE_LEADS_PUSH_TOKEN=\u2026 npx lacspace-leads restaurants --city Kathmandu --target 300 --push https://api.lacspace.com/api/webmail/leads/import
|
|
2069
2342
|
|
|
2070
2343
|
${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
|
|
2071
2344
|
${c("dim", "Service and local data-protection law, and use only public business data.")}
|
|
@@ -2079,6 +2352,43 @@ async function prompt(q, fallback = "") {
|
|
|
2079
2352
|
rl.close();
|
|
2080
2353
|
}
|
|
2081
2354
|
}
|
|
2355
|
+
function setupPush(args, config) {
|
|
2356
|
+
const p = resolvePush({ flagUrl: args.push, flagToken: args.pushToken, config: config?.push, env: process.env });
|
|
2357
|
+
if (!p) {
|
|
2358
|
+
if (args.pushToken) log(c("yellow", " ! --push-token given without --push; ignoring it."));
|
|
2359
|
+
return void 0;
|
|
2360
|
+
}
|
|
2361
|
+
try {
|
|
2362
|
+
validatePushUrl(p.url);
|
|
2363
|
+
} catch (err) {
|
|
2364
|
+
log(c("red", `
|
|
2365
|
+
\u2717 ${err.message}
|
|
2366
|
+
`));
|
|
2367
|
+
exit(1);
|
|
2368
|
+
}
|
|
2369
|
+
if (args.pushToken) log(c("yellow", " ! --push-token is visible in shell history and the process list; prefer LACSPACE_LEADS_PUSH_TOKEN."));
|
|
2370
|
+
return p;
|
|
2371
|
+
}
|
|
2372
|
+
function startPusher(p, file, search) {
|
|
2373
|
+
const s = {};
|
|
2374
|
+
if (search.type) s.type = search.type;
|
|
2375
|
+
if (search.city) s.city = search.city;
|
|
2376
|
+
if (search.area) s.area = search.area;
|
|
2377
|
+
if (search.target !== void 0) s.target = search.target;
|
|
2378
|
+
return createPusher({
|
|
2379
|
+
url: p.url,
|
|
2380
|
+
...p.token ? { token: p.token } : {},
|
|
2381
|
+
search: s,
|
|
2382
|
+
file,
|
|
2383
|
+
onWarn: (m) => log(c("yellow", ` ! ${m}`))
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
async function endPush(pusher, stats, timeoutMs) {
|
|
2387
|
+
if (!pusher) return;
|
|
2388
|
+
const st = await pusher.finish(stats, timeoutMs !== void 0 ? { timeoutMs } : void 0);
|
|
2389
|
+
log(` ${st.rejected || st.failed || st.dropped ? c("yellow", "!") : c("green", "\u2714")} ${c("dim", formatPushSummary(st))}`);
|
|
2390
|
+
}
|
|
2391
|
+
var pushEcho = (p) => `${maskUrl(p.url)}${p.token ? c("dim", " (token ***)") : c("dim", " (no token)")}`;
|
|
2082
2392
|
async function runConvert(rest) {
|
|
2083
2393
|
const a = parseArgs(rest);
|
|
2084
2394
|
const input = rest.find((x) => !x.startsWith("-") && x !== a.format && x !== a.out && x !== a.sheet);
|
|
@@ -2134,6 +2444,8 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
|
|
|
2134
2444
|
exit(1);
|
|
2135
2445
|
return;
|
|
2136
2446
|
}
|
|
2447
|
+
const pushCfg = setupPush(args, config);
|
|
2448
|
+
if (pushCfg) log(` ${c("dim", "push")} ${pushEcho(pushCfg)}`);
|
|
2137
2449
|
log(` ${c("dim", "searches")} ${config.searches.length} ${c("dim", "format")} ${format}${config.append ? c("dim", " (append)") : ""}${args.resume ? c("dim", " (resume)") : ""}
|
|
2138
2450
|
`);
|
|
2139
2451
|
const out = resolve2(args.out ?? config.out ?? defaultFilename("leads-campaign", format));
|
|
@@ -2153,15 +2465,28 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
|
|
|
2153
2465
|
saveCheckpoint(file, cp);
|
|
2154
2466
|
};
|
|
2155
2467
|
}
|
|
2468
|
+
const uniq = (xs) => [...new Set(xs.map((x) => x?.trim()).filter((x) => !!x))].join(", ") || void 0;
|
|
2469
|
+
const pusher = pushCfg ? startPusher(pushCfg, out, {
|
|
2470
|
+
type: uniq(config.searches.map((q) => q.type ?? q.query)),
|
|
2471
|
+
city: uniq(config.searches.map((q) => q.city)),
|
|
2472
|
+
area: uniq(config.searches.map((q) => q.area)),
|
|
2473
|
+
...config.total !== void 0 ? { target: config.total } : {}
|
|
2474
|
+
}) : void 0;
|
|
2156
2475
|
const controller = new AbortController();
|
|
2157
2476
|
const onSig = () => controller.abort();
|
|
2158
2477
|
process.once("SIGINT", onSig);
|
|
2159
2478
|
let leads;
|
|
2160
2479
|
try {
|
|
2161
|
-
leads = await runConfig(config, {
|
|
2480
|
+
leads = await runConfig(config, {
|
|
2481
|
+
signal: controller.signal,
|
|
2482
|
+
onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`),
|
|
2483
|
+
...pusher ? { onResult: (l) => pusher.push(l) } : {},
|
|
2484
|
+
...resumeHooks
|
|
2485
|
+
});
|
|
2162
2486
|
} catch (err) {
|
|
2163
2487
|
log(c("red", `
|
|
2164
2488
|
\u2717 ${err.message}`));
|
|
2489
|
+
await endPush(pusher, computeStats([]), 3e3);
|
|
2165
2490
|
exit(1);
|
|
2166
2491
|
return;
|
|
2167
2492
|
} finally {
|
|
@@ -2170,6 +2495,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
|
|
|
2170
2495
|
if (!leads.length) {
|
|
2171
2496
|
log(c("yellow", "\n No leads collected.\n"));
|
|
2172
2497
|
if (cpFile) clearCheckpoint(cpFile);
|
|
2498
|
+
await endPush(pusher, computeStats([]));
|
|
2173
2499
|
return;
|
|
2174
2500
|
}
|
|
2175
2501
|
const fields = config.fields ? normalizeFields(config.fields) : ALL_FIELDS.filter((f) => leads.some((l) => l[f] !== void 0));
|
|
@@ -2209,6 +2535,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
|
|
|
2209
2535
|
log(`
|
|
2210
2536
|
${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
|
|
2211
2537
|
log(` ${c("dim", `${s.withPhone} with a phone \xB7 ${s.withWebsite} with a website \xB7 ${s.withEmail} with an email`)}`);
|
|
2538
|
+
await endPush(pusher, s);
|
|
2212
2539
|
if (args.summary) {
|
|
2213
2540
|
log("");
|
|
2214
2541
|
formatSummary(summarize(leads)).split("\n").forEach((line, i) => {
|
|
@@ -2273,6 +2600,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
|
|
|
2273
2600
|
exit(1);
|
|
2274
2601
|
return;
|
|
2275
2602
|
}
|
|
2603
|
+
const pushCfg = setupPush(args);
|
|
2276
2604
|
const nearPoint = args.near ? parseLatLngPair(args.near) : void 0;
|
|
2277
2605
|
const tileStepM = args.step ? parseDistance(args.step) : void 0;
|
|
2278
2606
|
if (!args.details && (args.split || args.noWebsite)) {
|
|
@@ -2367,6 +2695,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
|
|
|
2367
2695
|
else if (nearPoint) log(` ${c("dim", "sort")} distance (nearest first)`);
|
|
2368
2696
|
if (args.country) log(` ${c("dim", "phones")} E.164 for ${args.country}`);
|
|
2369
2697
|
if (args.proxy) log(` ${c("dim", "proxy")} ${args.proxy.replace(/\/\/[^@]+@/, "//***@")}`);
|
|
2698
|
+
if (pushCfg) log(` ${c("dim", "push")} ${pushEcho(pushCfg)} ${c("dim", "(the file is written as well)")}`);
|
|
2370
2699
|
log(` ${c("dim", "limit")} ${args.limit}${isBatch ? "/search" : ""}${args.total ? ` (cap ${args.total})` : ""} ${c("dim", "format")} ${args.format} ${c("dim", "\u2192")} ${toStdout ? "stdout" : out}${args.append ? c("dim", " (append)") : ""}`);
|
|
2371
2700
|
if (!args.yes) {
|
|
2372
2701
|
const ok = await prompt(`
|
|
@@ -2382,12 +2711,19 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2382
2711
|
if (controller.signal.aborted) {
|
|
2383
2712
|
writerRef.current?.flush();
|
|
2384
2713
|
log(c("yellow", "\n ! Quit. Rows already written stay in the file; --resume continues from them.\n"));
|
|
2714
|
+
if (pusherRef.current) {
|
|
2715
|
+
const p = pusherRef.current;
|
|
2716
|
+
pusherRef.current = void 0;
|
|
2717
|
+
void endPush(p, computeStats([...writerRef.current?.leads ?? []]), 2e3).finally(() => exit(130));
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2385
2720
|
exit(130);
|
|
2386
2721
|
}
|
|
2387
2722
|
controller.abort();
|
|
2388
2723
|
log(c("yellow", "\n ! Stopping \u2014 finishing the leads in hand. Press Ctrl-C again to quit at once (rows already written stay in the file)."));
|
|
2389
2724
|
};
|
|
2390
2725
|
const writerRef = {};
|
|
2726
|
+
const pusherRef = {};
|
|
2391
2727
|
process.on("SIGINT", onSig);
|
|
2392
2728
|
let existing = [];
|
|
2393
2729
|
if ((args.append || args.resume) && !toStdout && existsSync3(out)) {
|
|
@@ -2442,11 +2778,24 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2442
2778
|
}
|
|
2443
2779
|
}
|
|
2444
2780
|
const goal = args.target ?? (isBatch ? args.total : void 0) ?? (isBatch ? void 0 : args.limit);
|
|
2781
|
+
const pusher = pushCfg ? startPusher(pushCfg, toStdout ? "stdout" : out, {
|
|
2782
|
+
...args.type ? { type: args.type } : args.query ? { type: args.query } : {},
|
|
2783
|
+
...args.city ? { city: args.city } : {},
|
|
2784
|
+
...args.area ? { area: args.area } : {},
|
|
2785
|
+
...goal !== void 0 ? { target: goal } : {}
|
|
2786
|
+
}) : void 0;
|
|
2787
|
+
pusherRef.current = pusher;
|
|
2788
|
+
const finishPush = async (list, timeoutMs) => {
|
|
2789
|
+
const p = pusherRef.current;
|
|
2790
|
+
pusherRef.current = void 0;
|
|
2791
|
+
await endPush(p, computeStats(list), timeoutMs);
|
|
2792
|
+
};
|
|
2445
2793
|
const startedAt = Date.now();
|
|
2446
2794
|
let fresh0 = 0;
|
|
2447
2795
|
const onResult = (lead) => {
|
|
2448
2796
|
if (inMaster?.(lead)) return;
|
|
2449
2797
|
if (writer && !writer.add(lead)) return;
|
|
2798
|
+
pusher?.push(lead);
|
|
2450
2799
|
fresh0++;
|
|
2451
2800
|
const have = resumeRows.length + fresh0;
|
|
2452
2801
|
const mins = (Date.now() - startedAt) / 6e4;
|
|
@@ -2547,11 +2896,13 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2547
2896
|
! ${err.message}`));
|
|
2548
2897
|
log(` ${c("green", "\u2714")} ${c("bold", String(writer.count))} leads are saved in ${c("cyan", out)}. Run the same command with ${c("bold", "--resume")} to continue.
|
|
2549
2898
|
`);
|
|
2899
|
+
await finishPush([...writer.leads], 3e3);
|
|
2550
2900
|
exit(controller.signal.aborted ? 130 : 1);
|
|
2551
2901
|
return;
|
|
2552
2902
|
}
|
|
2553
2903
|
log(c("red", `
|
|
2554
2904
|
\u2717 ${err.message}`));
|
|
2905
|
+
await finishPush([], 3e3);
|
|
2555
2906
|
exit(1);
|
|
2556
2907
|
return;
|
|
2557
2908
|
} finally {
|
|
@@ -2572,6 +2923,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2572
2923
|
log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
|
|
2573
2924
|
dropEmpty();
|
|
2574
2925
|
if (cpFile) clearCheckpoint(cpFile);
|
|
2926
|
+
await finishPush([]);
|
|
2575
2927
|
return;
|
|
2576
2928
|
}
|
|
2577
2929
|
if (master) {
|
|
@@ -2582,6 +2934,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2582
2934
|
log(c("yellow", "\n Nothing new \u2014 every lead was already in the master file.\n"));
|
|
2583
2935
|
dropEmpty();
|
|
2584
2936
|
if (cpFile) clearCheckpoint(cpFile);
|
|
2937
|
+
await finishPush([]);
|
|
2585
2938
|
return;
|
|
2586
2939
|
}
|
|
2587
2940
|
}
|
|
@@ -2619,6 +2972,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2619
2972
|
log(`
|
|
2620
2973
|
${c("green", "\u2714")} ${c("bold", String(leads.length))} leads written to stdout ${c("dim", `(${args.format})`)}
|
|
2621
2974
|
`);
|
|
2975
|
+
await finishPush(leads);
|
|
2622
2976
|
printSummary();
|
|
2623
2977
|
return;
|
|
2624
2978
|
}
|
|
@@ -2653,6 +3007,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
|
|
|
2653
3007
|
if (s.avgRating !== void 0) parts.push(`avg \u2605 ${s.avgRating}`);
|
|
2654
3008
|
log(` ${c("dim", parts.join(" \xB7 "))}`);
|
|
2655
3009
|
if (stoppedEarly) log(c("yellow", ` ! Stopped early. Run the same command with --resume to carry on from these ${leads.length}.`));
|
|
3010
|
+
await finishPush(leads);
|
|
2656
3011
|
printSummary();
|
|
2657
3012
|
log("");
|
|
2658
3013
|
}
|