@agent-pattern-labs/leads-rig 0.1.5 → 0.1.7
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/.claude/agents/general-free.md +3 -1
- package/.cursor/rules/agent-general-free.mdc +3 -1
- package/.cursor/rules/main.mdc +2 -1
- package/.opencode/agents/general-free.md +3 -1
- package/.pi/skills/general-free/SKILL.md +3 -1
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +2 -1
- package/batch/README.md +2 -1
- package/bin/create-leads-harness.mjs +1 -1
- package/config/profile.example.yml +13 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONSTRUCTION.md +1 -1
- package/iso/agents/general-free.md +3 -1
- package/iso/instructions.md +2 -1
- package/lib/leadharness-crawler.mjs +490 -199
- package/lib/leadharness-leads.mjs +47 -1
- package/modes/_shared.md +6 -3
- package/modes/batch.md +4 -3
- package/modes/crawl.md +1 -1
- package/modes/pipeline.md +1 -1
- package/package.json +1 -1
- package/scripts/batch-orchestrator.mjs +10 -2
- package/scripts/crawl.mjs +17 -3
- package/scripts/manifest.mjs +3 -2
- package/scripts/pipeline.mjs +18 -3
- package/scripts/validate-leads.mjs +1 -1
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { lookup, resolveMx } from 'dns/promises';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
4
|
+
import { dirname } from 'path';
|
|
3
5
|
import { setTimeout as sleep } from 'timers/promises';
|
|
4
|
-
import { cleanDomains, dedupeLeadRecords, isRejectedLeadEmail, mergeLeadRecords, normalizeDomain } from './leadharness-leads.mjs';
|
|
6
|
+
import { cleanDomains, dedupeLeadRecords, isGoodLead, isRejectedLeadEmail, mergeLeadRecords, normalizeDomain, resolveProjectPath } from './leadharness-leads.mjs';
|
|
5
7
|
|
|
6
8
|
const DEFAULT_MAX_PAGES = 10;
|
|
7
9
|
const HARD_MAX_PAGES = 25;
|
|
10
|
+
const DEFAULT_DOMAIN_CONCURRENCY = 2;
|
|
11
|
+
const HARD_DOMAIN_CONCURRENCY = 10;
|
|
12
|
+
const DEFAULT_PAGE_CONCURRENCY = 1;
|
|
13
|
+
const HARD_PAGE_CONCURRENCY = 2;
|
|
14
|
+
const DEFAULT_DNS_CONCURRENCY = 8;
|
|
15
|
+
const HARD_DNS_CONCURRENCY = 25;
|
|
8
16
|
const DEFAULT_TIMEOUT_MS = 14_000;
|
|
9
17
|
const DEFAULT_DELAY_MS = 200;
|
|
10
18
|
const DEFAULT_USER_AGENT = 'PublicLeadsBot/0.1 (+https://example.com/public-leads)';
|
|
11
19
|
const MAX_BODY_CHARS = 2 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_CACHE_PATH = '.leadharness-cache/crawler-cache.json';
|
|
21
|
+
const DEFAULT_ROBOTS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const DEFAULT_DNS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const DEFAULT_PAGE_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
24
|
+
const MAX_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
25
|
const EMAIL_RE = /\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/gi;
|
|
13
26
|
const NAME_RE = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
|
|
14
27
|
const PHONE_RE = /(?:\+?1[\s.-]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[\s.-]?[2-9]\d{2}[\s.-]?\d{4}\b/g;
|
|
@@ -35,27 +48,43 @@ export async function crawlDomains(inputs, options = {}) {
|
|
|
35
48
|
const maxPages = boundedInteger(options.maxPages, DEFAULT_MAX_PAGES, 1, HARD_MAX_PAGES);
|
|
36
49
|
const minConfidence = boundedInteger(options.minConfidence, 30, 0, 100);
|
|
37
50
|
const includeBlocked = Boolean(options.includeBlocked);
|
|
51
|
+
const concurrency = crawlerConcurrency(options);
|
|
52
|
+
const context = createCrawlContext(options);
|
|
38
53
|
const results = [];
|
|
39
54
|
const errors = [];
|
|
40
55
|
const leadsByKey = new Map();
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
try {
|
|
58
|
+
const settled = await mapLimit(targets, concurrency, async (target) => {
|
|
59
|
+
try {
|
|
60
|
+
return {
|
|
61
|
+
target,
|
|
62
|
+
result: await crawlDomainTarget(target, {
|
|
63
|
+
...options,
|
|
64
|
+
maxPages,
|
|
65
|
+
minConfidence,
|
|
66
|
+
includeBlocked,
|
|
67
|
+
}, context),
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { target, error };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const item of settled) {
|
|
75
|
+
if (item.error) {
|
|
76
|
+
errors.push(`${item.target.domain}: ${item.error instanceof Error ? item.error.message : String(item.error)}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
results.push(item.result);
|
|
80
|
+
for (const lead of item.result.leads) {
|
|
52
81
|
const key = leadKey(lead);
|
|
53
82
|
const current = leadsByKey.get(key);
|
|
54
83
|
leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
|
|
55
84
|
}
|
|
56
|
-
} catch (error) {
|
|
57
|
-
errors.push(`${target.domain}: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
85
|
}
|
|
86
|
+
} finally {
|
|
87
|
+
saveCrawlerCache(context.cache);
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
const leads = dedupeLeadRecords([...leadsByKey.values()]).sort(sortLead);
|
|
@@ -77,7 +106,12 @@ export async function crawlDomains(inputs, options = {}) {
|
|
|
77
106
|
export async function crawlDomain(input, options = {}) {
|
|
78
107
|
const [target] = normalizeCrawlTargets([input]);
|
|
79
108
|
if (!target) throw new Error('domain is required');
|
|
80
|
-
|
|
109
|
+
const context = createCrawlContext(options);
|
|
110
|
+
try {
|
|
111
|
+
return await crawlDomainTarget(target, options, context);
|
|
112
|
+
} finally {
|
|
113
|
+
saveCrawlerCache(context.cache);
|
|
114
|
+
}
|
|
81
115
|
}
|
|
82
116
|
|
|
83
117
|
export function normalizeCrawlTargets(inputs) {
|
|
@@ -124,73 +158,110 @@ function normalizeCrawlTarget(input) {
|
|
|
124
158
|
};
|
|
125
159
|
}
|
|
126
160
|
|
|
127
|
-
|
|
161
|
+
function createCrawlContext(options) {
|
|
162
|
+
return {
|
|
163
|
+
cache: loadCrawlerCache(options),
|
|
164
|
+
dnsInFlight: new Map(),
|
|
165
|
+
originPaces: new Map(),
|
|
166
|
+
robotsInFlight: new Map(),
|
|
167
|
+
dnsLimiter: createLimiter(boundedInteger(options.dnsConcurrency, DEFAULT_DNS_CONCURRENCY, 1, HARD_DNS_CONCURRENCY)),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function crawlDomainTarget(target, options, context) {
|
|
128
172
|
const maxPages = boundedInteger(options.maxPages, DEFAULT_MAX_PAGES, 1, HARD_MAX_PAGES);
|
|
129
173
|
const minConfidence = boundedInteger(options.minConfidence, 30, 0, 100);
|
|
174
|
+
const pageConcurrency = boundedInteger(options.pageConcurrency, DEFAULT_PAGE_CONCURRENCY, 1, HARD_PAGE_CONCURRENCY);
|
|
130
175
|
const queue = [...target.starts];
|
|
131
176
|
const queued = new Set(queue);
|
|
132
177
|
const seen = new Set();
|
|
178
|
+
const inFlight = new Set();
|
|
133
179
|
const leadsByKey = new Map();
|
|
134
180
|
const warnings = [];
|
|
135
181
|
const pages = [];
|
|
136
|
-
const
|
|
182
|
+
const stopAfterGoodLeads = boundedInteger(options.stopAfterGoodLeads, 0, 0, 1000);
|
|
183
|
+
const stopAfterContactPath = optionFlag(options.stopAfterContactPath);
|
|
184
|
+
let contactPathFound = false;
|
|
137
185
|
let websiteUrl = '';
|
|
138
186
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
continue;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
const shouldStop = () => {
|
|
188
|
+
if (stopAfterContactPath && contactPathFound) return true;
|
|
189
|
+
if (stopAfterGoodLeads > 0 && countGoodLeads(leadsByKey) >= stopAfterGoodLeads) return true;
|
|
190
|
+
return false;
|
|
191
|
+
};
|
|
192
|
+
const schedulePages = () => {
|
|
193
|
+
while (!shouldStop() && inFlight.size < pageConcurrency && queue.length > 0 && pages.length + inFlight.size < maxPages) {
|
|
194
|
+
const pageUrl = queue.shift();
|
|
195
|
+
if (!pageUrl || seen.has(pageUrl)) continue;
|
|
196
|
+
seen.add(pageUrl);
|
|
197
|
+
let task;
|
|
198
|
+
task = crawlPage(pageUrl, context, options, warnings)
|
|
199
|
+
.then((result) => ({ task, result }))
|
|
200
|
+
.catch((error) => ({
|
|
201
|
+
task,
|
|
202
|
+
result: {
|
|
203
|
+
pageUrl,
|
|
204
|
+
error: error instanceof Error ? error.message : String(error),
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
inFlight.add(task);
|
|
156
208
|
}
|
|
209
|
+
};
|
|
157
210
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
statusCode: page.statusCode,
|
|
163
|
-
emailsFound: page.emails.length,
|
|
164
|
-
phonesFound: page.phones.length,
|
|
165
|
-
socialUrlsFound: page.socialUrls.length,
|
|
166
|
-
contactUrlsFound: page.contactUrls.length,
|
|
167
|
-
});
|
|
211
|
+
schedulePages();
|
|
212
|
+
while (inFlight.size > 0) {
|
|
213
|
+
const { task, result } = await Promise.race(inFlight);
|
|
214
|
+
inFlight.delete(task);
|
|
168
215
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
216
|
+
if (result.blocked) {
|
|
217
|
+
pages.push({ url: result.pageUrl, title: '', statusCode: 0, emailsFound: 0, error: 'blocked by robots.txt' });
|
|
218
|
+
} else if (result.error) {
|
|
219
|
+
pages.push({ url: result.pageUrl, title: '', statusCode: 0, emailsFound: 0, error: result.error });
|
|
220
|
+
} else {
|
|
221
|
+
const page = result.page;
|
|
222
|
+
if (!websiteUrl) websiteUrl = originOf(page.url);
|
|
223
|
+
pages.push({
|
|
224
|
+
url: page.url,
|
|
225
|
+
title: page.title,
|
|
226
|
+
statusCode: page.statusCode,
|
|
227
|
+
emailsFound: page.emails.length,
|
|
228
|
+
phonesFound: page.phones.length,
|
|
229
|
+
socialUrlsFound: page.socialUrls.length,
|
|
230
|
+
contactUrlsFound: page.contactUrls.length,
|
|
231
|
+
});
|
|
176
232
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
const emailLeads = await Promise.all(
|
|
234
|
+
page.emails.map((email) => buildEmailLead(email, target.domain, websiteUrl, page, context)),
|
|
235
|
+
);
|
|
236
|
+
for (const lead of emailLeads) {
|
|
237
|
+
if (!isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) continue;
|
|
180
238
|
const key = leadKey(lead);
|
|
181
239
|
const current = leadsByKey.get(key);
|
|
182
240
|
leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
|
|
183
241
|
}
|
|
184
|
-
}
|
|
185
242
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
243
|
+
if (page.hasForm && isContactLikeURL(page.url)) {
|
|
244
|
+
const lead = buildContactPath(target.domain, websiteUrl, page);
|
|
245
|
+
if (isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) {
|
|
246
|
+
const key = leadKey(lead);
|
|
247
|
+
const current = leadsByKey.get(key);
|
|
248
|
+
leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
|
|
249
|
+
contactPathFound = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!shouldStop()) {
|
|
254
|
+
for (const link of page.links) {
|
|
255
|
+
if (queued.has(link) || seen.has(link)) continue;
|
|
256
|
+
if (!sameDomain(target.domain, link) || !isHighSignalPage(link)) continue;
|
|
257
|
+
queued.add(link);
|
|
258
|
+
queue.push(link);
|
|
259
|
+
}
|
|
260
|
+
queue.sort((a, b) => pagePriority(b) - pagePriority(a));
|
|
261
|
+
}
|
|
191
262
|
}
|
|
192
|
-
|
|
193
|
-
|
|
263
|
+
|
|
264
|
+
schedulePages();
|
|
194
265
|
}
|
|
195
266
|
|
|
196
267
|
const leads = dedupeLeadRecords([...leadsByKey.values()]).sort(sortLead);
|
|
@@ -208,62 +279,88 @@ async function crawlDomainTarget(target, options) {
|
|
|
208
279
|
};
|
|
209
280
|
}
|
|
210
281
|
|
|
211
|
-
async function
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
282
|
+
async function crawlPage(pageUrl, context, options, warnings) {
|
|
283
|
+
const allowed = await allowedByRobots(pageUrl, context, options, warnings);
|
|
284
|
+
if (!allowed) return { pageUrl, blocked: true };
|
|
285
|
+
const page = await fetchPage(pageUrl, options, context);
|
|
286
|
+
return { pageUrl, page };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function fetchPage(pageUrl, options, context) {
|
|
290
|
+
const cached = getCachedPage(context.cache, pageUrl);
|
|
291
|
+
if (cached) return cached;
|
|
292
|
+
|
|
293
|
+
return withOriginPace(context, pageUrl, options, async () => {
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timeout = setTimeout(() => controller.abort(), boundedInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 1_000, 60_000));
|
|
296
|
+
try {
|
|
297
|
+
const response = await fetch(pageUrl, {
|
|
298
|
+
headers: {
|
|
299
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
300
|
+
'User-Agent': options.userAgent || DEFAULT_USER_AGENT,
|
|
301
|
+
},
|
|
302
|
+
redirect: 'follow',
|
|
303
|
+
signal: controller.signal,
|
|
304
|
+
});
|
|
305
|
+
if (response.status >= 400) throw new Error(`HTTP ${response.status}`);
|
|
306
|
+
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
|
|
307
|
+
if (contentType && !contentType.includes('html')) {
|
|
308
|
+
throw new Error(`skipped non-HTML content type ${contentType}`);
|
|
309
|
+
}
|
|
310
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
311
|
+
if (contentLength > MAX_BODY_CHARS) {
|
|
312
|
+
throw new Error(`skipped large response (${contentLength} bytes)`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const html = (await response.text()).slice(0, MAX_BODY_CHARS);
|
|
316
|
+
const finalUrl = response.url || pageUrl;
|
|
317
|
+
const page = parsePage(finalUrl, response.status, html);
|
|
318
|
+
setCachedPage(context.cache, pageUrl, page);
|
|
319
|
+
return page;
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timeout);
|
|
231
322
|
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
232
325
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
hasForm: /<form\b/i.test(decoded),
|
|
251
|
-
};
|
|
252
|
-
} finally {
|
|
253
|
-
clearTimeout(timeout);
|
|
254
|
-
}
|
|
326
|
+
function parsePage(finalUrl, statusCode, html) {
|
|
327
|
+
const decoded = decodeEntities(html);
|
|
328
|
+
const text = compactText(stripHtml(decoded));
|
|
329
|
+
const refs = extractPageRefs(finalUrl, decoded);
|
|
330
|
+
const phones = extractPhones(text, refs.phoneValues);
|
|
331
|
+
return {
|
|
332
|
+
url: finalUrl,
|
|
333
|
+
statusCode,
|
|
334
|
+
title: compactText(extractTitle(decoded)),
|
|
335
|
+
text,
|
|
336
|
+
links: refs.links,
|
|
337
|
+
emails: extractEmails(decoded, refs.emailValues),
|
|
338
|
+
phones,
|
|
339
|
+
socialUrls: refs.socialUrls,
|
|
340
|
+
contactUrls: refs.contactUrls,
|
|
341
|
+
hasForm: /<form\b/i.test(decoded),
|
|
342
|
+
};
|
|
255
343
|
}
|
|
256
344
|
|
|
257
|
-
async function allowedByRobots(pageUrl,
|
|
345
|
+
async function allowedByRobots(pageUrl, context, options, warnings) {
|
|
258
346
|
const origin = originOf(pageUrl);
|
|
259
347
|
if (!origin) return true;
|
|
260
|
-
|
|
261
|
-
|
|
348
|
+
let rules = getCachedRobots(context.cache, origin);
|
|
349
|
+
if (!rules) {
|
|
350
|
+
try {
|
|
351
|
+
if (!context.robotsInFlight.has(origin)) {
|
|
352
|
+
const promise = fetchRobots(origin, options, context).finally(() => {
|
|
353
|
+
context.robotsInFlight.delete(origin);
|
|
354
|
+
});
|
|
355
|
+
context.robotsInFlight.set(origin, promise);
|
|
356
|
+
}
|
|
357
|
+
rules = await context.robotsInFlight.get(origin);
|
|
358
|
+
setCachedRobots(context.cache, origin, rules);
|
|
359
|
+
} catch (error) {
|
|
262
360
|
warnings.push(`robots.txt check failed for ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
-
|
|
264
|
-
}
|
|
361
|
+
rules = { disallow: [] };
|
|
362
|
+
}
|
|
265
363
|
}
|
|
266
|
-
const rules = cache.get(origin);
|
|
267
364
|
let path = '/';
|
|
268
365
|
try {
|
|
269
366
|
const parsed = new URL(pageUrl);
|
|
@@ -274,11 +371,12 @@ async function allowedByRobots(pageUrl, cache, options, warnings) {
|
|
|
274
371
|
return !rules.disallow.some((rule) => rule && path.startsWith(rule));
|
|
275
372
|
}
|
|
276
373
|
|
|
277
|
-
async function fetchRobots(origin, options) {
|
|
278
|
-
const
|
|
374
|
+
async function fetchRobots(origin, options, context) {
|
|
375
|
+
const robotsUrl = `${origin.replace(/\/+$/, '')}/robots.txt`;
|
|
376
|
+
const response = await withOriginPace(context, robotsUrl, options, () => fetch(robotsUrl, {
|
|
279
377
|
headers: { 'User-Agent': options.userAgent || DEFAULT_USER_AGENT },
|
|
280
378
|
signal: AbortSignal.timeout?.(5_000),
|
|
281
|
-
});
|
|
379
|
+
}));
|
|
282
380
|
if (response.status === 404) return { disallow: [] };
|
|
283
381
|
if (response.status >= 400) throw new Error(`robots.txt returned HTTP ${response.status}`);
|
|
284
382
|
return parseRobots(await response.text());
|
|
@@ -317,51 +415,64 @@ function extractTitle(html) {
|
|
|
317
415
|
return decodeEntities(String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '');
|
|
318
416
|
}
|
|
319
417
|
|
|
320
|
-
function
|
|
418
|
+
function extractPageRefs(baseUrl, html) {
|
|
321
419
|
const links = [];
|
|
322
|
-
const
|
|
420
|
+
const linkSeen = new Set();
|
|
421
|
+
const hrefUrls = [];
|
|
422
|
+
const hrefSeen = new Set();
|
|
423
|
+
const emailValues = [];
|
|
424
|
+
const phoneValues = [];
|
|
323
425
|
const hrefRe = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
324
426
|
let match;
|
|
325
|
-
while ((match = hrefRe.exec(html))) {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
427
|
+
while ((match = hrefRe.exec(String(html || '')))) {
|
|
428
|
+
const raw = decodeEntities(match[1] || match[2] || match[3] || '').trim();
|
|
429
|
+
if (!raw) continue;
|
|
430
|
+
const lower = raw.toLowerCase();
|
|
431
|
+
if (lower.startsWith('mailto:')) {
|
|
432
|
+
emailValues.push(decodeURIComponentSafe(raw.slice('mailto:'.length).split('?')[0]));
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (lower.startsWith('tel:')) {
|
|
436
|
+
phoneValues.push(decodeURIComponentSafe(raw.slice('tel:'.length).split('?')[0]));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (lower.startsWith('javascript:') || lower.startsWith('#')) continue;
|
|
335
440
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let url;
|
|
344
|
-
try {
|
|
345
|
-
url = new URL(raw, baseUrl);
|
|
346
|
-
} catch {
|
|
347
|
-
return '';
|
|
348
|
-
}
|
|
349
|
-
if (!['http:', 'https:'].includes(url.protocol)) return '';
|
|
350
|
-
url.hash = '';
|
|
351
|
-
url.search = '';
|
|
352
|
-
if (skipByExtension(url.pathname)) return '';
|
|
353
|
-
return url.toString();
|
|
354
|
-
}
|
|
441
|
+
let url;
|
|
442
|
+
try {
|
|
443
|
+
url = new URL(raw, baseUrl);
|
|
444
|
+
} catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!['http:', 'https:'].includes(url.protocol)) continue;
|
|
355
448
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
449
|
+
url.hash = '';
|
|
450
|
+
const hrefUrl = url.toString();
|
|
451
|
+
if (!hrefSeen.has(hrefUrl)) {
|
|
452
|
+
hrefSeen.add(hrefUrl);
|
|
453
|
+
hrefUrls.push(hrefUrl);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
url.search = '';
|
|
457
|
+
if (skipByExtension(url.pathname)) continue;
|
|
458
|
+
const link = url.toString();
|
|
459
|
+
if (!linkSeen.has(link)) {
|
|
460
|
+
linkSeen.add(link);
|
|
461
|
+
links.push(link);
|
|
462
|
+
}
|
|
363
463
|
}
|
|
464
|
+
links.sort((a, b) => pagePriority(b) - pagePriority(a));
|
|
465
|
+
return {
|
|
466
|
+
links,
|
|
467
|
+
emailValues,
|
|
468
|
+
phoneValues,
|
|
469
|
+
socialUrls: hrefUrls.filter((url) => isPublicProfileUrl(url)).slice(0, 8),
|
|
470
|
+
contactUrls: hrefUrls.filter((url) => isSchedulingOrContactUrl(url)).slice(0, 8),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
364
473
|
|
|
474
|
+
function extractEmails(html, extraValues = []) {
|
|
475
|
+
const values = [String(html || ''), ...extraValues];
|
|
365
476
|
const emails = new Set();
|
|
366
477
|
for (const value of values) {
|
|
367
478
|
for (const found of String(value || '').matchAll(EMAIL_RE)) {
|
|
@@ -372,15 +483,8 @@ function extractEmails(html) {
|
|
|
372
483
|
return [...emails].sort();
|
|
373
484
|
}
|
|
374
485
|
|
|
375
|
-
function extractPhones(
|
|
376
|
-
const
|
|
377
|
-
const values = [String(text || '')];
|
|
378
|
-
const telRe = /href\s*=\s*(?:"tel:([^"]+)"|'tel:([^']+)'|tel:([^\s>]+))/gi;
|
|
379
|
-
let match;
|
|
380
|
-
while ((match = telRe.exec(decoded))) {
|
|
381
|
-
values.push(decodeURIComponentSafe(match[1] || match[2] || match[3] || ''));
|
|
382
|
-
}
|
|
383
|
-
|
|
486
|
+
function extractPhones(text, extraValues = []) {
|
|
487
|
+
const values = [String(text || ''), ...extraValues];
|
|
384
488
|
const phones = new Set();
|
|
385
489
|
for (const value of values) {
|
|
386
490
|
for (const found of String(value || '').matchAll(PHONE_RE)) {
|
|
@@ -391,44 +495,7 @@ function extractPhones(html, text) {
|
|
|
391
495
|
return [...phones].sort().slice(0, 5);
|
|
392
496
|
}
|
|
393
497
|
|
|
394
|
-
function
|
|
395
|
-
return extractHrefUrls(baseUrl, html)
|
|
396
|
-
.filter((url) => isPublicProfileUrl(url))
|
|
397
|
-
.slice(0, 8);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function extractContactUrls(baseUrl, html) {
|
|
401
|
-
return extractHrefUrls(baseUrl, html)
|
|
402
|
-
.filter((url) => isSchedulingOrContactUrl(url))
|
|
403
|
-
.slice(0, 8);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function extractHrefUrls(baseUrl, html) {
|
|
407
|
-
const urls = [];
|
|
408
|
-
const seen = new Set();
|
|
409
|
-
const hrefRe = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
410
|
-
let match;
|
|
411
|
-
while ((match = hrefRe.exec(String(html || '')))) {
|
|
412
|
-
const raw = decodeEntities(match[1] || match[2] || match[3] || '');
|
|
413
|
-
const lower = raw.toLowerCase();
|
|
414
|
-
if (!raw || lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('javascript:') || lower.startsWith('#')) continue;
|
|
415
|
-
let url;
|
|
416
|
-
try {
|
|
417
|
-
url = new URL(raw, baseUrl);
|
|
418
|
-
} catch {
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
if (!['http:', 'https:'].includes(url.protocol)) continue;
|
|
422
|
-
url.hash = '';
|
|
423
|
-
const value = url.toString();
|
|
424
|
-
if (seen.has(value)) continue;
|
|
425
|
-
seen.add(value);
|
|
426
|
-
urls.push(value);
|
|
427
|
-
}
|
|
428
|
-
return urls;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
async function buildEmailLead(email, companyDomain, websiteUrl, page) {
|
|
498
|
+
async function buildEmailLead(email, companyDomain, websiteUrl, page, context) {
|
|
432
499
|
const sourceUrl = page.url;
|
|
433
500
|
const pageTitle = page.title;
|
|
434
501
|
const text = page.text;
|
|
@@ -481,7 +548,7 @@ async function buildEmailLead(email, companyDomain, websiteUrl, page) {
|
|
|
481
548
|
confidence = Math.max(confidence, 38);
|
|
482
549
|
}
|
|
483
550
|
|
|
484
|
-
const verificationStatus = await emailVerificationStatus(emailDomain);
|
|
551
|
+
const verificationStatus = await emailVerificationStatus(emailDomain, context);
|
|
485
552
|
if (verificationStatus !== 'mx_verified' && emailType !== 'blocked') {
|
|
486
553
|
warnings.push('email domain MX could not be verified');
|
|
487
554
|
}
|
|
@@ -551,8 +618,24 @@ function buildContactPath(domain, websiteUrl, page) {
|
|
|
551
618
|
return lead;
|
|
552
619
|
}
|
|
553
620
|
|
|
554
|
-
async function emailVerificationStatus(domain) {
|
|
621
|
+
async function emailVerificationStatus(domain, context) {
|
|
555
622
|
if (!domain) return 'unknown';
|
|
623
|
+
const cached = getCachedDnsStatus(context.cache, domain);
|
|
624
|
+
if (cached) return cached;
|
|
625
|
+
if (context.dnsInFlight.has(domain)) return context.dnsInFlight.get(domain);
|
|
626
|
+
|
|
627
|
+
const promise = context.dnsLimiter(async () => {
|
|
628
|
+
const status = await resolveEmailVerificationStatus(domain);
|
|
629
|
+
setCachedDnsStatus(context.cache, domain, status);
|
|
630
|
+
return status;
|
|
631
|
+
}).finally(() => {
|
|
632
|
+
context.dnsInFlight.delete(domain);
|
|
633
|
+
});
|
|
634
|
+
context.dnsInFlight.set(domain, promise);
|
|
635
|
+
return promise;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function resolveEmailVerificationStatus(domain) {
|
|
556
639
|
try {
|
|
557
640
|
const mx = await resolveMx(domain);
|
|
558
641
|
if (mx.length > 0) return 'mx_verified';
|
|
@@ -575,10 +658,19 @@ function classifyEmailLocal(local) {
|
|
|
575
658
|
}
|
|
576
659
|
|
|
577
660
|
function isRelevantLead(lead, options) {
|
|
661
|
+
if (lead.email && !isGoodLead(lead)) return false;
|
|
578
662
|
if (!options.includeBlocked && lead.emailType === 'blocked') return false;
|
|
579
663
|
return lead.confidence >= options.minConfidence;
|
|
580
664
|
}
|
|
581
665
|
|
|
666
|
+
function countGoodLeads(leadsByKey) {
|
|
667
|
+
let count = 0;
|
|
668
|
+
for (const lead of leadsByKey.values()) {
|
|
669
|
+
if (isGoodLead(lead)) count++;
|
|
670
|
+
}
|
|
671
|
+
return count;
|
|
672
|
+
}
|
|
673
|
+
|
|
582
674
|
function sameDomain(domain, link) {
|
|
583
675
|
try {
|
|
584
676
|
const host = new URL(link).hostname.toLowerCase().replace(/^www\./, '');
|
|
@@ -904,6 +996,205 @@ function decodeURIComponentSafe(value) {
|
|
|
904
996
|
}
|
|
905
997
|
}
|
|
906
998
|
|
|
999
|
+
async function mapLimit(items, limit, worker) {
|
|
1000
|
+
const results = new Array(items.length);
|
|
1001
|
+
let nextIndex = 0;
|
|
1002
|
+
const workerCount = Math.min(limit, items.length);
|
|
1003
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
1004
|
+
while (nextIndex < items.length) {
|
|
1005
|
+
const index = nextIndex++;
|
|
1006
|
+
results[index] = await worker(items[index], index);
|
|
1007
|
+
}
|
|
1008
|
+
}));
|
|
1009
|
+
return results;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function createLimiter(limit) {
|
|
1013
|
+
let active = 0;
|
|
1014
|
+
const queue = [];
|
|
1015
|
+
const runNext = () => {
|
|
1016
|
+
if (active >= limit || queue.length === 0) return;
|
|
1017
|
+
const item = queue.shift();
|
|
1018
|
+
active++;
|
|
1019
|
+
Promise.resolve()
|
|
1020
|
+
.then(item.task)
|
|
1021
|
+
.then(item.resolve, item.reject)
|
|
1022
|
+
.finally(() => {
|
|
1023
|
+
active--;
|
|
1024
|
+
runNext();
|
|
1025
|
+
});
|
|
1026
|
+
};
|
|
1027
|
+
return (task) => new Promise((resolve, reject) => {
|
|
1028
|
+
queue.push({ task, resolve, reject });
|
|
1029
|
+
runNext();
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function withOriginPace(context, url, options, task) {
|
|
1034
|
+
const delayMs = boundedInteger(options.delayMs, DEFAULT_DELAY_MS, 0, 10_000);
|
|
1035
|
+
if (delayMs <= 0) return task();
|
|
1036
|
+
const origin = originOf(url) || String(url || '');
|
|
1037
|
+
if (!origin) return task();
|
|
1038
|
+
|
|
1039
|
+
let pace = context.originPaces.get(origin);
|
|
1040
|
+
if (!pace) {
|
|
1041
|
+
pace = { gate: Promise.resolve(), nextAt: 0 };
|
|
1042
|
+
context.originPaces.set(origin, pace);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
let releaseGate;
|
|
1046
|
+
const previousGate = pace.gate;
|
|
1047
|
+
pace.gate = new Promise((resolve) => {
|
|
1048
|
+
releaseGate = resolve;
|
|
1049
|
+
});
|
|
1050
|
+
await previousGate;
|
|
1051
|
+
|
|
1052
|
+
const now = Date.now();
|
|
1053
|
+
const waitMs = Math.max(0, pace.nextAt - now);
|
|
1054
|
+
pace.nextAt = Math.max(now, pace.nextAt) + delayMs;
|
|
1055
|
+
releaseGate();
|
|
1056
|
+
|
|
1057
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
1058
|
+
return task();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function crawlerConcurrency(options) {
|
|
1062
|
+
return boundedInteger(
|
|
1063
|
+
options.concurrency ?? options.maxConcurrency ?? options.maxConcurrentDomains,
|
|
1064
|
+
DEFAULT_DOMAIN_CONCURRENCY,
|
|
1065
|
+
1,
|
|
1066
|
+
HARD_DOMAIN_CONCURRENCY,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function loadCrawlerCache(options = {}) {
|
|
1071
|
+
const enabled = !(options.noCache || options.disableCache);
|
|
1072
|
+
const cache = {
|
|
1073
|
+
enabled,
|
|
1074
|
+
path: resolveProjectPath(options.cachePath || DEFAULT_CACHE_PATH),
|
|
1075
|
+
robots: new Map(),
|
|
1076
|
+
dns: new Map(),
|
|
1077
|
+
pages: new Map(),
|
|
1078
|
+
dirty: false,
|
|
1079
|
+
robotsTtlMs: boundedInteger(options.robotsCacheTtlMs, DEFAULT_ROBOTS_CACHE_TTL_MS, 0, MAX_CACHE_TTL_MS),
|
|
1080
|
+
dnsTtlMs: boundedInteger(options.dnsCacheTtlMs, DEFAULT_DNS_CACHE_TTL_MS, 0, MAX_CACHE_TTL_MS),
|
|
1081
|
+
pageTtlMs: pageCacheTtlMs(options),
|
|
1082
|
+
};
|
|
1083
|
+
if (!enabled || !existsSync(cache.path)) return cache;
|
|
1084
|
+
|
|
1085
|
+
const value = readCrawlerCacheFile(cache.path);
|
|
1086
|
+
for (const [origin, record] of Object.entries(value.robots || {})) {
|
|
1087
|
+
if (origin && record?.rules) cache.robots.set(origin, record);
|
|
1088
|
+
}
|
|
1089
|
+
for (const [domain, record] of Object.entries(value.dns || {})) {
|
|
1090
|
+
if (domain && record?.status) cache.dns.set(domain, record);
|
|
1091
|
+
}
|
|
1092
|
+
for (const [pageUrl, record] of Object.entries(value.pages || {})) {
|
|
1093
|
+
if (pageUrl && record?.page) cache.pages.set(pageUrl, record);
|
|
1094
|
+
}
|
|
1095
|
+
return cache;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function saveCrawlerCache(cache) {
|
|
1099
|
+
if (!cache?.enabled || !cache.dirty) return;
|
|
1100
|
+
const current = readCrawlerCacheFile(cache.path);
|
|
1101
|
+
const value = {
|
|
1102
|
+
version: 1,
|
|
1103
|
+
generatedAt: new Date().toISOString(),
|
|
1104
|
+
robots: mergeTimedRecords(current.robots, Object.fromEntries(cache.robots), 'fetchedAt'),
|
|
1105
|
+
dns: mergeTimedRecords(current.dns, Object.fromEntries(cache.dns), 'checkedAt'),
|
|
1106
|
+
pages: mergeTimedRecords(current.pages, Object.fromEntries(cache.pages), 'fetchedAt'),
|
|
1107
|
+
};
|
|
1108
|
+
mkdirSync(dirname(cache.path), { recursive: true });
|
|
1109
|
+
const tempPath = `${cache.path}.${process.pid}.${Date.now()}.tmp`;
|
|
1110
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
1111
|
+
renameSync(tempPath, cache.path);
|
|
1112
|
+
cache.dirty = false;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function readCrawlerCacheFile(path) {
|
|
1116
|
+
try {
|
|
1117
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
1118
|
+
return value && typeof value === 'object' ? value : {};
|
|
1119
|
+
} catch {
|
|
1120
|
+
return {};
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function mergeTimedRecords(current = {}, incoming = {}, timestampKey) {
|
|
1125
|
+
const out = { ...(current && typeof current === 'object' ? current : {}) };
|
|
1126
|
+
for (const [key, record] of Object.entries(incoming || {})) {
|
|
1127
|
+
const currentTime = Date.parse(out[key]?.[timestampKey] || '') || 0;
|
|
1128
|
+
const nextTime = Date.parse(record?.[timestampKey] || '') || 0;
|
|
1129
|
+
if (!out[key] || nextTime >= currentTime) out[key] = record;
|
|
1130
|
+
}
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function getCachedRobots(cache, origin) {
|
|
1135
|
+
const record = freshRecord(cache.robots.get(origin), 'fetchedAt', cache.robotsTtlMs);
|
|
1136
|
+
return record?.rules || null;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function setCachedRobots(cache, origin, rules) {
|
|
1140
|
+
if (!cache.enabled || !origin) return;
|
|
1141
|
+
cache.robots.set(origin, {
|
|
1142
|
+
fetchedAt: new Date().toISOString(),
|
|
1143
|
+
rules: {
|
|
1144
|
+
disallow: Array.isArray(rules?.disallow) ? rules.disallow.map((item) => String(item)) : [],
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1147
|
+
cache.dirty = true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function getCachedDnsStatus(cache, domain) {
|
|
1151
|
+
const record = freshRecord(cache.dns.get(domain), 'checkedAt', cache.dnsTtlMs);
|
|
1152
|
+
return record?.status || '';
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function setCachedDnsStatus(cache, domain, status) {
|
|
1156
|
+
if (!cache.enabled || !domain) return;
|
|
1157
|
+
cache.dns.set(domain, {
|
|
1158
|
+
checkedAt: new Date().toISOString(),
|
|
1159
|
+
status,
|
|
1160
|
+
});
|
|
1161
|
+
cache.dirty = true;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function getCachedPage(cache, pageUrl) {
|
|
1165
|
+
const record = freshRecord(cache.pages.get(pageUrl), 'fetchedAt', cache.pageTtlMs);
|
|
1166
|
+
return record?.page || null;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function setCachedPage(cache, pageUrl, page) {
|
|
1170
|
+
if (!cache.enabled || cache.pageTtlMs <= 0 || !pageUrl || !page) return;
|
|
1171
|
+
cache.pages.set(pageUrl, {
|
|
1172
|
+
fetchedAt: new Date().toISOString(),
|
|
1173
|
+
page,
|
|
1174
|
+
});
|
|
1175
|
+
cache.dirty = true;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function freshRecord(record, timestampKey, ttlMs) {
|
|
1179
|
+
if (!record || ttlMs <= 0) return null;
|
|
1180
|
+
const timestamp = Date.parse(record[timestampKey] || '');
|
|
1181
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
1182
|
+
return Date.now() - timestamp <= ttlMs ? record : null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function pageCacheTtlMs(options) {
|
|
1186
|
+
const configured = Number(options.pageCacheTtlMs);
|
|
1187
|
+
if (Number.isFinite(configured) && configured > 0) {
|
|
1188
|
+
return boundedInteger(configured, DEFAULT_PAGE_CACHE_TTL_MS, 1_000, MAX_CACHE_TTL_MS);
|
|
1189
|
+
}
|
|
1190
|
+
return optionFlag(options.pageCache) ? DEFAULT_PAGE_CACHE_TTL_MS : 0;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function optionFlag(value) {
|
|
1194
|
+
if (typeof value === 'boolean') return value;
|
|
1195
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
1196
|
+
}
|
|
1197
|
+
|
|
907
1198
|
function boundedInteger(value, fallback, min, max) {
|
|
908
1199
|
const number = Number(value);
|
|
909
1200
|
if (!Number.isFinite(number)) return fallback;
|