lacspace-leads 1.7.2 → 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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { writeFileSync as writeFileSync2, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
4
+ import { writeFileSync as writeFileSync3, existsSync as existsSync3, readFileSync as readFileSync2, unlinkSync as unlinkSync2 } from "fs";
5
5
  import { resolve as resolve2, extname as extname2 } from "path";
6
6
  import { createInterface } from "readline/promises";
7
7
  import { stdin, stdout, stderr, argv, exit } from "process";
@@ -717,17 +717,6 @@ function rowsToLeads(rows) {
717
717
  }
718
718
 
719
719
  // src/scrape.ts
720
- async function pool(items, n, fn, signal) {
721
- let next = 0;
722
- const workers = Array.from({ length: Math.max(1, Math.min(n, items.length)) }, async () => {
723
- for (; ; ) {
724
- const i = next++;
725
- if (i >= items.length || signal?.aborted) return;
726
- await fn(items[i], i);
727
- }
728
- });
729
- await Promise.all(workers);
730
- }
731
720
  var LeadsError = class extends Error {
732
721
  code;
733
722
  cause;
@@ -765,9 +754,14 @@ function parseLatLng(url) {
765
754
  if (at) return { latitude: parseFloat(at[1]), longitude: parseFloat(at[2]) };
766
755
  return {};
767
756
  }
757
+ function placeKey(href) {
758
+ const id = href.match(/!1s(0x[0-9a-f]+:0x[0-9a-f]+)/i) ?? href.match(/[?&]cid=(\d+)/);
759
+ if (id) return id[1].toLowerCase();
760
+ return href.split("?")[0].replace(/\/@[^/]+/, "").toLowerCase();
761
+ }
768
762
  async function launchBrowser(headless, proxy) {
769
763
  let lastErr;
770
- const base = { headless };
764
+ const base = { headless, handleSIGINT: false };
771
765
  if (proxy) base.proxy = { server: proxy };
772
766
  for (const channel of ["chrome", "msedge"]) {
773
767
  try {
@@ -828,7 +822,7 @@ async function extractDetail(page, fields, fallbackName) {
828
822
  const text = async (sel) => {
829
823
  const loc = page.locator(sel).first();
830
824
  if (await loc.count()) {
831
- const t = (await loc.innerText().catch(() => "")).trim();
825
+ const t = (await loc.innerText({ timeout: 2e3 }).catch(() => "")).trim();
832
826
  return t || void 0;
833
827
  }
834
828
  return void 0;
@@ -836,7 +830,7 @@ async function extractDetail(page, fields, fallbackName) {
836
830
  const aria = async (sel) => {
837
831
  const loc = page.locator(sel).first();
838
832
  if (await loc.count()) {
839
- return await loc.getAttribute("aria-label").catch(() => null) ?? void 0;
833
+ return await loc.getAttribute("aria-label", { timeout: 2e3 }).catch(() => null) ?? void 0;
840
834
  }
841
835
  return void 0;
842
836
  };
@@ -854,14 +848,14 @@ async function extractDetail(page, fields, fallbackName) {
854
848
  const box = page.locator("div.F7nice").first();
855
849
  if (await box.count()) {
856
850
  if (fields.has("rating")) {
857
- const rt = await box.locator('span[aria-hidden="true"]').first().innerText().catch(() => "");
851
+ const rt = await box.locator('span[aria-hidden="true"]').first().innerText({ timeout: 1500 }).catch(() => "");
858
852
  lead.rating = parseRating(rt);
859
853
  }
860
854
  if (fields.has("reviews")) {
861
- const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
855
+ const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label", { timeout: 1500 }).catch(() => null);
862
856
  let count = parseReviewCount(rv);
863
857
  if (count === void 0) {
864
- const boxText = await box.innerText().catch(() => "");
858
+ const boxText = await box.innerText({ timeout: 1500 }).catch(() => "");
865
859
  const m = boxText.match(/\(([\d.,\s]+)\)/);
866
860
  if (m) count = parseReviewCount(m[1]);
867
861
  }
@@ -878,7 +872,7 @@ async function extractDetail(page, fields, fallbackName) {
878
872
  if (fields.has("website")) {
879
873
  const site = page.locator('a[data-item-id="authority"]').first();
880
874
  if (await site.count()) {
881
- lead.website = await site.getAttribute("href").catch(() => null) ?? void 0;
875
+ lead.website = await site.getAttribute("href", { timeout: 2e3 }).catch(() => null) ?? void 0;
882
876
  }
883
877
  }
884
878
  if (fields.has("priceLevel")) {
@@ -934,6 +928,7 @@ async function scrapeLeads(opts) {
934
928
  const near = opts.near;
935
929
  const collect = new Set(fields);
936
930
  if (wantEnrich) collect.add("website");
931
+ if (opts.filters?.hasWebsite || opts.filters?.noWebsite || opts.filters?.hasContact) collect.add("website");
937
932
  if (near) {
938
933
  collect.add("latitude");
939
934
  collect.add("longitude");
@@ -949,6 +944,7 @@ async function scrapeLeads(opts) {
949
944
  locale
950
945
  });
951
946
  const page = await context.newPage();
947
+ page.setDefaultTimeout(5e3);
952
948
  const urlOpts = { hl: locale };
953
949
  if (opts.region) urlOpts.gl = opts.region;
954
950
  if (near) urlOpts.center = { lat: near.lat, lng: near.lng, zoom: zoomForRadius(opts.radiusM ?? opts.zoomRadiusM ?? 2e3, near.lat) };
@@ -976,6 +972,19 @@ async function scrapeLeads(opts) {
976
972
  onProgress?.("no listings found (Google may have shown a CAPTCHA or an empty result).");
977
973
  return [];
978
974
  }
975
+ {
976
+ const seen = /* @__PURE__ */ new Set();
977
+ const uniq = cards.filter((card) => {
978
+ const k = placeKey(card.href);
979
+ if (seen.has(k)) return false;
980
+ seen.add(k);
981
+ return true;
982
+ });
983
+ if (uniq.length !== cards.length) {
984
+ onProgress?.(`${cards.length - uniq.length} listing${cards.length - uniq.length === 1 ? "" : "s"} shown twice in the feed \u2014 opening each place once.`);
985
+ cards.splice(0, cards.length, ...uniq);
986
+ }
987
+ }
979
988
  if (opts.skipListing) {
980
989
  const all = cards;
981
990
  const before = all.length;
@@ -992,9 +1001,87 @@ async function scrapeLeads(opts) {
992
1001
  }
993
1002
  if (all.length === 0) return [];
994
1003
  }
995
- let leads = [];
1004
+ const dedupeBy = opts.dedupe ?? "smart";
1005
+ const seenKeys = /* @__PURE__ */ new Set();
1006
+ const accepted = [];
1007
+ const order = /* @__PURE__ */ new Map();
1008
+ const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
1009
+ const concurrency = Math.max(1, Math.trunc(opts.concurrency ?? 3));
1010
+ let inflight = 0;
1011
+ let enriched = 0;
1012
+ const waiters = [];
1013
+ const tasks = [];
1014
+ const settleOne = () => new Promise((res) => waiters.push(res));
1015
+ const finish = async (lead) => {
1016
+ if ((opts.cleanUrls ?? true) && lead.website) lead.website = cleanWebsite(lead.website);
1017
+ if (near && typeof lead.latitude === "number" && typeof lead.longitude === "number") {
1018
+ const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
1019
+ lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
1020
+ }
1021
+ if (near && opts.radiusM !== void 0 && !(lead.distanceKm !== void 0 && lead.distanceKm * 1e3 <= opts.radiusM)) return;
1022
+ const key = dedupeKey(lead, dedupeBy);
1023
+ if (key !== void 0) {
1024
+ if (seenKeys.has(key)) return;
1025
+ seenKeys.add(key);
1026
+ }
1027
+ if (wantEnrich && lead.website && !overBudget() && !signal?.aborted) {
1028
+ onProgress?.(`enriching ${++enriched}: ${lead.name ?? lead.website}\u2026`);
1029
+ try {
1030
+ const c2 = await enrichContacts(lead.website);
1031
+ if (fields.has("email") && c2.email) lead.email = c2.email;
1032
+ for (const f of socialFields) {
1033
+ const v = c2[f];
1034
+ if (fields.has(f) && v) lead[f] = v;
1035
+ }
1036
+ } catch {
1037
+ }
1038
+ }
1039
+ if (wantVerify && lead.email && !signal?.aborted) {
1040
+ try {
1041
+ await verifyEmails([lead], { concurrency: 1 });
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ if (opts.country && fields.has("phone") && lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
1046
+ if (opts.filters && filterLeads([lead], opts.filters).length === 0) return;
1047
+ if (!fields.has("website")) delete lead.website;
1048
+ if (wantVerify && !fields.has("emailStatus")) delete lead.emailStatus;
1049
+ if (near) {
1050
+ if (!fields.has("latitude")) delete lead.latitude;
1051
+ if (!fields.has("longitude")) delete lead.longitude;
1052
+ if (!fields.has("distanceKm")) delete lead.distanceKm;
1053
+ }
1054
+ accepted.push(lead);
1055
+ try {
1056
+ opts.onResult?.(lead);
1057
+ } catch {
1058
+ }
1059
+ };
1060
+ const schedule = async (lead, index) => {
1061
+ order.set(lead, index);
1062
+ while (inflight >= concurrency) await settleOne();
1063
+ inflight++;
1064
+ tasks.push(finish(lead).catch(() => {
1065
+ }).finally(() => {
1066
+ inflight--;
1067
+ waiters.shift()?.();
1068
+ }));
1069
+ };
1070
+ const wantMore = async () => {
1071
+ if (!opts.remaining) return true;
1072
+ for (; ; ) {
1073
+ const left = opts.remaining();
1074
+ if (left <= 0) return false;
1075
+ if (left - inflight > 0) return true;
1076
+ if (inflight === 0) return true;
1077
+ await settleOne();
1078
+ }
1079
+ };
1080
+ let read = 0;
996
1081
  if (!wantDetails) {
997
- leads = cards.map((c2) => {
1082
+ for (let i = 0; i < cards.length; i++) {
1083
+ if (signal?.aborted || !await wantMore()) break;
1084
+ const c2 = cards[i];
998
1085
  const lead = {};
999
1086
  if (collect.has("name")) lead.name = c2.name;
1000
1087
  if (collect.has("mapsUrl")) lead.mapsUrl = c2.href;
@@ -1003,17 +1090,16 @@ async function scrapeLeads(opts) {
1003
1090
  if (collect.has("latitude")) lead.latitude = geo.latitude;
1004
1091
  if (collect.has("longitude")) lead.longitude = geo.longitude;
1005
1092
  }
1006
- return lead;
1007
- });
1008
- for (const lead of leads) {
1093
+ read++;
1009
1094
  try {
1010
1095
  onLead?.(lead);
1011
1096
  } catch {
1012
1097
  }
1098
+ await schedule(lead, i);
1013
1099
  }
1014
1100
  } else {
1015
1101
  for (let i = 0; i < cards.length; i++) {
1016
- if (signal?.aborted || overBudget()) break;
1102
+ if (signal?.aborted || overBudget() || !await wantMore()) break;
1017
1103
  const card = cards[i];
1018
1104
  onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
1019
1105
  let lead;
@@ -1039,84 +1125,20 @@ async function scrapeLeads(opts) {
1039
1125
  }
1040
1126
  }
1041
1127
  if (lead) {
1042
- leads.push(lead);
1128
+ read++;
1043
1129
  try {
1044
1130
  onLead?.(lead);
1045
1131
  } catch {
1046
1132
  }
1133
+ await schedule(lead, i);
1047
1134
  }
1048
1135
  if (delayMs) await page.waitForTimeout(pauseMs());
1049
1136
  }
1050
1137
  }
1051
- onProgress?.(`collected ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
1052
- if (opts.cleanUrls ?? true) {
1053
- for (const lead of leads) {
1054
- if (lead.website) lead.website = cleanWebsite(lead.website);
1055
- }
1056
- }
1057
- if (near) {
1058
- for (const lead of leads) {
1059
- if (typeof lead.latitude === "number" && typeof lead.longitude === "number") {
1060
- const m = haversineMeters(near, { lat: lead.latitude, lng: lead.longitude });
1061
- lead.distanceKm = Math.round(m / 1e3 * 100) / 100;
1062
- }
1063
- }
1064
- if (opts.radiusM !== void 0) {
1065
- const r = opts.radiusM;
1066
- leads = leads.filter((l) => l.distanceKm !== void 0 && l.distanceKm * 1e3 <= r);
1067
- onProgress?.(`within ${Math.round(r)} m: ${leads.length} listing${leads.length === 1 ? "" : "s"}.`);
1068
- }
1069
- }
1070
- leads = dedupeLeads(leads, opts.dedupe ?? "website");
1071
- if (wantEnrich) {
1072
- const socialFields = ENRICHED_FIELDS.filter((f) => f !== "email");
1073
- let done = 0;
1074
- await pool(
1075
- leads,
1076
- Math.max(1, Math.trunc(opts.concurrency ?? 3)),
1077
- async (lead) => {
1078
- if (overBudget()) return;
1079
- const site = lead.website;
1080
- if (site) {
1081
- onProgress?.(`enriching ${++done}/${leads.length}: ${lead.name ?? site}\u2026`);
1082
- const c2 = await enrichContacts(site);
1083
- if (fields.has("email") && c2.email) lead.email = c2.email;
1084
- for (const f of socialFields) {
1085
- const v = c2[f];
1086
- if (fields.has(f) && v) lead[f] = v;
1087
- }
1088
- }
1089
- if (!fields.has("website")) delete lead.website;
1090
- },
1091
- signal
1092
- );
1093
- }
1094
- if (wantVerify && !signal?.aborted) {
1095
- onProgress?.("verifying email domains\u2026");
1096
- const vOpts = {
1097
- concurrency: Math.max(1, Math.trunc(opts.concurrency ?? 5))
1098
- };
1099
- if (onProgress) vOpts.onProgress = (m) => onProgress(m);
1100
- await verifyEmails(leads, vOpts);
1101
- }
1102
- if (opts.country && fields.has("phone")) {
1103
- for (const lead of leads) {
1104
- if (lead.phone) lead.phone = normalizePhone(lead.phone, opts.country);
1105
- }
1106
- }
1107
- if (opts.filters) leads = filterLeads(leads, opts.filters);
1138
+ await Promise.all(tasks);
1139
+ onProgress?.(`read ${read} listing${read === 1 ? "" : "s"}.`);
1140
+ let leads = accepted.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
1108
1141
  if (opts.sort) leads = sortLeads(leads, opts.sort, opts.sortDir);
1109
- if (wantVerify && !fields.has("emailStatus")) for (const l of leads) delete l.emailStatus;
1110
- if (near) {
1111
- const keepLat = fields.has("latitude");
1112
- const keepLng = fields.has("longitude");
1113
- const keepDist = fields.has("distanceKm");
1114
- for (const l of leads) {
1115
- if (!keepLat) delete l.latitude;
1116
- if (!keepLng) delete l.longitude;
1117
- if (!keepDist) delete l.distanceKm;
1118
- }
1119
- }
1120
1142
  onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
1121
1143
  return leads;
1122
1144
  } finally {
@@ -1146,8 +1168,29 @@ async function searchLeadsBatch(queries, opts = {}) {
1146
1168
  void _q;
1147
1169
  let merged = seedLeads ? [...seedLeads] : [];
1148
1170
  const seenDedupe = shared.dedupe ?? "website";
1171
+ const userOnResult = shared.onResult;
1172
+ const userRemaining = shared.remaining;
1173
+ const liveKeys = /* @__PURE__ */ new Set();
1174
+ for (const l of merged) {
1175
+ const k = dedupeKey(l, seenDedupe);
1176
+ if (k !== void 0) liveKeys.add(k);
1177
+ }
1178
+ let live = merged.length;
1179
+ const onResult = (lead) => {
1180
+ const k = dedupeKey(lead, seenDedupe);
1181
+ if (k !== void 0) {
1182
+ if (liveKeys.has(k)) return;
1183
+ liveKeys.add(k);
1184
+ }
1185
+ live++;
1186
+ try {
1187
+ userOnResult?.(lead);
1188
+ } catch {
1189
+ }
1190
+ };
1191
+ const remaining = () => Math.min(total !== void 0 && total > 0 ? total - live : Infinity, userRemaining ? userRemaining() : Infinity);
1149
1192
  for (let i = 0; i < queries.length; i++) {
1150
- if (signal?.aborted) break;
1193
+ if (signal?.aborted || remaining() <= 0) break;
1151
1194
  const q = queries[i];
1152
1195
  const label = (() => {
1153
1196
  try {
@@ -1170,6 +1213,8 @@ async function searchLeadsBatch(queries, opts = {}) {
1170
1213
  ...q.area ? { area: q.area } : {}
1171
1214
  };
1172
1215
  delete subOpts.sort;
1216
+ subOpts.onResult = onResult;
1217
+ if (userRemaining || total !== void 0 && total > 0) subOpts.remaining = remaining;
1173
1218
  if (signal) subOpts.signal = signal;
1174
1219
  if (onProgress) subOpts.onProgress = (m) => onProgress(` ${m}`);
1175
1220
  try {
@@ -1189,16 +1234,18 @@ async function searchLeadsBatch(queries, opts = {}) {
1189
1234
 
1190
1235
  // src/config.ts
1191
1236
  async function runConfig(config, hooks = {}) {
1192
- const { searches, filters, total, out, format, append, sheet, ...shared } = config;
1237
+ const { searches, filters, total, out, format, append, sheet, push, ...shared } = config;
1193
1238
  void out;
1194
1239
  void format;
1195
1240
  void append;
1196
1241
  void sheet;
1242
+ void push;
1197
1243
  const opts = { ...shared };
1198
1244
  if (filters) opts.filters = filters;
1199
1245
  if (total !== void 0) opts.total = total;
1200
1246
  if (hooks.onProgress) opts.onProgress = hooks.onProgress;
1201
1247
  if (hooks.signal) opts.signal = hooks.signal;
1248
+ if (hooks.onResult) opts.onResult = hooks.onResult;
1202
1249
  if (hooks.skip) opts.skip = hooks.skip;
1203
1250
  if (hooks.seedLeads) opts.seedLeads = hooks.seedLeads;
1204
1251
  if (hooks.onQueryDone) opts.onQueryDone = hooks.onQueryDone;
@@ -1210,6 +1257,12 @@ function assertConfig(value) {
1210
1257
  if (!Array.isArray(searches) || searches.length === 0) {
1211
1258
  throw new Error('Config needs a non-empty "searches" array, e.g. [{ "type": "cafes", "city": "Kathmandu" }].');
1212
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
+ }
1213
1266
  }
1214
1267
 
1215
1268
  // src/convert.ts
@@ -1430,6 +1483,151 @@ function formatSummary(s) {
1430
1483
  return lines.join("\n");
1431
1484
  }
1432
1485
 
1486
+ // src/live.ts
1487
+ import { appendFileSync, closeSync, existsSync as existsSync2, fstatSync, openSync, renameSync as renameSync2, writeFileSync as writeFileSync2, writeSync } from "fs";
1488
+ import { stringify as csvStringify3 } from "@lacspace/csv";
1489
+ var pick = (lead, fields) => {
1490
+ const o = {};
1491
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
1492
+ return o;
1493
+ };
1494
+ function createLiveWriter(opts) {
1495
+ const { file, format, fields } = opts;
1496
+ const by = opts.dedupe ?? "smart";
1497
+ const toStdout = file === "-" || !!opts.stdout;
1498
+ const out = opts.stdout ?? { write: (c2) => process.stdout.write(c2) };
1499
+ if (toStdout && (format === "json" || format === "xlsx")) throw new Error(`live output to stdout needs csv or ndjson, not ${format}`);
1500
+ const rows = [];
1501
+ const keys = /* @__PURE__ */ new Set();
1502
+ let added = 0;
1503
+ let failed = false;
1504
+ let dirty = false;
1505
+ let jsonFd;
1506
+ const warn = (err) => {
1507
+ if (!failed) opts.onWriteError?.(err);
1508
+ failed = true;
1509
+ dirty = true;
1510
+ };
1511
+ const atomic = (data) => {
1512
+ const tmp = `${file}.tmp`;
1513
+ writeFileSync2(tmp, data);
1514
+ renameSync2(tmp, file);
1515
+ };
1516
+ const csvLine = (lead) => csvStringify3(toRows([lead], fields), { header: false, escapeFormulas: true });
1517
+ const csvHeader = () => csvStringify3([toRows([{}], fields)[0]], { escapeFormulas: true }).split(/\r?\n/)[0] + "\n";
1518
+ const nl = (s) => s.endsWith("\n") ? s : s + "\n";
1519
+ const rewrite = (list) => {
1520
+ const { data, binary } = serialize([...list], format, fields, opts.sheetName ? { sheetName: opts.sheetName } : {});
1521
+ if (format === "csv" && !list.length) atomic(csvHeader());
1522
+ else atomic(binary ? Buffer.from(data) : format === "json" ? data : nl(data));
1523
+ };
1524
+ const jsonAppend = (lead) => {
1525
+ jsonFd ??= openSync(file, "r+");
1526
+ const size = fstatSync(jsonFd).size;
1527
+ const body = JSON.stringify(pick(lead, fields), null, 2).replace(/^/gm, " ");
1528
+ const chunk = (rows.length === 1 ? "\n" : ",\n") + body + "\n]";
1529
+ writeSync(jsonFd, chunk, Math.max(1, size - (rows.length === 1 ? 1 : 2)));
1530
+ };
1531
+ const start = () => {
1532
+ for (const l of opts.seed ?? []) {
1533
+ const k = dedupeKey(l, by);
1534
+ if (k !== void 0) keys.add(k);
1535
+ rows.push(l);
1536
+ }
1537
+ if (toStdout) {
1538
+ if (format === "csv") out.write(csvHeader());
1539
+ return;
1540
+ }
1541
+ try {
1542
+ if (format === "json" && !rows.length) atomic("[]");
1543
+ else rewrite(rows);
1544
+ } catch (e) {
1545
+ warn(e);
1546
+ }
1547
+ };
1548
+ const write = (lead) => {
1549
+ if (toStdout) {
1550
+ out.write(format === "csv" ? nl(csvLine(lead)) : JSON.stringify(pick(lead, fields)) + "\n");
1551
+ return;
1552
+ }
1553
+ if (dirty) {
1554
+ try {
1555
+ if (jsonFd !== void 0) {
1556
+ closeSync(jsonFd);
1557
+ jsonFd = void 0;
1558
+ }
1559
+ rewrite(rows);
1560
+ dirty = false;
1561
+ } catch (e) {
1562
+ warn(e);
1563
+ }
1564
+ return;
1565
+ }
1566
+ try {
1567
+ if (format === "csv") appendFileSync(file, nl(csvLine(lead)));
1568
+ else if (format === "ndjson") appendFileSync(file, JSON.stringify(pick(lead, fields)) + "\n");
1569
+ else if (format === "json") {
1570
+ if (rows.length > 1 && !existsSync2(file)) throw new Error("output file disappeared");
1571
+ jsonAppend(lead);
1572
+ } else rewrite(rows);
1573
+ } catch (e) {
1574
+ warn(e);
1575
+ }
1576
+ };
1577
+ start();
1578
+ return {
1579
+ add(lead) {
1580
+ const k = dedupeKey(lead, by);
1581
+ if (k !== void 0) {
1582
+ if (keys.has(k)) return false;
1583
+ keys.add(k);
1584
+ }
1585
+ rows.push(lead);
1586
+ added++;
1587
+ write(lead);
1588
+ return true;
1589
+ },
1590
+ get count() {
1591
+ return rows.length;
1592
+ },
1593
+ get added() {
1594
+ return added;
1595
+ },
1596
+ get leads() {
1597
+ return rows;
1598
+ },
1599
+ flush() {
1600
+ if (toStdout || !dirty) return;
1601
+ try {
1602
+ if (jsonFd !== void 0) {
1603
+ closeSync(jsonFd);
1604
+ jsonFd = void 0;
1605
+ }
1606
+ rewrite(rows);
1607
+ dirty = false;
1608
+ } catch (e) {
1609
+ warn(e);
1610
+ }
1611
+ },
1612
+ finish(final) {
1613
+ if (jsonFd !== void 0) {
1614
+ try {
1615
+ closeSync(jsonFd);
1616
+ } catch {
1617
+ }
1618
+ jsonFd = void 0;
1619
+ }
1620
+ if (toStdout) return;
1621
+ try {
1622
+ rewrite(final ?? rows);
1623
+ dirty = false;
1624
+ } catch (e) {
1625
+ warn(e);
1626
+ }
1627
+ }
1628
+ };
1629
+ }
1630
+
1433
1631
  // src/pipe.ts
1434
1632
  function leadDomain(website) {
1435
1633
  if (!website) return void 0;
@@ -1463,6 +1661,262 @@ function leadsToEnrichInput(leads) {
1463
1661
  return out;
1464
1662
  }
1465
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
+
1466
1920
  // src/sweep.ts
1467
1921
  var MAX_PER_SEARCH = 120;
1468
1922
  var MIN_PER_STEP = 40;
@@ -1531,8 +1985,26 @@ async function sweepLeads(opts) {
1531
1985
  stats.unique = collected.length;
1532
1986
  return collected.length - before;
1533
1987
  };
1534
- const enough = () => collected.length >= want;
1535
- const stepLimit = () => Math.min(cap, Math.max(MIN_PER_STEP, (want - collected.length) * 2));
1988
+ const userOnResult = shared.onResult;
1989
+ const userRemaining = shared.remaining;
1990
+ const userSkip = shared.skipListing;
1991
+ const liveKeys = /* @__PURE__ */ new Set();
1992
+ let live = 0;
1993
+ const onResult = (lead) => {
1994
+ const k = dedupeKey(lead, dedupeBy);
1995
+ if (k !== void 0) {
1996
+ if (liveKeys.has(k)) return;
1997
+ liveKeys.add(k);
1998
+ }
1999
+ live++;
2000
+ try {
2001
+ userOnResult?.(lead);
2002
+ } catch {
2003
+ }
2004
+ };
2005
+ const remaining = () => Math.min(want - Math.max(live, collected.length), userRemaining ? userRemaining() : Infinity);
2006
+ const enough = () => remaining() <= 0;
2007
+ const stepLimit = () => Math.min(cap, Math.max(MIN_PER_STEP, Math.max(0, remaining()) * 2));
1536
2008
  let center = near ? { lat: near.lat, lng: near.lng } : void 0;
1537
2009
  const captureCenter = (c2) => {
1538
2010
  center ??= c2;
@@ -1543,7 +2015,9 @@ async function sweepLeads(opts) {
1543
2015
  ...shared,
1544
2016
  ...step,
1545
2017
  limit: stepLimit(),
1546
- onCenter: captureCenter
2018
+ onCenter: captureCenter,
2019
+ onResult,
2020
+ remaining
1547
2021
  };
1548
2022
  const found = await scrapeLeads(searchOpts);
1549
2023
  stats.searches++;
@@ -1569,7 +2043,9 @@ async function sweepLeads(opts) {
1569
2043
  limit: stepLimit(),
1570
2044
  near: point,
1571
2045
  onCenter: captureCenter,
1572
- skipListing
2046
+ skipListing: (l) => skipListing(l) || (userSkip?.(l) ?? false),
2047
+ onResult,
2048
+ remaining
1573
2049
  };
1574
2050
  delete tileOpts.area;
1575
2051
  delete tileOpts.city;
@@ -1667,6 +2143,7 @@ function parseArgs(list) {
1667
2143
  jitter: false,
1668
2144
  resume: false,
1669
2145
  summary: false,
2146
+ live: true,
1670
2147
  yes: false,
1671
2148
  help: false
1672
2149
  };
@@ -1723,10 +2200,14 @@ function parseArgs(list) {
1723
2200
  else if (arg === "--business-status") a.businessStatus = next();
1724
2201
  else if (arg === "--name-exclude" || arg === "--exclude-names") a.nameExclude = next();
1725
2202
  else if (arg === "--resume") a.resume = true;
2203
+ else if (arg === "--no-live") a.live = false;
2204
+ else if (arg === "--live") a.live = true;
1726
2205
  else if (arg === "--summary") a.summary = true;
1727
2206
  else if (arg === "--dedupe-across") a.dedupeAcross = next();
1728
2207
  else if (arg === "--enrich-out") a.enrichOut = next();
1729
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();
1730
2211
  else if (arg === "--dedupe") a.dedupe = next();
1731
2212
  else if (arg === "--sort") a.sort = next();
1732
2213
  else if (arg === "--desc") a.desc = true;
@@ -1800,8 +2281,8 @@ ${c("bold", "Filters & order")}
1800
2281
  --category <text> Keep only leads whose category/tags contain this text
1801
2282
  --business-status <s> Keep only this status (operational | closed | temporarily-closed)
1802
2283
  --name-exclude <list> Drop leads whose name contains any of these terms
1803
- --dedupe <key> website | phone | name | smart | none (default website;
1804
- --append uses smart: website\u2192phone\u2192name)
2284
+ --dedupe <key> website | phone | name | smart | none (default smart:
2285
+ same website, else same phone, else same name)
1805
2286
  --dedupe-across <file> Drop leads already present in an existing master file
1806
2287
  --sort <key> rating | reviews | name | priceLevel | distance
1807
2288
  --desc / --asc Sort direction (distance defaults nearest-first)
@@ -1817,7 +2298,19 @@ ${c("bold", "Output")}
1817
2298
  --sheet <name> Excel sheet name (default "Leads")
1818
2299
  --summary Print run stats (rating bands, % with contact, top categories)
1819
2300
  --enrich-out <f> Also write { name, website, domain } NDJSON for lacspace-enrich
1820
- --resume Resume an interrupted city sweep from its checkpoint file
2301
+ --resume Continue an interrupted run: keeps the rows already in the
2302
+ output file, skips those listings and fills up to --target
2303
+ (multi-area sweeps also use their checkpoint file)
2304
+ --no-live Write the file only at the end. By default every lead is
2305
+ added to the file the moment it is found (csv/ndjson rows
2306
+ append, json stays valid, xlsx is rewritten), so stopping
2307
+ early never loses what was collected
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)
1821
2314
 
1822
2315
  ${c("bold", "Runtime")}
1823
2316
  --delay <ms> Pause between listings (default 700)
@@ -1845,6 +2338,7 @@ ${c("bold", "Examples")}
1845
2338
  npx lacspace-leads gyms --city Lalitpur --dedupe-across master.csv -o new.csv --append
1846
2339
  npx lacspace-leads clinics --city Pokhara --enrich-out sites.ndjson # feed lacspace-enrich
1847
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
1848
2342
 
1849
2343
  ${c("dim", "Please scrape responsibly: keep volumes small, respect Google's Terms of")}
1850
2344
  ${c("dim", "Service and local data-protection law, and use only public business data.")}
@@ -1858,6 +2352,43 @@ async function prompt(q, fallback = "") {
1858
2352
  rl.close();
1859
2353
  }
1860
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)")}`;
1861
2392
  async function runConvert(rest) {
1862
2393
  const a = parseArgs(rest);
1863
2394
  const input = rest.find((x) => !x.startsWith("-") && x !== a.format && x !== a.out && x !== a.sheet);
@@ -1913,6 +2444,8 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1913
2444
  exit(1);
1914
2445
  return;
1915
2446
  }
2447
+ const pushCfg = setupPush(args, config);
2448
+ if (pushCfg) log(` ${c("dim", "push")} ${pushEcho(pushCfg)}`);
1916
2449
  log(` ${c("dim", "searches")} ${config.searches.length} ${c("dim", "format")} ${format}${config.append ? c("dim", " (append)") : ""}${args.resume ? c("dim", " (resume)") : ""}
1917
2450
  `);
1918
2451
  const out = resolve2(args.out ?? config.out ?? defaultFilename("leads-campaign", format));
@@ -1932,15 +2465,28 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1932
2465
  saveCheckpoint(file, cp);
1933
2466
  };
1934
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;
1935
2475
  const controller = new AbortController();
1936
2476
  const onSig = () => controller.abort();
1937
2477
  process.once("SIGINT", onSig);
1938
2478
  let leads;
1939
2479
  try {
1940
- 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
+ });
1941
2486
  } catch (err) {
1942
2487
  log(c("red", `
1943
2488
  \u2717 ${err.message}`));
2489
+ await endPush(pusher, computeStats([]), 3e3);
1944
2490
  exit(1);
1945
2491
  return;
1946
2492
  } finally {
@@ -1949,10 +2495,11 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1949
2495
  if (!leads.length) {
1950
2496
  log(c("yellow", "\n No leads collected.\n"));
1951
2497
  if (cpFile) clearCheckpoint(cpFile);
2498
+ await endPush(pusher, computeStats([]));
1952
2499
  return;
1953
2500
  }
1954
2501
  const fields = config.fields ? normalizeFields(config.fields) : ALL_FIELDS.filter((f) => leads.some((l) => l[f] !== void 0));
1955
- if (args.dedupeAcross && existsSync2(args.dedupeAcross)) {
2502
+ if (args.dedupeAcross && existsSync3(args.dedupeAcross)) {
1956
2503
  try {
1957
2504
  const master = rowsToLeads(await readRows(args.dedupeAcross));
1958
2505
  const before = leads.length;
@@ -1962,7 +2509,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1962
2509
  log(c("yellow", ` ! couldn't read --dedupe-across (${err.message})`));
1963
2510
  }
1964
2511
  }
1965
- if ((args.append || config.append) && existsSync2(out)) {
2512
+ if ((args.append || config.append) && existsSync3(out)) {
1966
2513
  try {
1967
2514
  const existing = rowsToLeads(await readRows(out));
1968
2515
  const before = existing.length;
@@ -1976,18 +2523,19 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1976
2523
  const inputs = leadsToEnrichInput(leads);
1977
2524
  const nd = inputs.map((o) => JSON.stringify(o)).join("\n") + (inputs.length ? "\n" : "");
1978
2525
  const enrichPath = resolve2(args.enrichOut);
1979
- writeFileSync2(enrichPath, nd);
2526
+ writeFileSync3(enrichPath, nd);
1980
2527
  log(` ${c("cyan", "\u25F7")} ${c("dim", `wrote ${inputs.length} enrich input${inputs.length === 1 ? "" : "s"} \u2192 ${enrichPath}`)}`);
1981
2528
  }
1982
2529
  const serOpts = {};
1983
2530
  if (args.sheet ?? config.sheet) serOpts.sheetName = args.sheet ?? config.sheet;
1984
2531
  const { data, binary } = serialize(leads, format, fields, serOpts);
1985
- writeFileSync2(out, binary ? Buffer.from(data) : data);
2532
+ writeFileSync3(out, binary ? Buffer.from(data) : data);
1986
2533
  if (cpFile) clearCheckpoint(cpFile);
1987
2534
  const s = computeStats(leads);
1988
2535
  log(`
1989
2536
  ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}`);
1990
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);
1991
2539
  if (args.summary) {
1992
2540
  log("");
1993
2541
  formatSummary(summarize(leads)).split("\n").forEach((line, i) => {
@@ -2052,6 +2600,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
2052
2600
  exit(1);
2053
2601
  return;
2054
2602
  }
2603
+ const pushCfg = setupPush(args);
2055
2604
  const nearPoint = args.near ? parseLatLngPair(args.near) : void 0;
2056
2605
  const tileStepM = args.step ? parseDistance(args.step) : void 0;
2057
2606
  if (!args.details && (args.split || args.noWebsite)) {
@@ -2146,6 +2695,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads"))} ${c("dim", "\u2014 Google Ma
2146
2695
  else if (nearPoint) log(` ${c("dim", "sort")} distance (nearest first)`);
2147
2696
  if (args.country) log(` ${c("dim", "phones")} E.164 for ${args.country}`);
2148
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)")}`);
2149
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)") : ""}`);
2150
2700
  if (!args.yes) {
2151
2701
  const ok = await prompt(`
@@ -2158,9 +2708,103 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2158
2708
  log("");
2159
2709
  const controller = new AbortController();
2160
2710
  const onSig = () => {
2711
+ if (controller.signal.aborted) {
2712
+ writerRef.current?.flush();
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
+ }
2720
+ exit(130);
2721
+ }
2161
2722
  controller.abort();
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)."));
2724
+ };
2725
+ const writerRef = {};
2726
+ const pusherRef = {};
2727
+ process.on("SIGINT", onSig);
2728
+ let existing = [];
2729
+ if ((args.append || args.resume) && !toStdout && existsSync3(out)) {
2730
+ try {
2731
+ existing = rowsToLeads(await readRows(out));
2732
+ } catch (err) {
2733
+ log(c("yellow", ` ! couldn't read "${out}" (${err.message}); starting it fresh`));
2734
+ }
2735
+ }
2736
+ let master;
2737
+ if (args.dedupeAcross && existsSync3(args.dedupeAcross)) {
2738
+ try {
2739
+ master = rowsToLeads(await readRows(args.dedupeAcross));
2740
+ } catch (err) {
2741
+ log(c("yellow", ` ! couldn't read --dedupe-across "${args.dedupeAcross}" (${err.message}); keeping all`));
2742
+ }
2743
+ }
2744
+ const dedupeBy = args.dedupe ?? "smart";
2745
+ const inMaster = master?.length ? (l) => subtractLeads([l], master, dedupeBy).length === 0 : void 0;
2746
+ const resumeRows = args.resume && !isBatch ? existing : [];
2747
+ if (resumeRows.length) log(` ${c("cyan", "\u25F7")} ${c("dim", `resuming \u2014 ${resumeRows.length} leads already in ${out}; skipping those listings`)}`);
2748
+ const liveFormat = toStdout ? args.format === "csv" || args.format === "ndjson" : true;
2749
+ let writer;
2750
+ const outExisted = !toStdout && existsSync3(out);
2751
+ const dropEmpty = () => {
2752
+ if (!writer || toStdout) return;
2753
+ if (outExisted) writer.finish([...existing]);
2754
+ else {
2755
+ writer.finish([]);
2756
+ try {
2757
+ unlinkSync2(out);
2758
+ } catch {
2759
+ }
2760
+ }
2761
+ };
2762
+ if (args.live && liveFormat && !(toStdout && args.split)) {
2763
+ try {
2764
+ writer = createLiveWriter({
2765
+ file: out,
2766
+ format: args.format,
2767
+ fields,
2768
+ dedupe: dedupeBy,
2769
+ seed: existing,
2770
+ ...args.sheet ? { sheetName: args.sheet } : {},
2771
+ ...toStdout ? { stdout } : {},
2772
+ onWriteError: (e) => log(c("yellow", ` ! couldn't write ${out} (${e.message}) \u2014 is it open in another app? Rows are kept and written as soon as it's free.`))
2773
+ });
2774
+ writerRef.current = writer;
2775
+ if (!toStdout) log(` ${c("cyan", "\u25F7")} ${c("dim", `writing live \u2192 ${out}`)}`);
2776
+ } catch (err) {
2777
+ log(c("yellow", ` ! live output off: ${err.message}`));
2778
+ }
2779
+ }
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
+ };
2793
+ const startedAt = Date.now();
2794
+ let fresh0 = 0;
2795
+ const onResult = (lead) => {
2796
+ if (inMaster?.(lead)) return;
2797
+ if (writer && !writer.add(lead)) return;
2798
+ pusher?.push(lead);
2799
+ fresh0++;
2800
+ const have = resumeRows.length + fresh0;
2801
+ const mins = (Date.now() - startedAt) / 6e4;
2802
+ const rate = mins > 0.05 ? fresh0 / mins : 0;
2803
+ const left = goal !== void 0 ? Math.max(0, goal - have) : 0;
2804
+ const eta = rate > 0 && left > 0 ? `, ~${Math.ceil(left / rate)} min left` : "";
2805
+ const bits = [lead.phone, lead.website?.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""), lead.email].filter(Boolean).join(" \xB7 ");
2806
+ log(` ${c("green", "\u271A")} ${c("bold", goal !== void 0 ? `${have}/${goal}` : String(have))} ${lead.name ?? "(no name)"}${bits ? c("dim", ` \u2014 ${bits}`) : ""}${rate ? c("dim", ` (${rate.toFixed(1)}/min${eta})`) : ""}`);
2162
2807
  };
2163
- process.once("SIGINT", onSig);
2164
2808
  const opts = {
2165
2809
  type: args.type ?? "",
2166
2810
  query: args.query ?? "",
@@ -2176,10 +2820,17 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2176
2820
  verifyEmails: args.verifyEmails,
2177
2821
  jitter: args.jitter,
2178
2822
  signal: controller.signal,
2179
- onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
2823
+ onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`),
2824
+ onResult
2180
2825
  };
2826
+ if (resumeRows.length) {
2827
+ const names = new Set(resumeRows.map((l) => l.name?.trim().toLowerCase()).filter(Boolean));
2828
+ const urls = new Set(resumeRows.map((l) => l.mapsUrl).filter(Boolean));
2829
+ opts.skipListing = (l) => urls.has(l.href) || !!l.name && names.has(l.name.trim().toLowerCase());
2830
+ }
2831
+ if (args.target !== void 0 && (resumeRows.length || inMaster)) opts.remaining = () => args.target - resumeRows.length - fresh0;
2181
2832
  if (hasFilters) opts.filters = filters;
2182
- if (args.dedupe) opts.dedupe = args.dedupe;
2833
+ opts.dedupe = dedupeBy;
2183
2834
  if (nearPoint) opts.near = { lat: nearPoint.lat, lng: nearPoint.lng };
2184
2835
  if (radiusM !== void 0) opts.radiusM = radiusM;
2185
2836
  if (args.sort) opts.sort = args.sort;
@@ -2239,13 +2890,25 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2239
2890
  leads = await scrapeLeads(opts);
2240
2891
  }
2241
2892
  } catch (err) {
2893
+ if (writer && writer.added) {
2894
+ writer.flush();
2895
+ log(c("yellow", `
2896
+ ! ${err.message}`));
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.
2898
+ `);
2899
+ await finishPush([...writer.leads], 3e3);
2900
+ exit(controller.signal.aborted ? 130 : 1);
2901
+ return;
2902
+ }
2242
2903
  log(c("red", `
2243
2904
  \u2717 ${err.message}`));
2905
+ await finishPush([], 3e3);
2244
2906
  exit(1);
2245
2907
  return;
2246
2908
  } finally {
2247
2909
  process.removeListener("SIGINT", onSig);
2248
2910
  }
2911
+ const stoppedEarly = controller.signal.aborted;
2249
2912
  if (sweptShort) {
2250
2913
  log(c("yellow", `
2251
2914
  ! The map ran out of new results at ${leads.length} \u2014 that is everything Google lists here.`));
@@ -2256,44 +2919,39 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2256
2919
  log(c("dim", ` To really get ${args.limit}, ask for a total: --target ${args.limit} \u2014 it sweeps every area you name, then tiles the map.
2257
2920
  `));
2258
2921
  }
2259
- if (leads.length === 0) {
2922
+ if (leads.length === 0 && !resumeRows.length) {
2260
2923
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
2924
+ dropEmpty();
2261
2925
  if (cpFile) clearCheckpoint(cpFile);
2926
+ await finishPush([]);
2262
2927
  return;
2263
2928
  }
2264
- if (args.dedupeAcross && existsSync2(args.dedupeAcross)) {
2265
- try {
2266
- const master = rowsToLeads(await readRows(args.dedupeAcross));
2267
- const before = leads.length;
2268
- leads = subtractLeads(leads, master, args.dedupe ?? "smart");
2269
- log(` ${c("cyan", "\u25F7")} ${c("dim", `dropped ${before - leads.length} already in ${args.dedupeAcross} \u2192 ${leads.length} new`)}`);
2270
- } catch (err) {
2271
- log(c("yellow", ` ! couldn't read --dedupe-across "${args.dedupeAcross}" (${err.message}); keeping all`));
2272
- }
2929
+ if (master) {
2930
+ const before = leads.length;
2931
+ leads = subtractLeads(leads, master, dedupeBy);
2932
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `dropped ${before - leads.length} already in ${args.dedupeAcross} \u2192 ${leads.length} new`)}`);
2273
2933
  if (leads.length === 0) {
2274
2934
  log(c("yellow", "\n Nothing new \u2014 every lead was already in the master file.\n"));
2935
+ dropEmpty();
2275
2936
  if (cpFile) clearCheckpoint(cpFile);
2937
+ await finishPush([]);
2276
2938
  return;
2277
2939
  }
2278
2940
  }
2279
2941
  let fresh = leads.length;
2280
- if (args.append && !toStdout && existsSync2(out)) {
2281
- try {
2282
- const existing = rowsToLeads(await readRows(out));
2283
- const before = existing.length;
2284
- const merged = dedupeLeads([...existing, ...leads], args.dedupe ?? "smart");
2285
- fresh = merged.length - before;
2286
- log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${merged.length} total (${fresh} new)`)}`);
2287
- leads = merged;
2288
- } catch (err) {
2289
- log(c("yellow", ` ! couldn't read "${out}" to append (${err.message}); overwriting instead`));
2290
- }
2942
+ if ((args.append || resumeRows.length) && !toStdout && existing.length) {
2943
+ const before = existing.length;
2944
+ const merged = dedupeLeads([...existing, ...leads], dedupeBy);
2945
+ fresh = merged.length - before;
2946
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${merged.length} total (${fresh} new)`)}`);
2947
+ leads = merged;
2948
+ if (args.target !== void 0 && resumeRows.length) leads = leads.slice(0, Math.max(args.target, before));
2291
2949
  }
2292
2950
  if (args.enrichOut) {
2293
2951
  const inputs = leadsToEnrichInput(leads);
2294
2952
  const nd = inputs.map((o) => JSON.stringify(o)).join("\n") + (inputs.length ? "\n" : "");
2295
2953
  const enrichPath = resolve2(args.enrichOut);
2296
- writeFileSync2(enrichPath, nd);
2954
+ writeFileSync3(enrichPath, nd);
2297
2955
  log(` ${c("cyan", "\u25F7")} ${c("dim", `wrote ${inputs.length} enrich input${inputs.length === 1 ? "" : "s"} \u2192 ${enrichPath} (feed to lacspace-enrich)`)}`);
2298
2956
  }
2299
2957
  const printSummary = () => {
@@ -2307,11 +2965,14 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2307
2965
  if (args.sheet) serOpts.sheetName = args.sheet;
2308
2966
  const { data, binary } = serialize(leads, args.format, fields, serOpts);
2309
2967
  if (toStdout) {
2310
- stdout.write(binary ? Buffer.from(data) : data);
2311
- if (!binary) stdout.write("\n");
2968
+ if (!writer) {
2969
+ stdout.write(binary ? Buffer.from(data) : data);
2970
+ if (!binary) stdout.write("\n");
2971
+ }
2312
2972
  log(`
2313
2973
  ${c("green", "\u2714")} ${c("bold", String(leads.length))} leads written to stdout ${c("dim", `(${args.format})`)}
2314
2974
  `);
2975
+ await finishPush(leads);
2315
2976
  printSummary();
2316
2977
  return;
2317
2978
  }
@@ -2328,13 +2989,14 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2328
2989
  for (const [group, rows] of groups) {
2329
2990
  const file = `${stem}-${groupSlug(group)}${ext}`;
2330
2991
  const part = serialize(rows, args.format, fields, serOpts);
2331
- writeFileSync2(file, part.binary ? Buffer.from(part.data) : part.data);
2992
+ writeFileSync3(file, part.binary ? Buffer.from(part.data) : part.data);
2332
2993
  log(` ${c("green", "\u2714")} ${c("bold", String(rows.length))} leads \u2192 ${c("cyan", file)}`);
2333
2994
  }
2334
2995
  }
2335
2996
  }
2336
- writeFileSync2(out, binary ? Buffer.from(data) : data);
2337
- if (cpFile) clearCheckpoint(cpFile);
2997
+ if (writer) writer.finish(leads);
2998
+ else writeFileSync3(out, binary ? Buffer.from(data) : data);
2999
+ if (cpFile && !stoppedEarly) clearCheckpoint(cpFile);
2338
3000
  const s = computeStats(leads);
2339
3001
  log(`
2340
3002
  ${c("green", "\u2714")} Saved ${c("bold", String(leads.length))} leads \u2192 ${c("cyan", out)}${args.append && fresh !== leads.length ? c("dim", ` (${fresh} new)`) : ""}`);
@@ -2344,6 +3006,8 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2344
3006
  if (args.socials) parts.push(`${s.withSocial} with a social link`);
2345
3007
  if (s.avgRating !== void 0) parts.push(`avg \u2605 ${s.avgRating}`);
2346
3008
  log(` ${c("dim", parts.join(" \xB7 "))}`);
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);
2347
3011
  printSummary();
2348
3012
  log("");
2349
3013
  }