@agent-pattern-labs/leads-rig 0.1.6 → 0.1.8

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/batch/README.md CHANGED
@@ -17,10 +17,11 @@ id domain company notes
17
17
  ```bash
18
18
  batch/batch-runner.sh --dry-run
19
19
  batch/batch-runner.sh --parallel 2
20
+ batch/batch-runner.sh --parallel 2 --timeout-ms 8000
20
21
  batch/batch-runner.sh --runner codex --parallel 2
21
22
  ```
22
23
 
23
- `--parallel` is capped at `2` to keep browser-heavy work bounded. Worker CLI permission-bypass flags are disabled by default; pass `--allow-unsafe-workers` only in a trusted local workspace when you explicitly want the old unsafe behavior.
24
+ `--parallel` is capped at `2` to keep browser-heavy work bounded. `--timeout-ms` controls the deterministic crawler timeout suggested to workers; the batch default is `8000` to avoid spending too long on dead sites. Worker CLI permission-bypass flags are disabled by default; pass `--allow-unsafe-workers` only in a trusted local workspace when you explicitly want the old unsafe behavior.
24
25
 
25
26
  The runner writes:
26
27
 
@@ -18,10 +18,23 @@ api:
18
18
 
19
19
  crawl:
20
20
  max_pages: 10
21
+ timeout_ms: 14000
21
22
  max_domains_per_batch: 50
22
23
  concurrency: 2
24
+ page_concurrency: 1
25
+ dns_concurrency: 8
26
+ cache_path: ".leadharness-cache/crawler-cache.json"
27
+ robots_cache_ttl_ms: 86400000
28
+ dns_cache_ttl_ms: 604800000
29
+ page_cache: false
30
+ page_cache_ttl_ms: 0
31
+ stop_after_good_leads: 0
32
+ stop_after_contact_path: false
23
33
  user_agent: "PublicLeadsBot/0.1 (+https://example.com/public-leads)"
24
34
 
35
+ batch:
36
+ batch_timeout_ms: 8000
37
+
25
38
  lead_policy:
26
39
  min_confidence: 30
27
40
  allowed_email_types:
@@ -82,4 +82,8 @@ The package also carries the `@agent-pattern-labs/iso-*` helper ecosystem for tr
82
82
  - page visits
83
83
  - ingest requests
84
84
 
85
+ Lead records preserve optional enrichment fields such as `linkedinUrl`, `phone`,
86
+ `address`, `streetAddress`, `location`, `city`, `region`, `postalCode`, and
87
+ `country` through normalization, deduplication, and ingest.
88
+
85
89
  The validator intentionally accepts the same defaults the local runtime normalizes, but it fails missing source evidence, invalid URL fields, invalid `emailType`, invalid `verificationStatus`, invalid confidence, person leads without email, and generic catch-all inboxes such as `info@`, `hello@`, or similar organizational aliases. Any email record must be a named `person` lead with a non-generic email and a human owner visible in the evidence; role inboxes, unknown-owner emails, blocked emails, unsupported emails, and unnamed person-like emails fail validation. Summaries expose `goodLeadCount`, which only counts those high-quality named human email records.
@@ -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, isGoodLead, 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';
@@ -580,6 +663,14 @@ function isRelevantLead(lead, options) {
580
663
  return lead.confidence >= options.minConfidence;
581
664
  }
582
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
+
583
674
  function sameDomain(domain, link) {
584
675
  try {
585
676
  const host = new URL(link).hostname.toLowerCase().replace(/^www\./, '');
@@ -905,6 +996,205 @@ function decodeURIComponentSafe(value) {
905
996
  }
906
997
  }
907
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
+
908
1198
  function boundedInteger(value, fallback, min, max) {
909
1199
  const number = Number(value);
910
1200
  if (!Number.isFinite(number)) return fallback;
@@ -162,7 +162,15 @@ export function normalizeLead(input, { now = new Date().toISOString() } = {}) {
162
162
  const verificationStatus = normalizeVerificationStatus(input.verificationStatus, emailType);
163
163
  const confidence = clampInteger(input.confidence, 0, 100, 0);
164
164
  const foundAt = normalizeDateTime(input.foundAt, now);
165
+ const linkedinUrl = normalizeLinkedInUrl(input.linkedinUrl);
165
166
  const phone = stringValue(input.phone);
167
+ const address = compactWhitespace(input.address);
168
+ const streetAddress = compactWhitespace(input.streetAddress);
169
+ const location = compactWhitespace(input.location);
170
+ const city = compactWhitespace(input.city);
171
+ const region = compactWhitespace(input.region);
172
+ const postalCode = compactWhitespace(input.postalCode);
173
+ const country = compactWhitespace(input.country);
166
174
  const socialUrls = normalizeUrlList(input.socialUrls);
167
175
  const contactUrls = normalizeUrlList(input.contactUrls);
168
176
 
@@ -187,7 +195,15 @@ export function normalizeLead(input, { now = new Date().toISOString() } = {}) {
187
195
  verificationStatus,
188
196
  confidence,
189
197
  warnings: normalizeWarnings(input.warnings),
198
+ linkedinUrl,
190
199
  phone,
200
+ address,
201
+ streetAddress,
202
+ location,
203
+ city,
204
+ region,
205
+ postalCode,
206
+ country,
191
207
  socialUrls,
192
208
  contactUrls,
193
209
  foundAt,
@@ -471,7 +487,18 @@ export function loadProfileConfig(root = PROJECT_DIR) {
471
487
  adminTokenEnv: yamlScalar(text, 'admin_token_env'),
472
488
  maxPages: yamlScalar(text, 'max_pages'),
473
489
  maxDomainsPerBatch: yamlScalar(text, 'max_domains_per_batch'),
490
+ timeoutMs: yamlScalar(text, 'timeout_ms'),
474
491
  concurrency: yamlScalar(text, 'concurrency'),
492
+ pageConcurrency: yamlScalar(text, 'page_concurrency'),
493
+ dnsConcurrency: yamlScalar(text, 'dns_concurrency'),
494
+ cachePath: yamlScalar(text, 'cache_path'),
495
+ robotsCacheTtlMs: yamlScalar(text, 'robots_cache_ttl_ms'),
496
+ dnsCacheTtlMs: yamlScalar(text, 'dns_cache_ttl_ms'),
497
+ pageCache: yamlScalar(text, 'page_cache'),
498
+ pageCacheTtlMs: yamlScalar(text, 'page_cache_ttl_ms'),
499
+ stopAfterGoodLeads: yamlScalar(text, 'stop_after_good_leads'),
500
+ stopAfterContactPath: yamlScalar(text, 'stop_after_contact_path'),
501
+ batchTimeoutMs: yamlScalar(text, 'batch_timeout_ms'),
475
502
  userAgent: yamlScalar(text, 'user_agent'),
476
503
  minConfidence: yamlScalar(text, 'min_confidence'),
477
504
  };
@@ -536,6 +563,15 @@ function normalizeLeadRecord(lead) {
536
563
  sources,
537
564
  evidence: sources[0]?.evidence || compactWhitespace(lead?.evidence),
538
565
  warnings: normalizeWarnings(lead?.warnings),
566
+ linkedinUrl: normalizeLinkedInUrl(lead?.linkedinUrl),
567
+ phone: stringValue(lead?.phone),
568
+ address: compactWhitespace(lead?.address),
569
+ streetAddress: compactWhitespace(lead?.streetAddress),
570
+ location: compactWhitespace(lead?.location),
571
+ city: compactWhitespace(lead?.city),
572
+ region: compactWhitespace(lead?.region),
573
+ postalCode: compactWhitespace(lead?.postalCode),
574
+ country: compactWhitespace(lead?.country),
539
575
  socialUrls: normalizeUrlList(lead?.socialUrls),
540
576
  contactUrls: normalizeUrlList(lead?.contactUrls),
541
577
  };
@@ -563,7 +599,15 @@ export function mergeLeadRecords(current, candidate) {
563
599
  ? secondary.verificationStatus
564
600
  : primary.verificationStatus,
565
601
  confidence: Math.max(primary.confidence || 0, secondary.confidence || 0),
602
+ linkedinUrl: primary.linkedinUrl || secondary.linkedinUrl,
566
603
  phone: primary.phone || secondary.phone,
604
+ address: longerString(primary.address, secondary.address),
605
+ streetAddress: primary.streetAddress || secondary.streetAddress,
606
+ location: longerString(primary.location, secondary.location),
607
+ city: primary.city || secondary.city,
608
+ region: primary.region || secondary.region,
609
+ postalCode: primary.postalCode || secondary.postalCode,
610
+ country: primary.country || secondary.country,
567
611
  socialUrls: normalizeUrlList([...(primary.socialUrls || []), ...(secondary.socialUrls || [])]),
568
612
  contactUrls: normalizeUrlList([...(primary.contactUrls || []), ...(secondary.contactUrls || [])]),
569
613
  warnings: normalizeWarnings([...(primary.warnings || []), ...(secondary.warnings || [])]),
@@ -831,6 +875,25 @@ function normalizeUrlList(value) {
831
875
  return urls;
832
876
  }
833
877
 
878
+ function normalizeLinkedInUrl(value) {
879
+ let raw = stringValue(value);
880
+ if (!raw) return '';
881
+ if (!raw.includes('://')) raw = `https://${raw}`;
882
+
883
+ try {
884
+ const url = new URL(raw);
885
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
886
+ const host = url.hostname.toLowerCase();
887
+ if (host !== 'linkedin.com' && !host.endsWith('.linkedin.com')) return '';
888
+
889
+ const path = url.pathname.replace(/\/+$/, '');
890
+ if (!/^\/(in|pub|company|school|showcase)\//i.test(path)) return '';
891
+ return `https://www.linkedin.com${path}`;
892
+ } catch {
893
+ return '';
894
+ }
895
+ }
896
+
834
897
  function normalizeDateTime(value, fallback) {
835
898
  const raw = stringValue(value);
836
899
  if (!raw) return fallback;
@@ -865,6 +928,12 @@ function compactWhitespace(value) {
865
928
  return stringValue(value).replace(/\s+/g, ' ');
866
929
  }
867
930
 
931
+ function longerString(left, right) {
932
+ const first = stringValue(left);
933
+ const second = stringValue(right);
934
+ return second.length > first.length ? second : first;
935
+ }
936
+
868
937
  function displayCompany(domain) {
869
938
  const clean = normalizeDomain(domain);
870
939
  if (!clean) return '';
package/modes/batch.md CHANGED
@@ -25,9 +25,10 @@ id domain company notes
25
25
 
26
26
  1. Run `batch/batch-runner.sh --dry-run` first unless the user explicitly asks to start immediately.
27
27
  2. Use `--parallel 2` by default. Do not exceed 2 browser-heavy workers per round.
28
- 3. Each worker writes `batch/lead-results-{id}.json`.
29
- 4. Validate every artifact and update `data/lead-manifest.json`.
30
- 5. Run `npx public-leads verify`.
28
+ 3. For broad queues, keep the batch deterministic crawler timeout near the default `--timeout-ms 8000` unless target quality is known to be high.
29
+ 4. Each worker writes `batch/lead-results-{id}.json`.
30
+ 5. Validate every artifact and update `data/lead-manifest.json`.
31
+ 6. Run `npx public-leads verify`.
31
32
 
32
33
  ## Output
33
34
 
package/modes/crawl.md CHANGED
@@ -7,7 +7,7 @@ Use this for one or more assigned domains.
7
7
  1. Normalize the domain to lowercase without protocol or `www.`.
8
8
  2. Visit only bounded public pages from the official site.
9
9
  3. Prefer the deterministic crawler when no browser-only blocker exists:
10
- `npx public-leads crawl --input data/domains.tsv --out data/lead-results.json`
10
+ `npx public-leads crawl --input data/domains.tsv --out data/lead-results.json --concurrency 2 --page-concurrency 1`
11
11
  4. Extract public email addresses, person names/titles when visible nearby, contact forms, source URLs, page titles, evidence snippets, and warnings.
12
12
  5. Emit a JSON payload matching `templates/lead-schema.json`.
13
13
  6. Save the payload to `data/lead-results.json` for single-domain runs or `batch/lead-results-{id}.json` for batch workers.
package/modes/pipeline.md CHANGED
@@ -15,7 +15,7 @@ Read domains from the first available source:
15
15
  1. Build a deduped candidate list.
16
16
  2. Drop domains already represented in the manifest unless the user asks to retry.
17
17
  3. Prefer the deterministic end-to-end command for normal public pages:
18
- `npx public-leads pipeline --input data/domains.tsv --out data/lead-results.json`
18
+ `npx public-leads pipeline --input data/domains.tsv --out data/lead-results.json --concurrency 2 --page-concurrency 1`
19
19
  4. Use browser/MCP workers only when the deterministic crawler cannot reach a public page or the site requires rendering.
20
20
  5. For browser-heavy batch work, use `batch/batch-runner.sh --parallel 2`.
21
21
  6. Validate every artifact with `npx public-leads validate`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-pattern-labs/leads-rig",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Agentic public-web lead discovery harness with portable ingest artifacts",
5
5
  "type": "module",
6
6
  "author": "Razroo",
@@ -7,6 +7,7 @@ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
7
7
  import { dirname, join, resolve } from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { runWorkflow } from '@agent-pattern-labs/iso-orchestrator';
10
+ import { loadProfileConfig } from '../lib/leadharness-leads.mjs';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const PKG_ROOT = resolve(__dirname, '..');
@@ -37,6 +38,7 @@ Options:
37
38
  --retry-failed Only retry rows marked failed
38
39
  --start-from N Start from numeric id N
39
40
  --max-retries N Max failed retries per domain (default: 2)
41
+ --timeout-ms N Deterministic crawler page timeout in worker prompt (default: 8000)
40
42
  --workflow-id ID Durable workflow id (default: public-leads-batch)
41
43
  -h, --help Show help
42
44
 
@@ -113,6 +115,7 @@ async function main(opts) {
113
115
  }
114
116
 
115
117
  function parseArgs(argv) {
118
+ const profile = loadProfileConfig();
116
119
  const opts = {
117
120
  runner: process.env.PUBLIC_LEADS_BATCH_RUNNER || process.env.LEAD_HARNESS_BATCH_RUNNER || 'opencode',
118
121
  parallel: 1,
@@ -121,6 +124,7 @@ function parseArgs(argv) {
121
124
  retryFailed: false,
122
125
  startFrom: 0,
123
126
  maxRetries: 2,
127
+ timeoutMs: positiveInt(process.env.PUBLIC_LEADS_BATCH_TIMEOUT_MS || process.env.LEAD_HARNESS_BATCH_TIMEOUT_MS || profile.batchTimeoutMs || 8_000, 'batch timeout'),
124
128
  workflowId: 'public-leads-batch',
125
129
  };
126
130
 
@@ -138,6 +142,7 @@ function parseArgs(argv) {
138
142
  else if (arg === '--retry-failed') opts.retryFailed = true;
139
143
  else if (arg === '--start-from') opts.startFrom = nonNegativeInt(next(), '--start-from');
140
144
  else if (arg === '--max-retries') opts.maxRetries = positiveInt(next(), '--max-retries');
145
+ else if (arg === '--timeout-ms') opts.timeoutMs = positiveInt(next(), '--timeout-ms');
141
146
  else if (arg === '--workflow-id') opts.workflowId = sanitizeWorkflowId(next());
142
147
  else if (arg === '-h' || arg === '--help') opts.help = true;
143
148
  else throw new Error(`unknown option: ${arg}`);
@@ -299,7 +304,7 @@ async function processDomain(workflow, item, opts) {
299
304
  retries,
300
305
  });
301
306
 
302
- const prompt = buildWorkerPrompt(item, artifact);
307
+ const prompt = buildWorkerPrompt(item, artifact, opts);
303
308
  const run = await withWorkerLiveness(workflow, item, logFile, () => runWorker(opts, prompt, logFile));
304
309
  const statuses = parseStatusLines(run.output);
305
310
  const status = statuses.get(item.id);
@@ -356,12 +361,15 @@ async function processDomain(workflow, item, opts) {
356
361
  return { id: item.id, status: 'completed', artifact: status.artifact || artifact, leadCount: status.leadCount ?? 0 };
357
362
  }
358
363
 
359
- function buildWorkerPrompt(item, artifact) {
364
+ function buildWorkerPrompt(item, artifact, opts) {
360
365
  return `Process this assigned public-leads domain.
361
366
 
362
367
  Assignment:
363
368
  ${JSON.stringify({ ...item, artifact }, null, 2)}
364
369
 
370
+ Prefer the deterministic crawler first:
371
+ npx public-leads crawl --domain ${item.domain} --out ${artifact} --timeout-ms ${opts.timeoutMs} --stop-after-good-leads 1 --stop-after-contact-path
372
+
365
373
  Write the artifact exactly to ${artifact}. Validate it with:
366
374
  npx public-leads validate --input ${artifact}
367
375
 
package/scripts/crawl.mjs CHANGED
@@ -17,7 +17,10 @@ const USAGE = `public-leads crawl -- crawl public company pages into a lead arti
17
17
  Usage:
18
18
  public-leads crawl [--domain example.com | --domains example.com,example.org]
19
19
  [--input data/domains.tsv] [--out data/lead-results.json]
20
- [--max-pages 10] [--min-confidence 30]
20
+ [--max-pages 10] [--timeout-ms 14000] [--min-confidence 30]
21
+ [--concurrency 2] [--page-concurrency 1] [--dns-concurrency 8]
22
+ [--stop-after-good-leads 1] [--stop-after-contact-path]
23
+ [--cache-path .leadharness-cache/crawler-cache.json] [--page-cache] [--page-cache-ttl-ms 3600000] [--no-cache]
21
24
  [--include-blocked] [--allow-empty] [--json]
22
25
 
23
26
  When no domain or input is supplied, the first existing file is used:
@@ -44,10 +47,21 @@ async function main() {
44
47
  const payload = await crawlDomains(domains, {
45
48
  maxPages: opts.maxPages || profile.maxPages,
46
49
  minConfidence: opts.minConfidence || profile.minConfidence,
50
+ timeoutMs: opts.timeoutMs || profile.timeoutMs,
51
+ concurrency: opts.concurrency || profile.concurrency,
52
+ pageConcurrency: opts.pageConcurrency || profile.pageConcurrency,
53
+ dnsConcurrency: opts.dnsConcurrency || profile.dnsConcurrency,
47
54
  userAgent: opts.userAgent || profile.userAgent,
48
55
  includeBlocked: Boolean(opts.includeBlocked),
49
56
  delayMs: opts.delayMs,
50
- timeoutMs: opts.timeoutMs,
57
+ cachePath: opts.cachePath || profile.cachePath,
58
+ noCache: Boolean(opts.noCache),
59
+ robotsCacheTtlMs: opts.robotsCacheTtlMs || profile.robotsCacheTtlMs,
60
+ dnsCacheTtlMs: opts.dnsCacheTtlMs || profile.dnsCacheTtlMs,
61
+ pageCache: opts.pageCache || profile.pageCache,
62
+ pageCacheTtlMs: opts.pageCacheTtlMs || profile.pageCacheTtlMs,
63
+ stopAfterGoodLeads: opts.stopAfterGoodLeads || profile.stopAfterGoodLeads,
64
+ stopAfterContactPath: opts.stopAfterContactPath || profile.stopAfterContactPath,
51
65
  jobId: opts.jobId,
52
66
  });
53
67
  writeJson(resolveProjectPath(out), payload);
@@ -21,7 +21,10 @@ Usage:
21
21
  public-leads pipeline [--domain example.com | --domains example.com,example.org]
22
22
  [--input data/domains.tsv] [--out data/lead-results.json]
23
23
  [--manifest data/lead-manifest.json]
24
- [--max-pages 10] [--min-confidence 30]
24
+ [--max-pages 10] [--timeout-ms 14000] [--min-confidence 30]
25
+ [--concurrency 2] [--page-concurrency 1] [--dns-concurrency 8]
26
+ [--stop-after-good-leads 1] [--stop-after-contact-path]
27
+ [--cache-path .leadharness-cache/crawler-cache.json] [--page-cache] [--page-cache-ttl-ms 3600000] [--no-cache]
25
28
  [--ingest | --upload] [--dry-run]
26
29
  [--api https://cold-agent-leads.example.com]
27
30
 
@@ -47,10 +50,21 @@ try {
47
50
  const payload = await crawlDomains(domains, {
48
51
  maxPages: opts.maxPages || profile.maxPages,
49
52
  minConfidence: opts.minConfidence || profile.minConfidence,
53
+ timeoutMs: opts.timeoutMs || profile.timeoutMs,
54
+ concurrency: opts.concurrency || profile.concurrency,
55
+ pageConcurrency: opts.pageConcurrency || profile.pageConcurrency,
56
+ dnsConcurrency: opts.dnsConcurrency || profile.dnsConcurrency,
50
57
  userAgent: opts.userAgent || profile.userAgent,
51
58
  includeBlocked: Boolean(opts.includeBlocked),
52
59
  delayMs: opts.delayMs,
53
- timeoutMs: opts.timeoutMs,
60
+ cachePath: opts.cachePath || profile.cachePath,
61
+ noCache: Boolean(opts.noCache),
62
+ robotsCacheTtlMs: opts.robotsCacheTtlMs || profile.robotsCacheTtlMs,
63
+ dnsCacheTtlMs: opts.dnsCacheTtlMs || profile.dnsCacheTtlMs,
64
+ pageCache: opts.pageCache || profile.pageCache,
65
+ pageCacheTtlMs: opts.pageCacheTtlMs || profile.pageCacheTtlMs,
66
+ stopAfterGoodLeads: opts.stopAfterGoodLeads || profile.stopAfterGoodLeads,
67
+ stopAfterContactPath: opts.stopAfterContactPath || profile.stopAfterContactPath,
54
68
  jobId: opts.jobId,
55
69
  });
56
70
  writeJson(resolveProjectPath(out), payload);
@@ -30,7 +30,15 @@
30
30
  },
31
31
  { "name": "confidence", "type": "integer", "required": true, "min": 0, "max": 100 },
32
32
  { "name": "warnings", "type": "json" },
33
+ { "name": "linkedinUrl", "type": "url" },
33
34
  { "name": "phone", "type": "string" },
35
+ { "name": "address", "type": "string" },
36
+ { "name": "streetAddress", "type": "string" },
37
+ { "name": "location", "type": "string" },
38
+ { "name": "city", "type": "string" },
39
+ { "name": "region", "type": "string" },
40
+ { "name": "postalCode", "type": "string" },
41
+ { "name": "country", "type": "string" },
34
42
  { "name": "socialUrls", "type": "json" },
35
43
  { "name": "contactUrls", "type": "json" },
36
44
  { "name": "foundAt", "type": "datetime" }
@@ -74,10 +74,30 @@
74
74
  "items": { "type": "string" },
75
75
  "default": []
76
76
  },
77
+ "linkedinUrl": {
78
+ "type": "string",
79
+ "description": "Optional canonical public LinkedIn profile or organization URL."
80
+ },
77
81
  "phone": {
78
82
  "type": "string",
79
83
  "description": "Optional normalized phone number found near the public contact evidence."
80
84
  },
85
+ "address": {
86
+ "type": "string",
87
+ "description": "Optional complete public business address."
88
+ },
89
+ "streetAddress": {
90
+ "type": "string",
91
+ "description": "Optional street-address component."
92
+ },
93
+ "location": {
94
+ "type": "string",
95
+ "description": "Optional display location for the lead."
96
+ },
97
+ "city": { "type": "string" },
98
+ "region": { "type": "string" },
99
+ "postalCode": { "type": "string" },
100
+ "country": { "type": "string" },
81
101
  "socialUrls": {
82
102
  "type": "array",
83
103
  "items": { "type": "string" },