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/README.md +38 -3
- package/dist/cli.js +445 -136
- package/dist/lib.cjs +317 -101
- package/dist/lib.d.cts +53 -2
- package/dist/lib.d.ts +53 -2
- package/dist/lib.js +314 -100
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
|
1535
|
-
const
|
|
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
|
|
1804
|
-
|
|
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
|
|
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 &&
|
|
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) &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
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 &&
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2337
|
-
|
|
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
|
}
|