lacspace-leads 0.1.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 +98 -0
- package/dist/cli.js +468 -0
- package/dist/lib.cjs +347 -0
- package/dist/lib.d.cts +105 -0
- package/dist/lib.d.ts +105 -0
- package/dist/lib.js +309 -0
- package/package.json +73 -0
package/dist/lib.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// src/scrape.ts
|
|
2
|
+
import { chromium } from "playwright-core";
|
|
3
|
+
|
|
4
|
+
// src/types.ts
|
|
5
|
+
var ALL_FIELDS = [
|
|
6
|
+
"name",
|
|
7
|
+
"category",
|
|
8
|
+
"rating",
|
|
9
|
+
"reviews",
|
|
10
|
+
"address",
|
|
11
|
+
"phone",
|
|
12
|
+
"website",
|
|
13
|
+
"plusCode",
|
|
14
|
+
"hours",
|
|
15
|
+
"mapsUrl"
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// src/query.ts
|
|
19
|
+
function composeQuery(opts) {
|
|
20
|
+
if (opts.query && opts.query.trim()) return opts.query.trim();
|
|
21
|
+
const type = (opts.type ?? "").trim();
|
|
22
|
+
const where = [opts.area, opts.city].map((s) => (s ?? "").trim()).filter(Boolean).join(", ");
|
|
23
|
+
if (!type) throw new Error("A business `type` (or an explicit `query`) is required.");
|
|
24
|
+
return where ? `${type} in ${where}` : type;
|
|
25
|
+
}
|
|
26
|
+
function mapsSearchUrl(query) {
|
|
27
|
+
return `https://www.google.com/maps/search/${encodeURIComponent(query)}?hl=en`;
|
|
28
|
+
}
|
|
29
|
+
function normalizeFields(input) {
|
|
30
|
+
if (!input) return [...ALL_FIELDS];
|
|
31
|
+
const raw = typeof input === "string" ? input.split(",") : input;
|
|
32
|
+
const want = new Set(
|
|
33
|
+
raw.map((f) => String(f).trim().toLowerCase()).filter(Boolean)
|
|
34
|
+
);
|
|
35
|
+
const picked = ALL_FIELDS.filter((f) => want.has(f.toLowerCase()));
|
|
36
|
+
return picked.length > 0 ? picked : [...ALL_FIELDS];
|
|
37
|
+
}
|
|
38
|
+
function defaultFilename(query, format) {
|
|
39
|
+
const slug = query.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "leads";
|
|
40
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
41
|
+
return `${slug}-${stamp}.${format}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/scrape.ts
|
|
45
|
+
var LeadsError = class extends Error {
|
|
46
|
+
code;
|
|
47
|
+
cause;
|
|
48
|
+
constructor(message, code, cause) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "LeadsError";
|
|
51
|
+
if (code !== void 0) this.code = code;
|
|
52
|
+
if (cause !== void 0) this.cause = cause;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
function stripPrefix(value, prefix) {
|
|
56
|
+
if (!value) return void 0;
|
|
57
|
+
const v = value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
58
|
+
const trimmed = v.trim();
|
|
59
|
+
return trimmed || void 0;
|
|
60
|
+
}
|
|
61
|
+
function parseReviewCount(label) {
|
|
62
|
+
if (!label) return void 0;
|
|
63
|
+
const digits = label.replace(/[^0-9]/g, "");
|
|
64
|
+
if (!digits) return void 0;
|
|
65
|
+
const n = parseInt(digits, 10);
|
|
66
|
+
return Number.isFinite(n) ? n : void 0;
|
|
67
|
+
}
|
|
68
|
+
function parseRating(text) {
|
|
69
|
+
if (!text) return void 0;
|
|
70
|
+
const m = text.replace(",", ".").match(/\d+(\.\d+)?/);
|
|
71
|
+
if (!m) return void 0;
|
|
72
|
+
const n = parseFloat(m[0]);
|
|
73
|
+
return Number.isFinite(n) && n >= 0 && n <= 5 ? n : void 0;
|
|
74
|
+
}
|
|
75
|
+
async function launchBrowser(headless) {
|
|
76
|
+
let lastErr;
|
|
77
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
78
|
+
try {
|
|
79
|
+
return await chromium.launch({ headless, channel });
|
|
80
|
+
} catch (e) {
|
|
81
|
+
lastErr = e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await chromium.launch({ headless });
|
|
86
|
+
} catch (e) {
|
|
87
|
+
lastErr = e;
|
|
88
|
+
}
|
|
89
|
+
throw new LeadsError(
|
|
90
|
+
"Could not launch a browser. Install Google Chrome (or Microsoft Edge), or run `npx playwright install chromium`.",
|
|
91
|
+
"NO_BROWSER",
|
|
92
|
+
lastErr
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
async function dismissConsent(page) {
|
|
96
|
+
try {
|
|
97
|
+
const btn = page.locator(
|
|
98
|
+
'button[aria-label*="Accept all"], button[aria-label*="Accept the use"], form[action*="consent"] button, button:has-text("Accept all")'
|
|
99
|
+
).first();
|
|
100
|
+
if (await btn.count()) {
|
|
101
|
+
await btn.click({ timeout: 3e3 }).catch(() => {
|
|
102
|
+
});
|
|
103
|
+
await page.waitForTimeout(500);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function loadResults(page, limit, delayMs, onProgress, signal) {
|
|
109
|
+
const feed = page.locator('div[role="feed"]');
|
|
110
|
+
await feed.waitFor({ timeout: 15e3 }).catch(() => {
|
|
111
|
+
});
|
|
112
|
+
let prev = 0;
|
|
113
|
+
let stable = 0;
|
|
114
|
+
for (let i = 0; i < 60; i++) {
|
|
115
|
+
if (signal?.aborted) return;
|
|
116
|
+
const count = await page.locator("a.hfpxzc").count();
|
|
117
|
+
onProgress?.(`loaded ${count} listing${count === 1 ? "" : "s"}\u2026`);
|
|
118
|
+
if (count >= limit) return;
|
|
119
|
+
if (await page.locator('span:has-text("reached the end")').count()) return;
|
|
120
|
+
if (count === prev) {
|
|
121
|
+
if (++stable >= 3) return;
|
|
122
|
+
} else {
|
|
123
|
+
stable = 0;
|
|
124
|
+
}
|
|
125
|
+
prev = count;
|
|
126
|
+
await feed.evaluate((el) => el.scrollBy(0, el.scrollHeight)).catch(() => {
|
|
127
|
+
});
|
|
128
|
+
await page.waitForTimeout(Math.max(400, delayMs));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function extractDetail(page, fields, fallbackName) {
|
|
132
|
+
const lead = {};
|
|
133
|
+
const text = async (sel) => {
|
|
134
|
+
const loc = page.locator(sel).first();
|
|
135
|
+
if (await loc.count()) {
|
|
136
|
+
const t = (await loc.innerText().catch(() => "")).trim();
|
|
137
|
+
return t || void 0;
|
|
138
|
+
}
|
|
139
|
+
return void 0;
|
|
140
|
+
};
|
|
141
|
+
const aria = async (sel) => {
|
|
142
|
+
const loc = page.locator(sel).first();
|
|
143
|
+
if (await loc.count()) {
|
|
144
|
+
return await loc.getAttribute("aria-label").catch(() => null) ?? void 0;
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
};
|
|
148
|
+
if (fields.has("name")) lead.name = await text("h1.DUwDvf") ?? fallbackName;
|
|
149
|
+
else if (fallbackName) lead.name = fallbackName;
|
|
150
|
+
if (fields.has("category")) {
|
|
151
|
+
lead.category = await text('button[jsaction*="category"]') ?? void 0;
|
|
152
|
+
}
|
|
153
|
+
if (fields.has("rating") || fields.has("reviews")) {
|
|
154
|
+
const box = page.locator("div.F7nice").first();
|
|
155
|
+
if (await box.count()) {
|
|
156
|
+
if (fields.has("rating")) {
|
|
157
|
+
const rt = await box.locator('span[aria-hidden="true"]').first().innerText().catch(() => "");
|
|
158
|
+
lead.rating = parseRating(rt);
|
|
159
|
+
}
|
|
160
|
+
if (fields.has("reviews")) {
|
|
161
|
+
const rv = await box.locator('span[aria-label*="review"]').first().getAttribute("aria-label").catch(() => null);
|
|
162
|
+
lead.reviews = parseReviewCount(rv);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (fields.has("address")) {
|
|
167
|
+
lead.address = stripPrefix(await aria('button[data-item-id="address"]'), "Address:");
|
|
168
|
+
}
|
|
169
|
+
if (fields.has("phone")) {
|
|
170
|
+
lead.phone = stripPrefix(await aria('button[data-item-id^="phone"]'), "Phone:");
|
|
171
|
+
}
|
|
172
|
+
if (fields.has("website")) {
|
|
173
|
+
const site = page.locator('a[data-item-id="authority"]').first();
|
|
174
|
+
if (await site.count()) {
|
|
175
|
+
lead.website = await site.getAttribute("href").catch(() => null) ?? void 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (fields.has("plusCode")) {
|
|
179
|
+
lead.plusCode = stripPrefix(await aria('button[data-item-id="oloc"]'), "Plus code:");
|
|
180
|
+
}
|
|
181
|
+
if (fields.has("hours")) {
|
|
182
|
+
lead.hours = stripPrefix(await aria('div[jsaction*="openhours"]'), "") ?? await text('div[jsaction*="openhours"]');
|
|
183
|
+
}
|
|
184
|
+
if (fields.has("mapsUrl")) lead.mapsUrl = page.url();
|
|
185
|
+
return lead;
|
|
186
|
+
}
|
|
187
|
+
async function scrapeLeads(opts) {
|
|
188
|
+
const query = composeQuery(opts);
|
|
189
|
+
const limit = Math.max(1, Math.trunc(opts.limit ?? 60));
|
|
190
|
+
const fields = new Set(normalizeFields(opts.fields ?? ALL_FIELDS));
|
|
191
|
+
const wantDetails = opts.details ?? true;
|
|
192
|
+
const delayMs = Math.max(0, Math.trunc(opts.delayMs ?? 700));
|
|
193
|
+
const headless = opts.headless ?? false;
|
|
194
|
+
const onProgress = opts.onProgress;
|
|
195
|
+
const signal = opts.signal;
|
|
196
|
+
onProgress?.(`searching Google Maps for "${query}"\u2026`);
|
|
197
|
+
const browser = await launchBrowser(headless);
|
|
198
|
+
try {
|
|
199
|
+
const context = await browser.newContext({
|
|
200
|
+
viewport: { width: 1280, height: 900 },
|
|
201
|
+
locale: "en-US"
|
|
202
|
+
});
|
|
203
|
+
const page = await context.newPage();
|
|
204
|
+
await page.goto(mapsSearchUrl(query), { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
205
|
+
await dismissConsent(page);
|
|
206
|
+
await loadResults(page, limit, delayMs, onProgress, signal);
|
|
207
|
+
if (signal?.aborted) throw new LeadsError("Search aborted.", "ABORTED");
|
|
208
|
+
const cards = await page.locator("a.hfpxzc").evaluateAll(
|
|
209
|
+
(els, lim) => els.slice(0, lim).map((a) => ({
|
|
210
|
+
href: a.href,
|
|
211
|
+
name: a.getAttribute("aria-label") ?? void 0
|
|
212
|
+
})),
|
|
213
|
+
limit
|
|
214
|
+
).catch(() => []);
|
|
215
|
+
if (cards.length === 0) {
|
|
216
|
+
onProgress?.("no listings found (Google may have shown a CAPTCHA or an empty result).");
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
if (!wantDetails) {
|
|
220
|
+
return cards.map((c) => {
|
|
221
|
+
const lead = {};
|
|
222
|
+
if (fields.has("name")) lead.name = c.name;
|
|
223
|
+
if (fields.has("mapsUrl")) lead.mapsUrl = c.href;
|
|
224
|
+
return lead;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const leads = [];
|
|
228
|
+
for (let i = 0; i < cards.length; i++) {
|
|
229
|
+
if (signal?.aborted) break;
|
|
230
|
+
const card = cards[i];
|
|
231
|
+
onProgress?.(`reading ${i + 1}/${cards.length}: ${card.name ?? "listing"}\u2026`);
|
|
232
|
+
try {
|
|
233
|
+
await page.goto(card.href, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
234
|
+
await page.locator("h1.DUwDvf").first().waitFor({ timeout: 8e3 }).catch(() => {
|
|
235
|
+
});
|
|
236
|
+
leads.push(await extractDetail(page, fields, card.name));
|
|
237
|
+
} catch {
|
|
238
|
+
if (card.name && (fields.has("name") || fields.has("mapsUrl"))) {
|
|
239
|
+
const partial = {};
|
|
240
|
+
if (fields.has("name")) partial.name = card.name;
|
|
241
|
+
if (fields.has("mapsUrl")) partial.mapsUrl = card.href;
|
|
242
|
+
leads.push(partial);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (delayMs) await page.waitForTimeout(delayMs);
|
|
246
|
+
}
|
|
247
|
+
onProgress?.(`collected ${leads.length} lead${leads.length === 1 ? "" : "s"}.`);
|
|
248
|
+
return leads;
|
|
249
|
+
} finally {
|
|
250
|
+
await browser.close().catch(() => {
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/export.ts
|
|
256
|
+
import { stringify as csvStringify } from "@lacspace/csv";
|
|
257
|
+
import { jsonToXlsx } from "@lacspace/xlsx";
|
|
258
|
+
var HEADERS = {
|
|
259
|
+
name: "Name",
|
|
260
|
+
category: "Category",
|
|
261
|
+
rating: "Rating",
|
|
262
|
+
reviews: "Reviews",
|
|
263
|
+
address: "Address",
|
|
264
|
+
phone: "Phone",
|
|
265
|
+
website: "Website",
|
|
266
|
+
plusCode: "Plus Code",
|
|
267
|
+
hours: "Hours",
|
|
268
|
+
mapsUrl: "Maps URL"
|
|
269
|
+
};
|
|
270
|
+
function toRows(leads, fields = ALL_FIELDS) {
|
|
271
|
+
return leads.map((lead) => {
|
|
272
|
+
const row = {};
|
|
273
|
+
for (const f of fields) {
|
|
274
|
+
const v = lead[f];
|
|
275
|
+
row[HEADERS[f]] = v === void 0 || v === null ? "" : v;
|
|
276
|
+
}
|
|
277
|
+
return row;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function serialize(leads, format, fields = ALL_FIELDS) {
|
|
281
|
+
if (format === "json") {
|
|
282
|
+
const picked = leads.map((lead) => {
|
|
283
|
+
const o = {};
|
|
284
|
+
for (const f of fields) if (lead[f] !== void 0) o[f] = lead[f];
|
|
285
|
+
return o;
|
|
286
|
+
});
|
|
287
|
+
return { data: JSON.stringify(picked, null, 2), binary: false };
|
|
288
|
+
}
|
|
289
|
+
const rows = toRows(leads, fields);
|
|
290
|
+
if (format === "csv") {
|
|
291
|
+
const csvRows = rows;
|
|
292
|
+
return { data: csvStringify(csvRows, { escapeFormulas: true }), binary: false };
|
|
293
|
+
}
|
|
294
|
+
return { data: jsonToXlsx(rows, { sheetName: "Leads" }), binary: true };
|
|
295
|
+
}
|
|
296
|
+
export {
|
|
297
|
+
ALL_FIELDS,
|
|
298
|
+
LeadsError,
|
|
299
|
+
composeQuery,
|
|
300
|
+
defaultFilename,
|
|
301
|
+
mapsSearchUrl,
|
|
302
|
+
normalizeFields,
|
|
303
|
+
parseRating,
|
|
304
|
+
parseReviewCount,
|
|
305
|
+
scrapeLeads,
|
|
306
|
+
scrapeLeads as searchLeads,
|
|
307
|
+
serialize,
|
|
308
|
+
toRows
|
|
309
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lacspace-leads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Free, open-source local-business lead finder — name a city, area and business type, and it drives a real browser over Google Maps to collect names, phones, websites, ratings and addresses, then exports to JSON, CSV or Excel. No API keys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lacspace-leads": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/lib.cjs",
|
|
10
|
+
"module": "./dist/lib.js",
|
|
11
|
+
"types": "./dist/lib.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/lib.d.ts",
|
|
15
|
+
"import": "./dist/lib.js",
|
|
16
|
+
"require": "./dist/lib.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"leads",
|
|
32
|
+
"lead-generation",
|
|
33
|
+
"google-maps",
|
|
34
|
+
"scraper",
|
|
35
|
+
"local-business",
|
|
36
|
+
"prospecting",
|
|
37
|
+
"b2b",
|
|
38
|
+
"csv",
|
|
39
|
+
"excel",
|
|
40
|
+
"playwright",
|
|
41
|
+
"cli",
|
|
42
|
+
"osint",
|
|
43
|
+
"nepal",
|
|
44
|
+
"typescript"
|
|
45
|
+
],
|
|
46
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
47
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
48
|
+
"homepage": "https://developer.lacspace.com",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/lacspace/lacspace-leads.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/lacspace/lacspace-leads/issues"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@lacspace/csv": "^1.1.1",
|
|
61
|
+
"@lacspace/xlsx": "^1.1.0",
|
|
62
|
+
"playwright-core": "^1.48.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^20.14.0",
|
|
66
|
+
"tsup": "^8.0.0",
|
|
67
|
+
"typescript": "^5.5.0",
|
|
68
|
+
"vitest": "^2.0.0"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|