lacspace-leads 1.7.2 → 1.8.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 {
@@ -1430,6 +1475,151 @@ function formatSummary(s) {
1430
1475
  return lines.join("\n");
1431
1476
  }
1432
1477
 
1478
+ // src/live.ts
1479
+ import { appendFileSync, closeSync, existsSync as existsSync2, fstatSync, openSync, renameSync as renameSync2, writeFileSync as writeFileSync2, writeSync } from "fs";
1480
+ import { stringify as csvStringify3 } from "@lacspace/csv";
1481
+ var pick = (lead, fields) => {
1482
+ const o = {};
1483
+ for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
1484
+ return o;
1485
+ };
1486
+ function createLiveWriter(opts) {
1487
+ const { file, format, fields } = opts;
1488
+ const by = opts.dedupe ?? "smart";
1489
+ const toStdout = file === "-" || !!opts.stdout;
1490
+ const out = opts.stdout ?? { write: (c2) => process.stdout.write(c2) };
1491
+ if (toStdout && (format === "json" || format === "xlsx")) throw new Error(`live output to stdout needs csv or ndjson, not ${format}`);
1492
+ const rows = [];
1493
+ const keys = /* @__PURE__ */ new Set();
1494
+ let added = 0;
1495
+ let failed = false;
1496
+ let dirty = false;
1497
+ let jsonFd;
1498
+ const warn = (err) => {
1499
+ if (!failed) opts.onWriteError?.(err);
1500
+ failed = true;
1501
+ dirty = true;
1502
+ };
1503
+ const atomic = (data) => {
1504
+ const tmp = `${file}.tmp`;
1505
+ writeFileSync2(tmp, data);
1506
+ renameSync2(tmp, file);
1507
+ };
1508
+ const csvLine = (lead) => csvStringify3(toRows([lead], fields), { header: false, escapeFormulas: true });
1509
+ const csvHeader = () => csvStringify3([toRows([{}], fields)[0]], { escapeFormulas: true }).split(/\r?\n/)[0] + "\n";
1510
+ const nl = (s) => s.endsWith("\n") ? s : s + "\n";
1511
+ const rewrite = (list) => {
1512
+ const { data, binary } = serialize([...list], format, fields, opts.sheetName ? { sheetName: opts.sheetName } : {});
1513
+ if (format === "csv" && !list.length) atomic(csvHeader());
1514
+ else atomic(binary ? Buffer.from(data) : format === "json" ? data : nl(data));
1515
+ };
1516
+ const jsonAppend = (lead) => {
1517
+ jsonFd ??= openSync(file, "r+");
1518
+ const size = fstatSync(jsonFd).size;
1519
+ const body = JSON.stringify(pick(lead, fields), null, 2).replace(/^/gm, " ");
1520
+ const chunk = (rows.length === 1 ? "\n" : ",\n") + body + "\n]";
1521
+ writeSync(jsonFd, chunk, Math.max(1, size - (rows.length === 1 ? 1 : 2)));
1522
+ };
1523
+ const start = () => {
1524
+ for (const l of opts.seed ?? []) {
1525
+ const k = dedupeKey(l, by);
1526
+ if (k !== void 0) keys.add(k);
1527
+ rows.push(l);
1528
+ }
1529
+ if (toStdout) {
1530
+ if (format === "csv") out.write(csvHeader());
1531
+ return;
1532
+ }
1533
+ try {
1534
+ if (format === "json" && !rows.length) atomic("[]");
1535
+ else rewrite(rows);
1536
+ } catch (e) {
1537
+ warn(e);
1538
+ }
1539
+ };
1540
+ const write = (lead) => {
1541
+ if (toStdout) {
1542
+ out.write(format === "csv" ? nl(csvLine(lead)) : JSON.stringify(pick(lead, fields)) + "\n");
1543
+ return;
1544
+ }
1545
+ if (dirty) {
1546
+ try {
1547
+ if (jsonFd !== void 0) {
1548
+ closeSync(jsonFd);
1549
+ jsonFd = void 0;
1550
+ }
1551
+ rewrite(rows);
1552
+ dirty = false;
1553
+ } catch (e) {
1554
+ warn(e);
1555
+ }
1556
+ return;
1557
+ }
1558
+ try {
1559
+ if (format === "csv") appendFileSync(file, nl(csvLine(lead)));
1560
+ else if (format === "ndjson") appendFileSync(file, JSON.stringify(pick(lead, fields)) + "\n");
1561
+ else if (format === "json") {
1562
+ if (rows.length > 1 && !existsSync2(file)) throw new Error("output file disappeared");
1563
+ jsonAppend(lead);
1564
+ } else rewrite(rows);
1565
+ } catch (e) {
1566
+ warn(e);
1567
+ }
1568
+ };
1569
+ start();
1570
+ return {
1571
+ add(lead) {
1572
+ const k = dedupeKey(lead, by);
1573
+ if (k !== void 0) {
1574
+ if (keys.has(k)) return false;
1575
+ keys.add(k);
1576
+ }
1577
+ rows.push(lead);
1578
+ added++;
1579
+ write(lead);
1580
+ return true;
1581
+ },
1582
+ get count() {
1583
+ return rows.length;
1584
+ },
1585
+ get added() {
1586
+ return added;
1587
+ },
1588
+ get leads() {
1589
+ return rows;
1590
+ },
1591
+ flush() {
1592
+ if (toStdout || !dirty) return;
1593
+ try {
1594
+ if (jsonFd !== void 0) {
1595
+ closeSync(jsonFd);
1596
+ jsonFd = void 0;
1597
+ }
1598
+ rewrite(rows);
1599
+ dirty = false;
1600
+ } catch (e) {
1601
+ warn(e);
1602
+ }
1603
+ },
1604
+ finish(final) {
1605
+ if (jsonFd !== void 0) {
1606
+ try {
1607
+ closeSync(jsonFd);
1608
+ } catch {
1609
+ }
1610
+ jsonFd = void 0;
1611
+ }
1612
+ if (toStdout) return;
1613
+ try {
1614
+ rewrite(final ?? rows);
1615
+ dirty = false;
1616
+ } catch (e) {
1617
+ warn(e);
1618
+ }
1619
+ }
1620
+ };
1621
+ }
1622
+
1433
1623
  // src/pipe.ts
1434
1624
  function leadDomain(website) {
1435
1625
  if (!website) return void 0;
@@ -1531,8 +1721,26 @@ async function sweepLeads(opts) {
1531
1721
  stats.unique = collected.length;
1532
1722
  return collected.length - before;
1533
1723
  };
1534
- const enough = () => collected.length >= want;
1535
- const stepLimit = () => Math.min(cap, Math.max(MIN_PER_STEP, (want - collected.length) * 2));
1724
+ const userOnResult = shared.onResult;
1725
+ const userRemaining = shared.remaining;
1726
+ const userSkip = shared.skipListing;
1727
+ const liveKeys = /* @__PURE__ */ new Set();
1728
+ let live = 0;
1729
+ const onResult = (lead) => {
1730
+ const k = dedupeKey(lead, dedupeBy);
1731
+ if (k !== void 0) {
1732
+ if (liveKeys.has(k)) return;
1733
+ liveKeys.add(k);
1734
+ }
1735
+ live++;
1736
+ try {
1737
+ userOnResult?.(lead);
1738
+ } catch {
1739
+ }
1740
+ };
1741
+ const remaining = () => Math.min(want - Math.max(live, collected.length), userRemaining ? userRemaining() : Infinity);
1742
+ const enough = () => remaining() <= 0;
1743
+ const stepLimit = () => Math.min(cap, Math.max(MIN_PER_STEP, Math.max(0, remaining()) * 2));
1536
1744
  let center = near ? { lat: near.lat, lng: near.lng } : void 0;
1537
1745
  const captureCenter = (c2) => {
1538
1746
  center ??= c2;
@@ -1543,7 +1751,9 @@ async function sweepLeads(opts) {
1543
1751
  ...shared,
1544
1752
  ...step,
1545
1753
  limit: stepLimit(),
1546
- onCenter: captureCenter
1754
+ onCenter: captureCenter,
1755
+ onResult,
1756
+ remaining
1547
1757
  };
1548
1758
  const found = await scrapeLeads(searchOpts);
1549
1759
  stats.searches++;
@@ -1569,7 +1779,9 @@ async function sweepLeads(opts) {
1569
1779
  limit: stepLimit(),
1570
1780
  near: point,
1571
1781
  onCenter: captureCenter,
1572
- skipListing
1782
+ skipListing: (l) => skipListing(l) || (userSkip?.(l) ?? false),
1783
+ onResult,
1784
+ remaining
1573
1785
  };
1574
1786
  delete tileOpts.area;
1575
1787
  delete tileOpts.city;
@@ -1667,6 +1879,7 @@ function parseArgs(list) {
1667
1879
  jitter: false,
1668
1880
  resume: false,
1669
1881
  summary: false,
1882
+ live: true,
1670
1883
  yes: false,
1671
1884
  help: false
1672
1885
  };
@@ -1723,6 +1936,8 @@ function parseArgs(list) {
1723
1936
  else if (arg === "--business-status") a.businessStatus = next();
1724
1937
  else if (arg === "--name-exclude" || arg === "--exclude-names") a.nameExclude = next();
1725
1938
  else if (arg === "--resume") a.resume = true;
1939
+ else if (arg === "--no-live") a.live = false;
1940
+ else if (arg === "--live") a.live = true;
1726
1941
  else if (arg === "--summary") a.summary = true;
1727
1942
  else if (arg === "--dedupe-across") a.dedupeAcross = next();
1728
1943
  else if (arg === "--enrich-out") a.enrichOut = next();
@@ -1800,8 +2015,8 @@ ${c("bold", "Filters & order")}
1800
2015
  --category <text> Keep only leads whose category/tags contain this text
1801
2016
  --business-status <s> Keep only this status (operational | closed | temporarily-closed)
1802
2017
  --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)
2018
+ --dedupe <key> website | phone | name | smart | none (default smart:
2019
+ same website, else same phone, else same name)
1805
2020
  --dedupe-across <file> Drop leads already present in an existing master file
1806
2021
  --sort <key> rating | reviews | name | priceLevel | distance
1807
2022
  --desc / --asc Sort direction (distance defaults nearest-first)
@@ -1817,7 +2032,13 @@ ${c("bold", "Output")}
1817
2032
  --sheet <name> Excel sheet name (default "Leads")
1818
2033
  --summary Print run stats (rating bands, % with contact, top categories)
1819
2034
  --enrich-out <f> Also write { name, website, domain } NDJSON for lacspace-enrich
1820
- --resume Resume an interrupted city sweep from its checkpoint file
2035
+ --resume Continue an interrupted run: keeps the rows already in the
2036
+ output file, skips those listings and fills up to --target
2037
+ (multi-area sweeps also use their checkpoint file)
2038
+ --no-live Write the file only at the end. By default every lead is
2039
+ added to the file the moment it is found (csv/ndjson rows
2040
+ append, json stays valid, xlsx is rewritten), so stopping
2041
+ early never loses what was collected
1821
2042
 
1822
2043
  ${c("bold", "Runtime")}
1823
2044
  --delay <ms> Pause between listings (default 700)
@@ -1952,7 +2173,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1952
2173
  return;
1953
2174
  }
1954
2175
  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)) {
2176
+ if (args.dedupeAcross && existsSync3(args.dedupeAcross)) {
1956
2177
  try {
1957
2178
  const master = rowsToLeads(await readRows(args.dedupeAcross));
1958
2179
  const before = leads.length;
@@ -1962,7 +2183,7 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1962
2183
  log(c("yellow", ` ! couldn't read --dedupe-across (${err.message})`));
1963
2184
  }
1964
2185
  }
1965
- if ((args.append || config.append) && existsSync2(out)) {
2186
+ if ((args.append || config.append) && existsSync3(out)) {
1966
2187
  try {
1967
2188
  const existing = rowsToLeads(await readRows(out));
1968
2189
  const before = existing.length;
@@ -1976,13 +2197,13 @@ ${c("bold", c("magenta", "\u25C6 lacspace-leads config"))} ${c("dim", "\u2014 ru
1976
2197
  const inputs = leadsToEnrichInput(leads);
1977
2198
  const nd = inputs.map((o) => JSON.stringify(o)).join("\n") + (inputs.length ? "\n" : "");
1978
2199
  const enrichPath = resolve2(args.enrichOut);
1979
- writeFileSync2(enrichPath, nd);
2200
+ writeFileSync3(enrichPath, nd);
1980
2201
  log(` ${c("cyan", "\u25F7")} ${c("dim", `wrote ${inputs.length} enrich input${inputs.length === 1 ? "" : "s"} \u2192 ${enrichPath}`)}`);
1981
2202
  }
1982
2203
  const serOpts = {};
1983
2204
  if (args.sheet ?? config.sheet) serOpts.sheetName = args.sheet ?? config.sheet;
1984
2205
  const { data, binary } = serialize(leads, format, fields, serOpts);
1985
- writeFileSync2(out, binary ? Buffer.from(data) : data);
2206
+ writeFileSync3(out, binary ? Buffer.from(data) : data);
1986
2207
  if (cpFile) clearCheckpoint(cpFile);
1987
2208
  const s = computeStats(leads);
1988
2209
  log(`
@@ -2158,9 +2379,83 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2158
2379
  log("");
2159
2380
  const controller = new AbortController();
2160
2381
  const onSig = () => {
2382
+ if (controller.signal.aborted) {
2383
+ writerRef.current?.flush();
2384
+ log(c("yellow", "\n ! Quit. Rows already written stay in the file; --resume continues from them.\n"));
2385
+ exit(130);
2386
+ }
2161
2387
  controller.abort();
2388
+ 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
+ };
2390
+ const writerRef = {};
2391
+ process.on("SIGINT", onSig);
2392
+ let existing = [];
2393
+ if ((args.append || args.resume) && !toStdout && existsSync3(out)) {
2394
+ try {
2395
+ existing = rowsToLeads(await readRows(out));
2396
+ } catch (err) {
2397
+ log(c("yellow", ` ! couldn't read "${out}" (${err.message}); starting it fresh`));
2398
+ }
2399
+ }
2400
+ let master;
2401
+ if (args.dedupeAcross && existsSync3(args.dedupeAcross)) {
2402
+ try {
2403
+ master = rowsToLeads(await readRows(args.dedupeAcross));
2404
+ } catch (err) {
2405
+ log(c("yellow", ` ! couldn't read --dedupe-across "${args.dedupeAcross}" (${err.message}); keeping all`));
2406
+ }
2407
+ }
2408
+ const dedupeBy = args.dedupe ?? "smart";
2409
+ const inMaster = master?.length ? (l) => subtractLeads([l], master, dedupeBy).length === 0 : void 0;
2410
+ const resumeRows = args.resume && !isBatch ? existing : [];
2411
+ if (resumeRows.length) log(` ${c("cyan", "\u25F7")} ${c("dim", `resuming \u2014 ${resumeRows.length} leads already in ${out}; skipping those listings`)}`);
2412
+ const liveFormat = toStdout ? args.format === "csv" || args.format === "ndjson" : true;
2413
+ let writer;
2414
+ const outExisted = !toStdout && existsSync3(out);
2415
+ const dropEmpty = () => {
2416
+ if (!writer || toStdout) return;
2417
+ if (outExisted) writer.finish([...existing]);
2418
+ else {
2419
+ writer.finish([]);
2420
+ try {
2421
+ unlinkSync2(out);
2422
+ } catch {
2423
+ }
2424
+ }
2425
+ };
2426
+ if (args.live && liveFormat && !(toStdout && args.split)) {
2427
+ try {
2428
+ writer = createLiveWriter({
2429
+ file: out,
2430
+ format: args.format,
2431
+ fields,
2432
+ dedupe: dedupeBy,
2433
+ seed: existing,
2434
+ ...args.sheet ? { sheetName: args.sheet } : {},
2435
+ ...toStdout ? { stdout } : {},
2436
+ 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.`))
2437
+ });
2438
+ writerRef.current = writer;
2439
+ if (!toStdout) log(` ${c("cyan", "\u25F7")} ${c("dim", `writing live \u2192 ${out}`)}`);
2440
+ } catch (err) {
2441
+ log(c("yellow", ` ! live output off: ${err.message}`));
2442
+ }
2443
+ }
2444
+ const goal = args.target ?? (isBatch ? args.total : void 0) ?? (isBatch ? void 0 : args.limit);
2445
+ const startedAt = Date.now();
2446
+ let fresh0 = 0;
2447
+ const onResult = (lead) => {
2448
+ if (inMaster?.(lead)) return;
2449
+ if (writer && !writer.add(lead)) return;
2450
+ fresh0++;
2451
+ const have = resumeRows.length + fresh0;
2452
+ const mins = (Date.now() - startedAt) / 6e4;
2453
+ const rate = mins > 0.05 ? fresh0 / mins : 0;
2454
+ const left = goal !== void 0 ? Math.max(0, goal - have) : 0;
2455
+ const eta = rate > 0 && left > 0 ? `, ~${Math.ceil(left / rate)} min left` : "";
2456
+ const bits = [lead.phone, lead.website?.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""), lead.email].filter(Boolean).join(" \xB7 ");
2457
+ 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
2458
  };
2163
- process.once("SIGINT", onSig);
2164
2459
  const opts = {
2165
2460
  type: args.type ?? "",
2166
2461
  query: args.query ?? "",
@@ -2176,10 +2471,17 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2176
2471
  verifyEmails: args.verifyEmails,
2177
2472
  jitter: args.jitter,
2178
2473
  signal: controller.signal,
2179
- onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`)
2474
+ onProgress: (m) => log(` ${c("cyan", "\u25F7")} ${c("dim", m)}`),
2475
+ onResult
2180
2476
  };
2477
+ if (resumeRows.length) {
2478
+ const names = new Set(resumeRows.map((l) => l.name?.trim().toLowerCase()).filter(Boolean));
2479
+ const urls = new Set(resumeRows.map((l) => l.mapsUrl).filter(Boolean));
2480
+ opts.skipListing = (l) => urls.has(l.href) || !!l.name && names.has(l.name.trim().toLowerCase());
2481
+ }
2482
+ if (args.target !== void 0 && (resumeRows.length || inMaster)) opts.remaining = () => args.target - resumeRows.length - fresh0;
2181
2483
  if (hasFilters) opts.filters = filters;
2182
- if (args.dedupe) opts.dedupe = args.dedupe;
2484
+ opts.dedupe = dedupeBy;
2183
2485
  if (nearPoint) opts.near = { lat: nearPoint.lat, lng: nearPoint.lng };
2184
2486
  if (radiusM !== void 0) opts.radiusM = radiusM;
2185
2487
  if (args.sort) opts.sort = args.sort;
@@ -2239,6 +2541,15 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2239
2541
  leads = await scrapeLeads(opts);
2240
2542
  }
2241
2543
  } catch (err) {
2544
+ if (writer && writer.added) {
2545
+ writer.flush();
2546
+ log(c("yellow", `
2547
+ ! ${err.message}`));
2548
+ 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
+ `);
2550
+ exit(controller.signal.aborted ? 130 : 1);
2551
+ return;
2552
+ }
2242
2553
  log(c("red", `
2243
2554
  \u2717 ${err.message}`));
2244
2555
  exit(1);
@@ -2246,6 +2557,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2246
2557
  } finally {
2247
2558
  process.removeListener("SIGINT", onSig);
2248
2559
  }
2560
+ const stoppedEarly = controller.signal.aborted;
2249
2561
  if (sweptShort) {
2250
2562
  log(c("yellow", `
2251
2563
  ! The map ran out of new results at ${leads.length} \u2014 that is everything Google lists here.`));
@@ -2256,44 +2568,37 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2256
2568
  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
2569
  `));
2258
2570
  }
2259
- if (leads.length === 0) {
2571
+ if (leads.length === 0 && !resumeRows.length) {
2260
2572
  log(c("yellow", "\n No leads collected. Try a broader area, looser filters, or a smaller --limit.\n"));
2573
+ dropEmpty();
2261
2574
  if (cpFile) clearCheckpoint(cpFile);
2262
2575
  return;
2263
2576
  }
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
- }
2577
+ if (master) {
2578
+ const before = leads.length;
2579
+ leads = subtractLeads(leads, master, dedupeBy);
2580
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `dropped ${before - leads.length} already in ${args.dedupeAcross} \u2192 ${leads.length} new`)}`);
2273
2581
  if (leads.length === 0) {
2274
2582
  log(c("yellow", "\n Nothing new \u2014 every lead was already in the master file.\n"));
2583
+ dropEmpty();
2275
2584
  if (cpFile) clearCheckpoint(cpFile);
2276
2585
  return;
2277
2586
  }
2278
2587
  }
2279
2588
  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
- }
2589
+ if ((args.append || resumeRows.length) && !toStdout && existing.length) {
2590
+ const before = existing.length;
2591
+ const merged = dedupeLeads([...existing, ...leads], dedupeBy);
2592
+ fresh = merged.length - before;
2593
+ log(` ${c("cyan", "\u25F7")} ${c("dim", `merged with ${before} existing \u2192 ${merged.length} total (${fresh} new)`)}`);
2594
+ leads = merged;
2595
+ if (args.target !== void 0 && resumeRows.length) leads = leads.slice(0, Math.max(args.target, before));
2291
2596
  }
2292
2597
  if (args.enrichOut) {
2293
2598
  const inputs = leadsToEnrichInput(leads);
2294
2599
  const nd = inputs.map((o) => JSON.stringify(o)).join("\n") + (inputs.length ? "\n" : "");
2295
2600
  const enrichPath = resolve2(args.enrichOut);
2296
- writeFileSync2(enrichPath, nd);
2601
+ writeFileSync3(enrichPath, nd);
2297
2602
  log(` ${c("cyan", "\u25F7")} ${c("dim", `wrote ${inputs.length} enrich input${inputs.length === 1 ? "" : "s"} \u2192 ${enrichPath} (feed to lacspace-enrich)`)}`);
2298
2603
  }
2299
2604
  const printSummary = () => {
@@ -2307,8 +2612,10 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2307
2612
  if (args.sheet) serOpts.sheetName = args.sheet;
2308
2613
  const { data, binary } = serialize(leads, args.format, fields, serOpts);
2309
2614
  if (toStdout) {
2310
- stdout.write(binary ? Buffer.from(data) : data);
2311
- if (!binary) stdout.write("\n");
2615
+ if (!writer) {
2616
+ stdout.write(binary ? Buffer.from(data) : data);
2617
+ if (!binary) stdout.write("\n");
2618
+ }
2312
2619
  log(`
2313
2620
  ${c("green", "\u2714")} ${c("bold", String(leads.length))} leads written to stdout ${c("dim", `(${args.format})`)}
2314
2621
  `);
@@ -2328,13 +2635,14 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2328
2635
  for (const [group, rows] of groups) {
2329
2636
  const file = `${stem}-${groupSlug(group)}${ext}`;
2330
2637
  const part = serialize(rows, args.format, fields, serOpts);
2331
- writeFileSync2(file, part.binary ? Buffer.from(part.data) : part.data);
2638
+ writeFileSync3(file, part.binary ? Buffer.from(part.data) : part.data);
2332
2639
  log(` ${c("green", "\u2714")} ${c("bold", String(rows.length))} leads \u2192 ${c("cyan", file)}`);
2333
2640
  }
2334
2641
  }
2335
2642
  }
2336
- writeFileSync2(out, binary ? Buffer.from(data) : data);
2337
- if (cpFile) clearCheckpoint(cpFile);
2643
+ if (writer) writer.finish(leads);
2644
+ else writeFileSync3(out, binary ? Buffer.from(data) : data);
2645
+ if (cpFile && !stoppedEarly) clearCheckpoint(cpFile);
2338
2646
  const s = computeStats(leads);
2339
2647
  log(`
2340
2648
  ${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 +2652,7 @@ ${c("yellow", "!")} This opens a browser and searches Google Maps. Continue? ${c
2344
2652
  if (args.socials) parts.push(`${s.withSocial} with a social link`);
2345
2653
  if (s.avgRating !== void 0) parts.push(`avg \u2605 ${s.avgRating}`);
2346
2654
  log(` ${c("dim", parts.join(" \xB7 "))}`);
2655
+ if (stoppedEarly) log(c("yellow", ` ! Stopped early. Run the same command with --resume to carry on from these ${leads.length}.`));
2347
2656
  printSummary();
2348
2657
  log("");
2349
2658
  }