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 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, { signal: controller.signal, onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`), ...resumeHooks });
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
  }