@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.
@@ -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
- for (const target of targets) {
43
- try {
44
- const result = await crawlDomainTarget(target, {
45
- ...options,
46
- maxPages,
47
- minConfidence,
48
- includeBlocked,
49
- });
50
- results.push(result);
51
- for (const lead of result.leads) {
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
- return crawlDomainTarget(target, options);
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
- async function crawlDomainTarget(target, options) {
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 robotsCache = new Map();
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
- while (queue.length > 0 && pages.length < maxPages) {
140
- const pageUrl = queue.shift();
141
- if (!pageUrl || seen.has(pageUrl)) continue;
142
- seen.add(pageUrl);
143
-
144
- const allowed = await allowedByRobots(pageUrl, robotsCache, options, warnings);
145
- if (!allowed) {
146
- pages.push({ url: pageUrl, title: '', statusCode: 0, emailsFound: 0, error: 'blocked by robots.txt' });
147
- continue;
148
- }
149
-
150
- const page = await fetchPage(pageUrl, options).catch((error) => ({
151
- error: error instanceof Error ? error.message : String(error),
152
- }));
153
- if (page.error) {
154
- pages.push({ url: pageUrl, title: '', statusCode: 0, emailsFound: 0, error: page.error });
155
- continue;
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
- if (!websiteUrl) websiteUrl = originOf(page.url);
159
- pages.push({
160
- url: page.url,
161
- title: page.title,
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
- for (const email of page.emails) {
170
- const lead = await buildEmailLead(email, target.domain, websiteUrl, page);
171
- if (!isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) continue;
172
- const key = leadKey(lead);
173
- const current = leadsByKey.get(key);
174
- leadsByKey.set(key, current ? mergeLeadRecords(current, lead) : lead);
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
- if (page.hasForm && isContactLikeURL(page.url)) {
178
- const lead = buildContactPath(target.domain, websiteUrl, page);
179
- if (isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) {
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
- for (const link of page.links) {
187
- if (queued.has(link) || seen.has(link)) continue;
188
- if (!sameDomain(target.domain, link) || !isHighSignalPage(link)) continue;
189
- queued.add(link);
190
- queue.push(link);
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
- queue.sort((a, b) => pagePriority(b) - pagePriority(a));
193
- await sleep(boundedInteger(options.delayMs, DEFAULT_DELAY_MS, 0, 10_000));
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 fetchPage(pageUrl, options) {
212
- const controller = new AbortController();
213
- const timeout = setTimeout(() => controller.abort(), boundedInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 1_000, 60_000));
214
- try {
215
- const response = await fetch(pageUrl, {
216
- headers: {
217
- Accept: 'text/html,application/xhtml+xml',
218
- 'User-Agent': options.userAgent || DEFAULT_USER_AGENT,
219
- },
220
- redirect: 'follow',
221
- signal: controller.signal,
222
- });
223
- if (response.status >= 400) throw new Error(`HTTP ${response.status}`);
224
- const contentType = String(response.headers.get('content-type') || '').toLowerCase();
225
- if (contentType && !contentType.includes('html')) {
226
- throw new Error(`skipped non-HTML content type ${contentType}`);
227
- }
228
- const contentLength = Number(response.headers.get('content-length') || 0);
229
- if (contentLength > MAX_BODY_CHARS) {
230
- throw new Error(`skipped large response (${contentLength} bytes)`);
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
- const html = (await response.text()).slice(0, MAX_BODY_CHARS);
234
- const decoded = decodeEntities(html);
235
- const text = compactText(stripHtml(decoded));
236
- const finalUrl = response.url || pageUrl;
237
- const phones = extractPhones(decoded, text);
238
- const socialUrls = extractSocialUrls(finalUrl, decoded);
239
- const contactUrls = extractContactUrls(finalUrl, decoded);
240
- return {
241
- url: finalUrl,
242
- statusCode: response.status,
243
- title: compactText(extractTitle(decoded)),
244
- text,
245
- links: extractLinks(finalUrl, decoded),
246
- emails: extractEmails(decoded),
247
- phones,
248
- socialUrls,
249
- contactUrls,
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, cache, options, warnings) {
345
+ async function allowedByRobots(pageUrl, context, options, warnings) {
258
346
  const origin = originOf(pageUrl);
259
347
  if (!origin) return true;
260
- if (!cache.has(origin)) {
261
- cache.set(origin, await fetchRobots(origin, options).catch((error) => {
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
- return { disallow: [] };
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 response = await fetch(`${origin.replace(/\/+$/, '')}/robots.txt`, {
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 extractLinks(baseUrl, html) {
418
+ function extractPageRefs(baseUrl, html) {
321
419
  const links = [];
322
- const seen = new Set();
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 href = match[1] || match[2] || match[3] || '';
327
- const link = normalizeLink(baseUrl, href);
328
- if (!link || seen.has(link)) continue;
329
- seen.add(link);
330
- links.push(link);
331
- }
332
- links.sort((a, b) => pagePriority(b) - pagePriority(a));
333
- return links;
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
- function normalizeLink(baseUrl, href) {
337
- const raw = String(href || '').trim();
338
- if (!raw) return '';
339
- const lower = raw.toLowerCase();
340
- if (lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('javascript:') || lower.startsWith('#')) {
341
- return '';
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
- function extractEmails(html) {
357
- const decoded = decodeEntities(String(html || ''));
358
- const values = [decoded];
359
- const mailtoRe = /href\s*=\s*(?:"mailto:([^"]+)"|'mailto:([^']+)'|mailto:([^\s>]+))/gi;
360
- let match;
361
- while ((match = mailtoRe.exec(decoded))) {
362
- values.push(decodeURIComponentSafe(match[1] || match[2] || match[3] || ''));
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(html, text) {
376
- const decoded = decodeEntities(String(html || ''));
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 extractSocialUrls(baseUrl, html) {
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;