@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 +2 -1
- package/config/profile.example.yml +13 -0
- package/docs/ARCHITECTURE.md +4 -0
- package/lib/leadharness-crawler.mjs +489 -199
- package/lib/leadharness-leads.mjs +69 -0
- 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 +16 -2
- package/scripts/pipeline.mjs +16 -2
- package/templates/contracts.json +8 -0
- package/templates/lead-schema.json +20 -0
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:
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
-
|
|
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';
|
|
@@ -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.
|
|
29
|
-
4.
|
|
30
|
-
5.
|
|
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
|
@@ -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
|
-
|
|
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);
|
package/scripts/pipeline.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/templates/contracts.json
CHANGED
|
@@ -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" },
|