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/README.md +105 -3
- package/dist/cli.js +802 -138
- package/dist/lib.cjs +598 -102
- package/dist/lib.d.cts +204 -2
- package/dist/lib.d.ts +204 -2
- package/dist/lib.js +587 -101
- 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 {
|
|
@@ -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
|
|
1535
|
-
const
|
|
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
|
|
1804
|
-
|
|
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
|
|
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, {
|
|
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 &&
|
|
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) &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
}
|
|
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 &&
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2337
|
-
|
|
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
|
}
|