@agent-pattern-labs/leads-rig 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/general-free.md +39 -0
- package/.claude/agents/general-paid.md +20 -0
- package/.claude/agents/glm-minimal.md +9 -0
- package/.claude/iso-route.resolved.json +21 -0
- package/.claude/settings.json +3 -0
- package/.codex/config.toml +24 -0
- package/.cursor/iso-route.md +17 -0
- package/.cursor/mcp.json +19 -0
- package/.cursor/rules/agent-general-free.mdc +38 -0
- package/.cursor/rules/agent-general-paid.mdc +19 -0
- package/.cursor/rules/agent-glm-minimal.mdc +8 -0
- package/.cursor/rules/main.mdc +61 -0
- package/.mcp.json +19 -0
- package/.opencode/agents/general-free.md +48 -0
- package/.opencode/agents/general-paid.md +29 -0
- package/.opencode/agents/glm-minimal.md +19 -0
- package/.opencode/instructions.md +3 -0
- package/.opencode/skills/lead-harness.md +56 -0
- package/.opencode/skills/public-leads.md +56 -0
- package/.pi/prompts/lead-harness.md +54 -0
- package/.pi/prompts/public-leads.md +54 -0
- package/.pi/skills/general-free/SKILL.md +38 -0
- package/.pi/skills/general-paid/SKILL.md +19 -0
- package/.pi/skills/glm-minimal/SKILL.md +8 -0
- package/AGENTS.md +56 -0
- package/CLAUDE.md +56 -0
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/batch/README.md +37 -0
- package/batch/batch-prompt.md +19 -0
- package/batch/batch-runner.sh +18 -0
- package/bin/create-leads-harness.mjs +178 -0
- package/bin/lead-harness.mjs +201 -0
- package/bin/sync.mjs +150 -0
- package/config/profile.example.yml +34 -0
- package/docs/ARCHITECTURE.md +85 -0
- package/docs/CONSTRUCTION.md +40 -0
- package/docs/README.md +5 -0
- package/docs/SETUP.md +89 -0
- package/examples/README.md +9 -0
- package/examples/sample-leads.json +57 -0
- package/iso/agents/general-free.md +50 -0
- package/iso/agents/general-paid.md +31 -0
- package/iso/agents/glm-minimal.md +21 -0
- package/iso/commands/lead-harness.md +59 -0
- package/iso/commands/public-leads.md +59 -0
- package/iso/config.json +11 -0
- package/iso/instructions.md +56 -0
- package/iso/instructions.opencode.md +3 -0
- package/iso/mcp.json +16 -0
- package/lib/leadharness-crawler.mjs +911 -0
- package/lib/leadharness-ingest.mjs +157 -0
- package/lib/leadharness-leads.mjs +574 -0
- package/models.yaml +32 -0
- package/modes/_shared.md +50 -0
- package/modes/batch.md +34 -0
- package/modes/crawl.md +25 -0
- package/modes/ingest.md +33 -0
- package/modes/pipeline.md +27 -0
- package/modes/reference-local-helpers.md +13 -0
- package/modes/review.md +15 -0
- package/modes/setup.md +25 -0
- package/opencode.json +43 -0
- package/package.json +186 -0
- package/scripts/batch-orchestrator.mjs +558 -0
- package/scripts/crawl.mjs +129 -0
- package/scripts/ingest.mjs +48 -0
- package/scripts/manifest.mjs +71 -0
- package/scripts/pipeline.mjs +118 -0
- package/scripts/validate-leads.mjs +69 -0
- package/templates/canon.json +26 -0
- package/templates/capabilities.json +23 -0
- package/templates/context.json +20 -0
- package/templates/contracts.json +72 -0
- package/templates/facts.json +13 -0
- package/templates/index.json +13 -0
- package/templates/lead-schema.json +143 -0
- package/templates/lineage.json +10 -0
- package/templates/migrations.json +4 -0
- package/templates/postflight.json +11 -0
- package/templates/preflight.json +19 -0
- package/templates/prioritize.json +10 -0
- package/templates/redact.json +15 -0
- package/templates/score.json +18 -0
- package/templates/states.yml +25 -0
- package/templates/timeline.json +10 -0
- package/verify-pipeline.mjs +123 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { lookup, resolveMx } from 'dns/promises';
|
|
3
|
+
import { setTimeout as sleep } from 'timers/promises';
|
|
4
|
+
import { cleanDomains, normalizeDomain } from './leadharness-leads.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_PAGES = 10;
|
|
7
|
+
const HARD_MAX_PAGES = 25;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 14_000;
|
|
9
|
+
const DEFAULT_DELAY_MS = 200;
|
|
10
|
+
const DEFAULT_USER_AGENT = 'PublicLeadsBot/0.1 (+https://example.com/public-leads)';
|
|
11
|
+
const MAX_BODY_CHARS = 2 * 1024 * 1024;
|
|
12
|
+
const EMAIL_RE = /\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/gi;
|
|
13
|
+
const NAME_RE = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
|
|
14
|
+
const PHONE_RE = /(?:\+?1[\s.-]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[\s.-]?[2-9]\d{2}[\s.-]?\d{4}\b/g;
|
|
15
|
+
const BLOCKED_LOCAL_PARTS = new Set(['abuse', 'postmaster', 'hostmaster', 'security', 'privacy', 'legal', 'dmca', 'noreply', 'no-reply']);
|
|
16
|
+
const ROLE_LOCAL_PARTS = new Set([
|
|
17
|
+
'admin', 'booking', 'bookings', 'business', 'contact', 'customerservice',
|
|
18
|
+
'customer-service', 'customersuccess', 'customer-success', 'events', 'hello',
|
|
19
|
+
'help', 'hi', 'info', 'inquiries', 'enquiries', 'media', 'office', 'outreach',
|
|
20
|
+
'partners', 'partnerships', 'press', 'sales', 'service', 'services',
|
|
21
|
+
'speaking', 'success', 'support', 'team',
|
|
22
|
+
]);
|
|
23
|
+
const PERSONAL_EMAIL_DOMAINS = new Set([
|
|
24
|
+
'aol.com', 'gmail.com', 'googlemail.com', 'hotmail.com', 'icloud.com',
|
|
25
|
+
'live.com', 'mac.com', 'me.com', 'msn.com', 'outlook.com', 'proton.me',
|
|
26
|
+
'protonmail.com', 'yahoo.com',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export async function crawlDomains(inputs, options = {}) {
|
|
30
|
+
const targets = normalizeCrawlTargets(inputs);
|
|
31
|
+
if (targets.length === 0) {
|
|
32
|
+
throw new Error('at least one domain is required');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const maxPages = boundedInteger(options.maxPages, DEFAULT_MAX_PAGES, 1, HARD_MAX_PAGES);
|
|
36
|
+
const minConfidence = boundedInteger(options.minConfidence, 30, 0, 100);
|
|
37
|
+
const includeBlocked = Boolean(options.includeBlocked);
|
|
38
|
+
const results = [];
|
|
39
|
+
const errors = [];
|
|
40
|
+
const leadsByKey = new Map();
|
|
41
|
+
|
|
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) {
|
|
52
|
+
const key = leadKey(lead);
|
|
53
|
+
const current = leadsByKey.get(key);
|
|
54
|
+
if (!current || isBetterLead(lead, current)) {
|
|
55
|
+
leadsByKey.set(key, lead);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
errors.push(`${target.domain}: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const leads = [...leadsByKey.values()].sort(sortLead);
|
|
64
|
+
const domains = cleanDomains([
|
|
65
|
+
...targets.map((target) => target.domain),
|
|
66
|
+
...results.map((result) => result.domain),
|
|
67
|
+
...leads.map((lead) => lead.domain),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
jobId: options.jobId || `public-leads-${timestampForID()}-${hash(domains.join('|')).slice(0, 8)}`,
|
|
72
|
+
domains,
|
|
73
|
+
leads,
|
|
74
|
+
results,
|
|
75
|
+
errors,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function crawlDomain(input, options = {}) {
|
|
80
|
+
const [target] = normalizeCrawlTargets([input]);
|
|
81
|
+
if (!target) throw new Error('domain is required');
|
|
82
|
+
return crawlDomainTarget(target, options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeCrawlTargets(inputs) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const input of inputs || []) {
|
|
89
|
+
const target = normalizeCrawlTarget(input);
|
|
90
|
+
if (!target || seen.has(target.key)) continue;
|
|
91
|
+
seen.add(target.key);
|
|
92
|
+
out.push(target);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeCrawlTarget(input) {
|
|
98
|
+
const rawInput = String(input || '').trim();
|
|
99
|
+
if (!rawInput) return null;
|
|
100
|
+
const raw = rawInput.includes('://') ? rawInput : `https://${rawInput}`;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = new URL(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!parsed.hostname || !['http:', 'https:'].includes(parsed.protocol)) return null;
|
|
108
|
+
|
|
109
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
110
|
+
const domain = normalizeDomain(host);
|
|
111
|
+
const starts = [];
|
|
112
|
+
if (parsed.pathname !== '/' || parsed.search) {
|
|
113
|
+
parsed.hash = '';
|
|
114
|
+
starts.push(parsed.toString());
|
|
115
|
+
}
|
|
116
|
+
starts.push(`${parsed.protocol}//${parsed.host}/`);
|
|
117
|
+
if (!parsed.port && host !== 'localhost') {
|
|
118
|
+
starts.push(`https://${host}/`, `http://${host}/`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
key: parsed.port ? `${host}:${parsed.port}` : host,
|
|
123
|
+
domain,
|
|
124
|
+
host,
|
|
125
|
+
starts: [...new Set(starts)],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function crawlDomainTarget(target, options) {
|
|
130
|
+
const maxPages = boundedInteger(options.maxPages, DEFAULT_MAX_PAGES, 1, HARD_MAX_PAGES);
|
|
131
|
+
const minConfidence = boundedInteger(options.minConfidence, 30, 0, 100);
|
|
132
|
+
const queue = [...target.starts];
|
|
133
|
+
const queued = new Set(queue);
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const leadsByKey = new Map();
|
|
136
|
+
const warnings = [];
|
|
137
|
+
const pages = [];
|
|
138
|
+
const robotsCache = new Map();
|
|
139
|
+
let websiteUrl = '';
|
|
140
|
+
|
|
141
|
+
while (queue.length > 0 && pages.length < maxPages) {
|
|
142
|
+
const pageUrl = queue.shift();
|
|
143
|
+
if (!pageUrl || seen.has(pageUrl)) continue;
|
|
144
|
+
seen.add(pageUrl);
|
|
145
|
+
|
|
146
|
+
const allowed = await allowedByRobots(pageUrl, robotsCache, options, warnings);
|
|
147
|
+
if (!allowed) {
|
|
148
|
+
pages.push({ url: pageUrl, title: '', statusCode: 0, emailsFound: 0, error: 'blocked by robots.txt' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const page = await fetchPage(pageUrl, options).catch((error) => ({
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
}));
|
|
155
|
+
if (page.error) {
|
|
156
|
+
pages.push({ url: pageUrl, title: '', statusCode: 0, emailsFound: 0, error: page.error });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!websiteUrl) websiteUrl = originOf(page.url);
|
|
161
|
+
pages.push({
|
|
162
|
+
url: page.url,
|
|
163
|
+
title: page.title,
|
|
164
|
+
statusCode: page.statusCode,
|
|
165
|
+
emailsFound: page.emails.length,
|
|
166
|
+
phonesFound: page.phones.length,
|
|
167
|
+
socialUrlsFound: page.socialUrls.length,
|
|
168
|
+
contactUrlsFound: page.contactUrls.length,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
for (const email of page.emails) {
|
|
172
|
+
const lead = await buildEmailLead(email, target.domain, websiteUrl, page);
|
|
173
|
+
if (!isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) continue;
|
|
174
|
+
const key = leadKey(lead);
|
|
175
|
+
const current = leadsByKey.get(key);
|
|
176
|
+
if (!current || isBetterLead(lead, current)) {
|
|
177
|
+
leadsByKey.set(key, lead);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (page.hasForm && isContactLikeURL(page.url)) {
|
|
182
|
+
const lead = buildContactPath(target.domain, websiteUrl, page);
|
|
183
|
+
if (isRelevantLead(lead, { minConfidence, includeBlocked: options.includeBlocked })) {
|
|
184
|
+
const key = leadKey(lead);
|
|
185
|
+
const current = leadsByKey.get(key);
|
|
186
|
+
if (!current || isBetterLead(lead, current)) {
|
|
187
|
+
leadsByKey.set(key, lead);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const link of page.links) {
|
|
193
|
+
if (queued.has(link) || seen.has(link)) continue;
|
|
194
|
+
if (!sameDomain(target.domain, link) || !isHighSignalPage(link)) continue;
|
|
195
|
+
queued.add(link);
|
|
196
|
+
queue.push(link);
|
|
197
|
+
}
|
|
198
|
+
queue.sort((a, b) => pagePriority(b) - pagePriority(a));
|
|
199
|
+
await sleep(boundedInteger(options.delayMs, DEFAULT_DELAY_MS, 0, 10_000));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const leads = [...leadsByKey.values()].sort(sortLead);
|
|
203
|
+
const completedAt = new Date().toISOString();
|
|
204
|
+
if (pages.length === 0) {
|
|
205
|
+
warnings.push(`no pages could be crawled for ${target.domain}`);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
domain: target.domain,
|
|
209
|
+
websiteUrl: websiteUrl || `https://${target.domain}/`,
|
|
210
|
+
leads,
|
|
211
|
+
pages,
|
|
212
|
+
warnings,
|
|
213
|
+
completedAt,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function fetchPage(pageUrl, options) {
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const timeout = setTimeout(() => controller.abort(), boundedInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 1_000, 60_000));
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetch(pageUrl, {
|
|
222
|
+
headers: {
|
|
223
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
224
|
+
'User-Agent': options.userAgent || DEFAULT_USER_AGENT,
|
|
225
|
+
},
|
|
226
|
+
redirect: 'follow',
|
|
227
|
+
signal: controller.signal,
|
|
228
|
+
});
|
|
229
|
+
if (response.status >= 400) throw new Error(`HTTP ${response.status}`);
|
|
230
|
+
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
|
|
231
|
+
if (contentType && !contentType.includes('html')) {
|
|
232
|
+
throw new Error(`skipped non-HTML content type ${contentType}`);
|
|
233
|
+
}
|
|
234
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
235
|
+
if (contentLength > MAX_BODY_CHARS) {
|
|
236
|
+
throw new Error(`skipped large response (${contentLength} bytes)`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const html = (await response.text()).slice(0, MAX_BODY_CHARS);
|
|
240
|
+
const decoded = decodeEntities(html);
|
|
241
|
+
const text = compactText(stripHtml(decoded));
|
|
242
|
+
const finalUrl = response.url || pageUrl;
|
|
243
|
+
const phones = extractPhones(decoded, text);
|
|
244
|
+
const socialUrls = extractSocialUrls(finalUrl, decoded);
|
|
245
|
+
const contactUrls = extractContactUrls(finalUrl, decoded);
|
|
246
|
+
return {
|
|
247
|
+
url: finalUrl,
|
|
248
|
+
statusCode: response.status,
|
|
249
|
+
title: compactText(extractTitle(decoded)),
|
|
250
|
+
text,
|
|
251
|
+
links: extractLinks(finalUrl, decoded),
|
|
252
|
+
emails: extractEmails(decoded),
|
|
253
|
+
phones,
|
|
254
|
+
socialUrls,
|
|
255
|
+
contactUrls,
|
|
256
|
+
hasForm: /<form\b/i.test(decoded),
|
|
257
|
+
};
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timeout);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function allowedByRobots(pageUrl, cache, options, warnings) {
|
|
264
|
+
const origin = originOf(pageUrl);
|
|
265
|
+
if (!origin) return true;
|
|
266
|
+
if (!cache.has(origin)) {
|
|
267
|
+
cache.set(origin, await fetchRobots(origin, options).catch((error) => {
|
|
268
|
+
warnings.push(`robots.txt check failed for ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
269
|
+
return { disallow: [] };
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
const rules = cache.get(origin);
|
|
273
|
+
let path = '/';
|
|
274
|
+
try {
|
|
275
|
+
const parsed = new URL(pageUrl);
|
|
276
|
+
path = parsed.pathname || '/';
|
|
277
|
+
} catch {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
return !rules.disallow.some((rule) => rule && path.startsWith(rule));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function fetchRobots(origin, options) {
|
|
284
|
+
const response = await fetch(`${origin.replace(/\/+$/, '')}/robots.txt`, {
|
|
285
|
+
headers: { 'User-Agent': options.userAgent || DEFAULT_USER_AGENT },
|
|
286
|
+
signal: AbortSignal.timeout?.(5_000),
|
|
287
|
+
});
|
|
288
|
+
if (response.status === 404) return { disallow: [] };
|
|
289
|
+
if (response.status >= 400) throw new Error(`robots.txt returned HTTP ${response.status}`);
|
|
290
|
+
return parseRobots(await response.text());
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseRobots(body) {
|
|
294
|
+
const disallow = [];
|
|
295
|
+
let applies = false;
|
|
296
|
+
let sawDirective = false;
|
|
297
|
+
for (const rawLine of String(body || '').split(/\r?\n/)) {
|
|
298
|
+
const line = rawLine.split('#')[0].trim();
|
|
299
|
+
if (!line) continue;
|
|
300
|
+
const index = line.indexOf(':');
|
|
301
|
+
if (index === -1) continue;
|
|
302
|
+
const key = line.slice(0, index).trim().toLowerCase();
|
|
303
|
+
const value = line.slice(index + 1).trim();
|
|
304
|
+
if (key === 'user-agent') {
|
|
305
|
+
if (sawDirective) {
|
|
306
|
+
applies = false;
|
|
307
|
+
sawDirective = false;
|
|
308
|
+
}
|
|
309
|
+
const agent = value.toLowerCase();
|
|
310
|
+
applies = agent === '*' || agent.includes('publicleadsbot') || agent.includes('coldagentleadsbot');
|
|
311
|
+
} else if (key === 'disallow') {
|
|
312
|
+
sawDirective = true;
|
|
313
|
+
if (applies && value) disallow.push(value);
|
|
314
|
+
} else {
|
|
315
|
+
sawDirective = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
disallow.sort((a, b) => b.length - a.length);
|
|
319
|
+
return { disallow };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function extractTitle(html) {
|
|
323
|
+
return decodeEntities(String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractLinks(baseUrl, html) {
|
|
327
|
+
const links = [];
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
const hrefRe = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
330
|
+
let match;
|
|
331
|
+
while ((match = hrefRe.exec(html))) {
|
|
332
|
+
const href = match[1] || match[2] || match[3] || '';
|
|
333
|
+
const link = normalizeLink(baseUrl, href);
|
|
334
|
+
if (!link || seen.has(link)) continue;
|
|
335
|
+
seen.add(link);
|
|
336
|
+
links.push(link);
|
|
337
|
+
}
|
|
338
|
+
links.sort((a, b) => pagePriority(b) - pagePriority(a));
|
|
339
|
+
return links;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeLink(baseUrl, href) {
|
|
343
|
+
const raw = String(href || '').trim();
|
|
344
|
+
if (!raw) return '';
|
|
345
|
+
const lower = raw.toLowerCase();
|
|
346
|
+
if (lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('javascript:') || lower.startsWith('#')) {
|
|
347
|
+
return '';
|
|
348
|
+
}
|
|
349
|
+
let url;
|
|
350
|
+
try {
|
|
351
|
+
url = new URL(raw, baseUrl);
|
|
352
|
+
} catch {
|
|
353
|
+
return '';
|
|
354
|
+
}
|
|
355
|
+
if (!['http:', 'https:'].includes(url.protocol)) return '';
|
|
356
|
+
url.hash = '';
|
|
357
|
+
url.search = '';
|
|
358
|
+
if (skipByExtension(url.pathname)) return '';
|
|
359
|
+
return url.toString();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extractEmails(html) {
|
|
363
|
+
const decoded = decodeEntities(String(html || ''));
|
|
364
|
+
const values = [decoded];
|
|
365
|
+
const mailtoRe = /href\s*=\s*(?:"mailto:([^"]+)"|'mailto:([^']+)'|mailto:([^\s>]+))/gi;
|
|
366
|
+
let match;
|
|
367
|
+
while ((match = mailtoRe.exec(decoded))) {
|
|
368
|
+
values.push(decodeURIComponentSafe(match[1] || match[2] || match[3] || ''));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const emails = new Set();
|
|
372
|
+
for (const value of values) {
|
|
373
|
+
for (const found of String(value || '').matchAll(EMAIL_RE)) {
|
|
374
|
+
const email = found[0].toLowerCase().replace(/^mailto:/, '').replace(/[.,;:!?()[\]{}<>"']+$/g, '');
|
|
375
|
+
if (email && !isLikelyAssetEmail(email)) emails.add(email);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return [...emails].sort();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function extractPhones(html, text) {
|
|
382
|
+
const decoded = decodeEntities(String(html || ''));
|
|
383
|
+
const values = [String(text || '')];
|
|
384
|
+
const telRe = /href\s*=\s*(?:"tel:([^"]+)"|'tel:([^']+)'|tel:([^\s>]+))/gi;
|
|
385
|
+
let match;
|
|
386
|
+
while ((match = telRe.exec(decoded))) {
|
|
387
|
+
values.push(decodeURIComponentSafe(match[1] || match[2] || match[3] || ''));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const phones = new Set();
|
|
391
|
+
for (const value of values) {
|
|
392
|
+
for (const found of String(value || '').matchAll(PHONE_RE)) {
|
|
393
|
+
const phone = normalizePhone(found[0]);
|
|
394
|
+
if (phone) phones.add(phone);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return [...phones].sort().slice(0, 5);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function extractSocialUrls(baseUrl, html) {
|
|
401
|
+
return extractHrefUrls(baseUrl, html)
|
|
402
|
+
.filter((url) => isPublicProfileUrl(url))
|
|
403
|
+
.slice(0, 8);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function extractContactUrls(baseUrl, html) {
|
|
407
|
+
return extractHrefUrls(baseUrl, html)
|
|
408
|
+
.filter((url) => isSchedulingOrContactUrl(url))
|
|
409
|
+
.slice(0, 8);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function extractHrefUrls(baseUrl, html) {
|
|
413
|
+
const urls = [];
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
const hrefRe = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
416
|
+
let match;
|
|
417
|
+
while ((match = hrefRe.exec(String(html || '')))) {
|
|
418
|
+
const raw = decodeEntities(match[1] || match[2] || match[3] || '');
|
|
419
|
+
const lower = raw.toLowerCase();
|
|
420
|
+
if (!raw || lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('javascript:') || lower.startsWith('#')) continue;
|
|
421
|
+
let url;
|
|
422
|
+
try {
|
|
423
|
+
url = new URL(raw, baseUrl);
|
|
424
|
+
} catch {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (!['http:', 'https:'].includes(url.protocol)) continue;
|
|
428
|
+
url.hash = '';
|
|
429
|
+
const value = url.toString();
|
|
430
|
+
if (seen.has(value)) continue;
|
|
431
|
+
seen.add(value);
|
|
432
|
+
urls.push(value);
|
|
433
|
+
}
|
|
434
|
+
return urls;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function buildEmailLead(email, companyDomain, websiteUrl, page) {
|
|
438
|
+
const sourceUrl = page.url;
|
|
439
|
+
const pageTitle = page.title;
|
|
440
|
+
const text = page.text;
|
|
441
|
+
const emailDomain = domainFromEmail(email);
|
|
442
|
+
const personalWebmail = isPersonalWebmailDomain(emailDomain);
|
|
443
|
+
const local = email.split('@')[0].toLowerCase();
|
|
444
|
+
let emailType = classifyEmailLocal(local);
|
|
445
|
+
const snippet = evidenceSnippet(text, email) || fallbackSnippet(pageTitle, sourceUrl);
|
|
446
|
+
const warnings = [];
|
|
447
|
+
let confidence = 45;
|
|
448
|
+
|
|
449
|
+
if (emailDomain === companyDomain || emailDomain.endsWith(`.${companyDomain}`)) {
|
|
450
|
+
confidence += 25;
|
|
451
|
+
} else {
|
|
452
|
+
confidence -= 20;
|
|
453
|
+
warnings.push('email domain differs from company domain');
|
|
454
|
+
}
|
|
455
|
+
if (personalWebmail) {
|
|
456
|
+
confidence -= 10;
|
|
457
|
+
warnings.push('personal webmail address; only use if visibly published for business contact');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let contactName = personalWebmail ? '' : (inferNameFromEmailLocal(local) || inferNameFromDomainLocal(local, companyDomain));
|
|
461
|
+
let title = '';
|
|
462
|
+
if (contactName) emailType = 'person';
|
|
463
|
+
if (personalWebmail && emailType === 'person') emailType = 'unknown';
|
|
464
|
+
|
|
465
|
+
if (emailType === 'person') {
|
|
466
|
+
title = inferTitle(snippet);
|
|
467
|
+
confidence += 20;
|
|
468
|
+
} else if (emailType === 'role') {
|
|
469
|
+
title = roleTitle(local);
|
|
470
|
+
confidence -= 5;
|
|
471
|
+
warnings.push('role inbox; review fit before outreach');
|
|
472
|
+
} else if (emailType === 'unknown') {
|
|
473
|
+
title = inferTitle(snippet);
|
|
474
|
+
confidence -= 5;
|
|
475
|
+
warnings.push('email address is published but not enough evidence to classify as a named person or role inbox');
|
|
476
|
+
} else if (emailType === 'blocked') {
|
|
477
|
+
confidence = 0;
|
|
478
|
+
warnings.push('blocked operational inbox; do not use for outreach');
|
|
479
|
+
}
|
|
480
|
+
if (!contactName && emailType === 'person') contactName = inferName(snippet);
|
|
481
|
+
if (contactName) confidence += 10;
|
|
482
|
+
if (title && title !== 'Role inbox') confidence += 5;
|
|
483
|
+
if (page.phones.length > 0) confidence += 3;
|
|
484
|
+
if (page.socialUrls.length > 0) confidence += 2;
|
|
485
|
+
if (page.contactUrls.length > 0) confidence += 2;
|
|
486
|
+
if (personalWebmail && emailType !== 'blocked') {
|
|
487
|
+
confidence = Math.max(confidence, 38);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const verificationStatus = await emailVerificationStatus(emailDomain);
|
|
491
|
+
if (verificationStatus !== 'mx_verified' && emailType !== 'blocked') {
|
|
492
|
+
warnings.push('email domain MX could not be verified');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const lead = {
|
|
496
|
+
company: displayCompany(companyDomain),
|
|
497
|
+
domain: companyDomain,
|
|
498
|
+
websiteUrl,
|
|
499
|
+
contactName,
|
|
500
|
+
title,
|
|
501
|
+
email,
|
|
502
|
+
emailType,
|
|
503
|
+
sourceUrl,
|
|
504
|
+
sourceLabel: sourceLabel(sourceUrl),
|
|
505
|
+
evidence: snippet,
|
|
506
|
+
extractionMethod: 'agentic_harness_public_page',
|
|
507
|
+
verificationStatus: emailType === 'blocked' ? 'blocked' : verificationStatus,
|
|
508
|
+
confidence: clamp(confidence, 0, 100),
|
|
509
|
+
warnings,
|
|
510
|
+
phone: page.phones[0] || '',
|
|
511
|
+
socialUrls: page.socialUrls,
|
|
512
|
+
contactUrls: page.contactUrls,
|
|
513
|
+
foundAt: new Date().toISOString(),
|
|
514
|
+
};
|
|
515
|
+
lead.id = stableID(leadKey(lead));
|
|
516
|
+
return lead;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function buildContactPath(domain, websiteUrl, page) {
|
|
520
|
+
const sourceUrl = page.url;
|
|
521
|
+
const confidence = 35
|
|
522
|
+
+ (safePath(sourceUrl).includes('contact') ? 10 : 0)
|
|
523
|
+
+ (page.phones.length > 0 ? 5 : 0)
|
|
524
|
+
+ (page.socialUrls.length > 0 ? 3 : 0)
|
|
525
|
+
+ (page.contactUrls.length > 0 ? 5 : 0);
|
|
526
|
+
const lead = {
|
|
527
|
+
company: displayCompany(domain),
|
|
528
|
+
domain,
|
|
529
|
+
websiteUrl,
|
|
530
|
+
contactName: '',
|
|
531
|
+
title: 'Contact form',
|
|
532
|
+
email: '',
|
|
533
|
+
emailType: 'contact_path',
|
|
534
|
+
sourceUrl,
|
|
535
|
+
sourceLabel: sourceLabel(sourceUrl),
|
|
536
|
+
evidence: contactPathEvidence(page),
|
|
537
|
+
extractionMethod: 'agentic_harness_contact_form',
|
|
538
|
+
verificationStatus: 'not_applicable',
|
|
539
|
+
confidence: clamp(confidence, 0, 100),
|
|
540
|
+
warnings: [],
|
|
541
|
+
phone: page.phones[0] || '',
|
|
542
|
+
socialUrls: page.socialUrls,
|
|
543
|
+
contactUrls: page.contactUrls,
|
|
544
|
+
foundAt: new Date().toISOString(),
|
|
545
|
+
};
|
|
546
|
+
lead.id = stableID(leadKey(lead));
|
|
547
|
+
return lead;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function emailVerificationStatus(domain) {
|
|
551
|
+
if (!domain) return 'unknown';
|
|
552
|
+
try {
|
|
553
|
+
const mx = await resolveMx(domain);
|
|
554
|
+
if (mx.length > 0) return 'mx_verified';
|
|
555
|
+
} catch {
|
|
556
|
+
// Fall back to a host lookup before marking unknown.
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
await lookup(domain);
|
|
560
|
+
return 'unverified';
|
|
561
|
+
} catch {
|
|
562
|
+
return 'unknown';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function classifyEmailLocal(local) {
|
|
567
|
+
if (BLOCKED_LOCAL_PARTS.has(local)) return 'blocked';
|
|
568
|
+
if (ROLE_LOCAL_PARTS.has(local)) return 'role';
|
|
569
|
+
if (inferNameFromEmailLocal(local)) return 'person';
|
|
570
|
+
return 'unknown';
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isRelevantLead(lead, options) {
|
|
574
|
+
if (!options.includeBlocked && lead.emailType === 'blocked') return false;
|
|
575
|
+
return lead.confidence >= options.minConfidence;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sameDomain(domain, link) {
|
|
579
|
+
try {
|
|
580
|
+
const host = new URL(link).hostname.toLowerCase().replace(/^www\./, '');
|
|
581
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
582
|
+
} catch {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function isHighSignalPage(link) {
|
|
588
|
+
const lower = link.toLowerCase();
|
|
589
|
+
if (pagePriority(link) > 0) return true;
|
|
590
|
+
return lower.endsWith('/') && lower.split('/').length <= 4;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function pagePriority(link) {
|
|
594
|
+
const path = safePath(link);
|
|
595
|
+
const weighted = [
|
|
596
|
+
['contact', 100],
|
|
597
|
+
['about', 90],
|
|
598
|
+
['team', 85],
|
|
599
|
+
['people', 85],
|
|
600
|
+
['leadership', 85],
|
|
601
|
+
['staff', 80],
|
|
602
|
+
['founder', 80],
|
|
603
|
+
['press', 65],
|
|
604
|
+
['media', 65],
|
|
605
|
+
['blog', 55],
|
|
606
|
+
['author', 55],
|
|
607
|
+
['career', 45],
|
|
608
|
+
['privacy', 25],
|
|
609
|
+
['legal', 20],
|
|
610
|
+
['impressum', 20],
|
|
611
|
+
];
|
|
612
|
+
for (const [needle, score] of weighted) {
|
|
613
|
+
if (path.includes(needle)) return score;
|
|
614
|
+
}
|
|
615
|
+
return path === '/' ? 50 : 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function isContactLikeURL(link) {
|
|
619
|
+
const path = safePath(link);
|
|
620
|
+
return /contact|about|team|people|leadership|staff/.test(path);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function safePath(link) {
|
|
624
|
+
try {
|
|
625
|
+
return new URL(link).pathname.toLowerCase();
|
|
626
|
+
} catch {
|
|
627
|
+
return '';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function evidenceSnippet(text, needle) {
|
|
632
|
+
const compact = compactText(text);
|
|
633
|
+
const lower = compact.toLowerCase();
|
|
634
|
+
const index = lower.indexOf(String(needle || '').toLowerCase());
|
|
635
|
+
if (index === -1) return '';
|
|
636
|
+
const start = Math.max(0, index - 140);
|
|
637
|
+
const end = Math.min(compact.length, index + String(needle).length + 140);
|
|
638
|
+
return trimToWordBoundary(compact.slice(start, end));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function fallbackSnippet(...values) {
|
|
642
|
+
for (const value of values) {
|
|
643
|
+
const text = compactText(value);
|
|
644
|
+
if (!text) continue;
|
|
645
|
+
return text.length > 260 ? trimToWordBoundary(text.slice(0, 260)) : text;
|
|
646
|
+
}
|
|
647
|
+
return '';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function inferName(snippet) {
|
|
651
|
+
for (const match of String(snippet || '').matchAll(NAME_RE)) {
|
|
652
|
+
const value = match[0];
|
|
653
|
+
if (!isBadNameCandidate(value)) return value;
|
|
654
|
+
}
|
|
655
|
+
return '';
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function inferNameFromEmailLocal(local) {
|
|
659
|
+
const parts = String(local || '')
|
|
660
|
+
.split(/[._-]+/)
|
|
661
|
+
.map((part) => part.trim().toLowerCase())
|
|
662
|
+
.filter(Boolean);
|
|
663
|
+
if (parts.length < 2 || parts.length > 3) return '';
|
|
664
|
+
if (parts.some((part) => part.length < 2 || /\d/.test(part) || ROLE_LOCAL_PARTS.has(part) || BLOCKED_LOCAL_PARTS.has(part) || isBrandLikeNamePart(part))) return '';
|
|
665
|
+
return parts.map(titleCase).join(' ');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function inferNameFromDomainLocal(local, domain) {
|
|
669
|
+
const first = String(local || '').toLowerCase();
|
|
670
|
+
if (first.length < 3 || ROLE_LOCAL_PARTS.has(first) || BLOCKED_LOCAL_PARTS.has(first)) return '';
|
|
671
|
+
|
|
672
|
+
let root = String(domain || '').split('.')[0]?.toLowerCase() || '';
|
|
673
|
+
root = root.replace(/^dr/, '');
|
|
674
|
+
root = root.replace(/(advisory|advisors|advisor|agency|books|coaching|consulting|group|partners|solutions|studio|strategy)$/i, '');
|
|
675
|
+
if (!root.startsWith(first)) return '';
|
|
676
|
+
|
|
677
|
+
const last = root.slice(first.length);
|
|
678
|
+
if (last.length < 2 || /[^a-z]/.test(last) || ROLE_LOCAL_PARTS.has(last) || isBrandLikeNamePart(last)) return '';
|
|
679
|
+
return `${titleCase(first)} ${titleCase(last)}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function inferTitle(snippet) {
|
|
683
|
+
const lower = String(snippet || '').toLowerCase();
|
|
684
|
+
const titles = [
|
|
685
|
+
['chief executive officer', 'CEO'],
|
|
686
|
+
['co-founder', 'Co-founder'],
|
|
687
|
+
['cofounder', 'Co-founder'],
|
|
688
|
+
['founder', 'Founder'],
|
|
689
|
+
['owner', 'Owner'],
|
|
690
|
+
['president', 'President'],
|
|
691
|
+
['principal', 'Principal'],
|
|
692
|
+
['director', 'Director'],
|
|
693
|
+
['head of sales', 'Head of sales'],
|
|
694
|
+
['sales', 'Sales'],
|
|
695
|
+
['partnership', 'Partnerships'],
|
|
696
|
+
['operations', 'Operations'],
|
|
697
|
+
['marketing', 'Marketing'],
|
|
698
|
+
];
|
|
699
|
+
return titles.find(([needle]) => lower.includes(needle))?.[1] || '';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function roleTitle(local) {
|
|
703
|
+
const normalized = String(local || '').replace(/[._-]+/g, ' ');
|
|
704
|
+
if (normalized.includes('sales')) return 'Sales';
|
|
705
|
+
if (normalized.includes('partner')) return 'Partnerships';
|
|
706
|
+
if (normalized.includes('press') || normalized.includes('media')) return 'Media';
|
|
707
|
+
if (normalized.includes('booking') || normalized.includes('speaking') || normalized.includes('events')) return 'Bookings';
|
|
708
|
+
if (normalized.includes('success') || normalized.includes('support') || normalized.includes('service')) return 'Customer support';
|
|
709
|
+
return 'Role inbox';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function isBadNameCandidate(value) {
|
|
713
|
+
const bad = new Set(['Contact Us', 'About Us', 'Privacy Policy', 'Terms Conditions', 'All Rights', 'Email Address', 'Phone Number', 'Home Contact']);
|
|
714
|
+
if (bad.has(value)) return true;
|
|
715
|
+
return value.split(/\s+/).some((word) => ['email', 'contact', 'privacy', 'copyright'].includes(word.toLowerCase()));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function contactPathEvidence(page) {
|
|
719
|
+
const title = compactText(page?.title || '');
|
|
720
|
+
if (title.length >= 8) return title;
|
|
721
|
+
const text = compactText(page?.text || '');
|
|
722
|
+
if (text.length >= 8) return text.length > 260 ? trimToWordBoundary(text.slice(0, 260)) : text;
|
|
723
|
+
return 'The public page contains a contact form.';
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function stripHtml(html) {
|
|
727
|
+
return String(html || '')
|
|
728
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
|
|
729
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
|
|
730
|
+
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, ' ')
|
|
731
|
+
.replace(/<svg\b[\s\S]*?<\/svg>/gi, ' ')
|
|
732
|
+
.replace(/<[^>]+>/g, ' ');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function decodeEntities(value) {
|
|
736
|
+
return String(value || '')
|
|
737
|
+
.replace(/ /gi, ' ')
|
|
738
|
+
.replace(/&/gi, '&')
|
|
739
|
+
.replace(/</gi, '<')
|
|
740
|
+
.replace(/>/gi, '>')
|
|
741
|
+
.replace(/"/gi, '"')
|
|
742
|
+
.replace(/'/g, "'")
|
|
743
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
744
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(Number.parseInt(code, 16)));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function compactText(value) {
|
|
748
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function trimToWordBoundary(value) {
|
|
752
|
+
return compactText(value);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function sourceLabel(sourceUrl) {
|
|
756
|
+
const path = safePath(sourceUrl).replace(/^\/+|\/+$/g, '');
|
|
757
|
+
if (!path) return 'Home';
|
|
758
|
+
const last = path.split('/').filter(Boolean).pop() || path;
|
|
759
|
+
return last.replace(/[-_]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function displayCompany(domain) {
|
|
763
|
+
const root = String(domain || '').split('.')[0] || '';
|
|
764
|
+
return root
|
|
765
|
+
.split(/[-_]/)
|
|
766
|
+
.filter(Boolean)
|
|
767
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
768
|
+
.join(' ');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function titleCase(value) {
|
|
772
|
+
return String(value || '').slice(0, 1).toUpperCase() + String(value || '').slice(1);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function domainFromEmail(email) {
|
|
776
|
+
return String(email || '').split('@')[1]?.toLowerCase() || '';
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function isPersonalWebmailDomain(domain) {
|
|
780
|
+
return PERSONAL_EMAIL_DOMAINS.has(String(domain || '').toLowerCase());
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isBrandLikeNamePart(value) {
|
|
784
|
+
const part = String(value || '').toLowerCase();
|
|
785
|
+
return part.endsWith('advisor')
|
|
786
|
+
|| part.endsWith('advisors')
|
|
787
|
+
|| part.endsWith('agency')
|
|
788
|
+
|| part.endsWith('consulting')
|
|
789
|
+
|| part.endsWith('group')
|
|
790
|
+
|| part.endsWith('solutions')
|
|
791
|
+
|| part.endsWith('studio');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function leadKey(lead) {
|
|
795
|
+
if (lead.email) return ['email', lead.domain || '', lead.email || ''].join('|').toLowerCase();
|
|
796
|
+
if (lead.emailType === 'contact_path') return ['contact_path', lead.domain || ''].join('|').toLowerCase();
|
|
797
|
+
return [
|
|
798
|
+
lead.email || '',
|
|
799
|
+
lead.sourceUrl || '',
|
|
800
|
+
lead.domain || '',
|
|
801
|
+
lead.contactName || '',
|
|
802
|
+
lead.title || '',
|
|
803
|
+
].join('|').toLowerCase();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function sortLead(a, b) {
|
|
807
|
+
if (a.confidence !== b.confidence) return b.confidence - a.confidence;
|
|
808
|
+
return leadKey(a).localeCompare(leadKey(b));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function isBetterLead(candidate, current) {
|
|
812
|
+
return leadQualityScore(candidate) > leadQualityScore(current);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function leadQualityScore(lead) {
|
|
816
|
+
return (lead.confidence || 0) * 1000
|
|
817
|
+
+ pagePriority(lead.sourceUrl || '') * 10
|
|
818
|
+
+ (lead.phone ? 25 : 0)
|
|
819
|
+
+ (Array.isArray(lead.socialUrls) ? lead.socialUrls.length : 0) * 5
|
|
820
|
+
+ (Array.isArray(lead.contactUrls) ? lead.contactUrls.length : 0) * 5
|
|
821
|
+
+ (lead.contactName ? 20 : 0)
|
|
822
|
+
+ (lead.title ? 10 : 0);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function stableID(value) {
|
|
826
|
+
return createHash('sha1').update(String(value || '')).digest('hex');
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function hash(value) {
|
|
830
|
+
return createHash('sha256').update(String(value || '')).digest('hex');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function timestampForID() {
|
|
834
|
+
return new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function originOf(value) {
|
|
838
|
+
try {
|
|
839
|
+
return new URL(value).origin;
|
|
840
|
+
} catch {
|
|
841
|
+
return '';
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function skipByExtension(path) {
|
|
846
|
+
return /\.(?:7z|avi|css|csv|doc|docx|gif|gz|ico|jpeg|jpg|js|json|mov|mp3|mp4|pdf|png|ppt|pptx|rar|rss|svg|tar|webm|webp|xls|xlsx|xml|zip)$/i.test(path);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function isLikelyAssetEmail(email) {
|
|
850
|
+
return /\.(?:png|jpg|jpeg|gif|webp|svg|css|js)$/i.test(email.split('@')[1] || '');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function normalizePhone(value) {
|
|
854
|
+
const compact = String(value || '').replace(/[^\d+]/g, '');
|
|
855
|
+
const digits = compact.replace(/\D/g, '');
|
|
856
|
+
if (digits.length === 10) return `+1${digits}`;
|
|
857
|
+
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
|
|
858
|
+
if (compact.startsWith('+') && digits.length >= 10 && digits.length <= 15) return `+${digits}`;
|
|
859
|
+
return '';
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function isPublicProfileUrl(value) {
|
|
863
|
+
try {
|
|
864
|
+
const url = new URL(value);
|
|
865
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
866
|
+
const path = url.pathname.toLowerCase();
|
|
867
|
+
if (host === 'linkedin.com') return /^\/(in|company)\//.test(path);
|
|
868
|
+
if (host === 'x.com' || host === 'twitter.com') return path.length > 1 && !/^\/(share|intent|search|hashtag)\b/.test(path);
|
|
869
|
+
if (host === 'facebook.com' || host === 'instagram.com' || host === 'youtube.com') return path.length > 1;
|
|
870
|
+
return false;
|
|
871
|
+
} catch {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function isSchedulingOrContactUrl(value) {
|
|
877
|
+
try {
|
|
878
|
+
const url = new URL(value);
|
|
879
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
880
|
+
return host === 'calendly.com'
|
|
881
|
+
|| host === 'cal.com'
|
|
882
|
+
|| host.endsWith('.cal.com')
|
|
883
|
+
|| host === 'tidycal.com'
|
|
884
|
+
|| host.includes('acuityscheduling.com')
|
|
885
|
+
|| host.includes('hubspot.com')
|
|
886
|
+
|| host === 'typeform.com'
|
|
887
|
+
|| host.endsWith('.typeform.com')
|
|
888
|
+
|| host === 'jotform.com'
|
|
889
|
+
|| host.endsWith('.jotform.com');
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function decodeURIComponentSafe(value) {
|
|
896
|
+
try {
|
|
897
|
+
return decodeURIComponent(value);
|
|
898
|
+
} catch {
|
|
899
|
+
return value;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function boundedInteger(value, fallback, min, max) {
|
|
904
|
+
const number = Number(value);
|
|
905
|
+
if (!Number.isFinite(number)) return fallback;
|
|
906
|
+
return Math.max(min, Math.min(max, Math.round(number)));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function clamp(value, min, max) {
|
|
910
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
911
|
+
}
|