open-local-audit 0.67.0 → 0.69.0
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/CHANGELOG.md +17 -0
- package/README.md +15 -1
- package/dist/cli.js +86 -5
- package/dist/cli.js.map +1 -1
- package/dist/discovery-cache.d.ts +24 -0
- package/dist/discovery-cache.js +312 -0
- package/dist/discovery-cache.js.map +1 -0
- package/dist/discovery-location.d.ts +13 -0
- package/dist/discovery-location.js +99 -0
- package/dist/discovery-location.js.map +1 -0
- package/dist/discovery-runner.d.ts +30 -0
- package/dist/discovery-runner.js +82 -7
- package/dist/discovery-runner.js.map +1 -1
- package/dist/discovery.d.ts +15 -3
- package/dist/discovery.js +56 -12
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/overture.d.ts +17 -0
- package/dist/overture.js +340 -0
- package/dist/overture.js.map +1 -0
- package/dist/schema.d.ts +28 -4
- package/dist/schema.js +9 -1
- package/dist/schema.js.map +1 -1
- package/dist/start.d.ts +11 -0
- package/dist/start.js +234 -0
- package/dist/start.js.map +1 -0
- package/dist/website-enrichment.d.ts +21 -0
- package/dist/website-enrichment.js +400 -0
- package/dist/website-enrichment.js.map +1 -0
- package/dist/workflow-config.d.ts +48 -3
- package/dist/workflow-config.js +17 -1
- package/dist/workflow-config.js.map +1 -1
- package/dist/workflow-plan.d.ts +1 -1
- package/dist/workflow-plan.js +4 -1
- package/dist/workflow-plan.js.map +1 -1
- package/dist/workflow-preflight.d.ts +1 -1
- package/dist/workflow-preflight.js +2 -2
- package/dist/workflow-preflight.js.map +1 -1
- package/dist/workflow.js +2 -1
- package/dist/workflow.js.map +1 -1
- package/docs/architecture/workflow-command.md +1 -1
- package/docs/guides/free-discovery.md +123 -0
- package/package.json +10 -4
package/dist/start.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
const categories = [
|
|
5
|
+
{ query: "dental", profile: "dental" },
|
|
6
|
+
{ query: "restaurant", profile: "restaurant" },
|
|
7
|
+
{ query: "beauty", profile: "beauty" },
|
|
8
|
+
{ query: "hotel", profile: "hotel" },
|
|
9
|
+
{ query: "gym", profile: "gym" }
|
|
10
|
+
];
|
|
11
|
+
const overtureCategoryPattern = /^[a-z][a-z0-9_]{0,80}$/i;
|
|
12
|
+
function defaultPromptIo() {
|
|
13
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
14
|
+
const reader = isTTY ? createInterface({ input: process.stdin, output: process.stdout, terminal: true }) : undefined;
|
|
15
|
+
let closed = false;
|
|
16
|
+
const close = () => {
|
|
17
|
+
if (!closed) {
|
|
18
|
+
closed = true;
|
|
19
|
+
reader?.close();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
reader?.on("SIGINT", close);
|
|
23
|
+
return {
|
|
24
|
+
isTTY,
|
|
25
|
+
question: async (prompt) => {
|
|
26
|
+
if (!reader || closed)
|
|
27
|
+
return null;
|
|
28
|
+
return new Promise((answer) => {
|
|
29
|
+
const finish = (value) => {
|
|
30
|
+
reader.off("close", onClose);
|
|
31
|
+
answer(value);
|
|
32
|
+
};
|
|
33
|
+
const onClose = () => finish(null);
|
|
34
|
+
reader.once("close", onClose);
|
|
35
|
+
reader.question(prompt).then((value) => finish(value), () => finish(null));
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
write: (message) => process.stdout.write(message),
|
|
39
|
+
close,
|
|
40
|
+
cwd: () => process.cwd(),
|
|
41
|
+
exists: async (path) => {
|
|
42
|
+
try {
|
|
43
|
+
await lstat(path);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
48
|
+
return false;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
now: () => new Date()
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function answer(io, prompt) {
|
|
56
|
+
try {
|
|
57
|
+
return await io.question(prompt);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function writeLine(io, message) {
|
|
64
|
+
io.write(`${message}\n`);
|
|
65
|
+
}
|
|
66
|
+
function slug(value) {
|
|
67
|
+
const normalized = value
|
|
68
|
+
.normalize("NFKD")
|
|
69
|
+
.replace(/\p{M}/gu, "")
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
72
|
+
.replace(/^-+|-+$/g, "");
|
|
73
|
+
return normalized || "search";
|
|
74
|
+
}
|
|
75
|
+
function timestamp(value) {
|
|
76
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
77
|
+
return `${value.getUTCFullYear()}${pad(value.getUTCMonth() + 1)}${pad(value.getUTCDate())}-${pad(value.getUTCHours())}${pad(value.getUTCMinutes())}${pad(value.getUTCSeconds())}`;
|
|
78
|
+
}
|
|
79
|
+
async function promptCountry(io) {
|
|
80
|
+
for (;;) {
|
|
81
|
+
const value = await answer(io, "Country ISO code (two letters): ");
|
|
82
|
+
if (value === null)
|
|
83
|
+
return null;
|
|
84
|
+
const country = value.trim().toUpperCase();
|
|
85
|
+
if (/^[A-Z]{2}$/.test(country))
|
|
86
|
+
return country;
|
|
87
|
+
writeLine(io, "Country must use a two-letter ISO code, for example TR.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function promptCity(io) {
|
|
91
|
+
for (;;) {
|
|
92
|
+
const value = await answer(io, "City: ");
|
|
93
|
+
if (value === null)
|
|
94
|
+
return null;
|
|
95
|
+
const city = value.trim();
|
|
96
|
+
if (city)
|
|
97
|
+
return city;
|
|
98
|
+
writeLine(io, "City is required.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function promptCategory(io) {
|
|
102
|
+
writeLine(io, "Category: 1) dental 2) restaurant 3) beauty 4) hotel 5) gym 6) custom Overture category");
|
|
103
|
+
for (;;) {
|
|
104
|
+
const selected = await answer(io, "Choose a category [1-6]: ");
|
|
105
|
+
if (selected === null)
|
|
106
|
+
return null;
|
|
107
|
+
if (!/^[1-6]$/.test(selected.trim())) {
|
|
108
|
+
writeLine(io, "Choose a category number from 1 to 6.");
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const index = Number(selected.trim()) - 1;
|
|
112
|
+
if (index < categories.length)
|
|
113
|
+
return categories[index];
|
|
114
|
+
for (;;) {
|
|
115
|
+
const custom = await answer(io, "Custom Overture category: ");
|
|
116
|
+
if (custom === null)
|
|
117
|
+
return null;
|
|
118
|
+
const query = custom.trim();
|
|
119
|
+
if (overtureCategoryPattern.test(query))
|
|
120
|
+
return { query, profile: "generic" };
|
|
121
|
+
writeLine(io, "Use an Overture category identifier beginning with a letter and containing only letters, digits, or underscores.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function promptWholeNumber(io, prompt, defaultValue, minimum, maximum, invalidMessage) {
|
|
126
|
+
for (;;) {
|
|
127
|
+
const input = await answer(io, `${prompt} [${defaultValue}]: `);
|
|
128
|
+
if (input === null)
|
|
129
|
+
return null;
|
|
130
|
+
const value = input.trim();
|
|
131
|
+
if (!value)
|
|
132
|
+
return defaultValue;
|
|
133
|
+
if (/^(?:0|[1-9]\d*)$/.test(value)) {
|
|
134
|
+
const parsed = Number(value);
|
|
135
|
+
if (Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum)
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
writeLine(io, invalidMessage);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function uniqueDefaultDirectory(io, country, city, category) {
|
|
142
|
+
const base = resolve(io.cwd(), "reports", `${country.toLowerCase()}-${slug(city)}-${slug(category)}-${timestamp(io.now())}`);
|
|
143
|
+
let candidate = base;
|
|
144
|
+
let suffix = 2;
|
|
145
|
+
while (await io.exists(candidate)) {
|
|
146
|
+
candidate = `${base}-${suffix}`;
|
|
147
|
+
suffix += 1;
|
|
148
|
+
}
|
|
149
|
+
return candidate;
|
|
150
|
+
}
|
|
151
|
+
async function promptOutputDirectory(io, defaultDirectory) {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const input = await answer(io, `Output directory [${defaultDirectory}]: `);
|
|
154
|
+
if (input === null)
|
|
155
|
+
return null;
|
|
156
|
+
const directory = input.trim() ? resolve(io.cwd(), input.trim()) : defaultDirectory;
|
|
157
|
+
if (!await io.exists(directory))
|
|
158
|
+
return directory;
|
|
159
|
+
writeLine(io, "Output path already exists. Choose a new directory so existing results are not overwritten.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function showSummary(io, options) {
|
|
163
|
+
writeLine(io, "");
|
|
164
|
+
writeLine(io, "Discovery summary");
|
|
165
|
+
writeLine(io, ` Location: ${options.city}, ${options.country}`);
|
|
166
|
+
writeLine(io, ` Category: ${options.query}`);
|
|
167
|
+
writeLine(io, ` Candidate limit: ${options.limit}`);
|
|
168
|
+
writeLine(io, ` Website audit cap: ${options.maxAudits}`);
|
|
169
|
+
writeLine(io, ` Output: ${options.outDir}`);
|
|
170
|
+
writeLine(io, " Free API-keyless Overture discovery; results are stored in local files.");
|
|
171
|
+
}
|
|
172
|
+
export async function collectStartOptions(io = defaultPromptIo()) {
|
|
173
|
+
try {
|
|
174
|
+
if (!io.isTTY) {
|
|
175
|
+
throw new Error("Interactive start requires a terminal. Use discover dental --city Istanbul --country TR --dry-run --export-csv leads.csv instead.");
|
|
176
|
+
}
|
|
177
|
+
const country = await promptCountry(io);
|
|
178
|
+
if (country === null)
|
|
179
|
+
return null;
|
|
180
|
+
const city = await promptCity(io);
|
|
181
|
+
if (city === null)
|
|
182
|
+
return null;
|
|
183
|
+
const category = await promptCategory(io);
|
|
184
|
+
if (category === null)
|
|
185
|
+
return null;
|
|
186
|
+
const limit = await promptWholeNumber(io, "Candidate count", 10, 1, 100, "Candidate count must be a whole number from 1 to 100.");
|
|
187
|
+
if (limit === null)
|
|
188
|
+
return null;
|
|
189
|
+
const maxAudits = await promptWholeNumber(io, "Website audit cap", Math.min(3, limit), 0, limit, `Website audit cap must be a whole number from 0 to ${limit}.`);
|
|
190
|
+
if (maxAudits === null)
|
|
191
|
+
return null;
|
|
192
|
+
const defaultDirectory = await uniqueDefaultDirectory(io, country, city, category.query);
|
|
193
|
+
let outDir = await promptOutputDirectory(io, defaultDirectory);
|
|
194
|
+
if (outDir === null)
|
|
195
|
+
return null;
|
|
196
|
+
for (;;) {
|
|
197
|
+
showSummary(io, { city, country, query: category.query, limit, maxAudits, outDir });
|
|
198
|
+
const confirmation = await answer(io, "Start? [Y/n]: ");
|
|
199
|
+
if (confirmation === null)
|
|
200
|
+
return null;
|
|
201
|
+
const normalized = confirmation.trim().toLowerCase();
|
|
202
|
+
if (!normalized || normalized === "y" || normalized === "yes") {
|
|
203
|
+
if (!await io.exists(outDir)) {
|
|
204
|
+
return {
|
|
205
|
+
provider: "overture",
|
|
206
|
+
query: category.query,
|
|
207
|
+
city,
|
|
208
|
+
country,
|
|
209
|
+
profile: category.profile,
|
|
210
|
+
limit,
|
|
211
|
+
maxAudits,
|
|
212
|
+
concurrency: 3,
|
|
213
|
+
dryRun: maxAudits === 0,
|
|
214
|
+
outDir,
|
|
215
|
+
exportCsv: join(outDir, "leads.csv"),
|
|
216
|
+
summaryJson: join(outDir, "discovery-summary.json")
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
writeLine(io, "Output path already exists. Choose a new directory so existing results are not overwritten.");
|
|
220
|
+
outDir = await promptOutputDirectory(io, defaultDirectory);
|
|
221
|
+
if (outDir === null)
|
|
222
|
+
return null;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (normalized === "n" || normalized === "no")
|
|
226
|
+
return null;
|
|
227
|
+
writeLine(io, "Please answer Y or n.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
io.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=start.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"start.js","sourceRoot":"","sources":["../src/start.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PageSnapshot, PublicContact } from "./types.js";
|
|
2
|
+
type Resolver = (hostname: string) => Promise<string[]>;
|
|
3
|
+
export interface WebsiteEnrichmentResult {
|
|
4
|
+
status: "success" | "blocked" | "failed";
|
|
5
|
+
snapshot?: PageSnapshot;
|
|
6
|
+
contact?: PublicContact;
|
|
7
|
+
sourceUrls: string[];
|
|
8
|
+
pagesFetched: number;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
error?: string;
|
|
11
|
+
warnings?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface WebsiteEnrichmentOptions {
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
maxPages?: number;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** Test seam for deterministic DNS checks. Production uses Node's resolver. */
|
|
18
|
+
resolve?: Resolver;
|
|
19
|
+
}
|
|
20
|
+
export declare function enrichWebsite(url: string, options?: WebsiteEnrichmentOptions): Promise<WebsiteEnrichmentResult>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { load } from "cheerio";
|
|
7
|
+
import { extractPublicContact } from "./contact.js";
|
|
8
|
+
const USER_AGENT = "open-local-audit/0.1 (+https://github.com/Esquetta/open-local-audit)";
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
10
|
+
const MAX_HTML_BYTES = 1_048_576;
|
|
11
|
+
const MAX_REDIRECTS = 3;
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const robotsParser = require("robots-parser");
|
|
14
|
+
class BlockedError extends Error {
|
|
15
|
+
}
|
|
16
|
+
class TimeoutError extends Error {
|
|
17
|
+
}
|
|
18
|
+
function normalizeHeaders(headers) {
|
|
19
|
+
const output = {};
|
|
20
|
+
headers.forEach((value, key) => {
|
|
21
|
+
output[key] = value;
|
|
22
|
+
});
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
function sameBusinessHost(first, second) {
|
|
26
|
+
return first.replace(/^www\./i, "").toLowerCase() === second.replace(/^www\./i, "").toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
function isPublicAddress(address) {
|
|
29
|
+
const normalized = address.toLowerCase().split("%")[0] ?? "";
|
|
30
|
+
if (isIP(normalized) === 4) {
|
|
31
|
+
const octets = normalized.split(".").map(Number);
|
|
32
|
+
const [first, second] = octets;
|
|
33
|
+
return !(first === 0 ||
|
|
34
|
+
first === 10 ||
|
|
35
|
+
first === 127 ||
|
|
36
|
+
first >= 224 ||
|
|
37
|
+
(first === 192 && second === 0) ||
|
|
38
|
+
(first === 198 && (second === 18 || second === 19)) ||
|
|
39
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
40
|
+
(first === 169 && second === 254) ||
|
|
41
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
42
|
+
(first === 192 && second === 168));
|
|
43
|
+
}
|
|
44
|
+
if (isIP(normalized) !== 6) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// Only native global unicast; exclude mapped/translation/tunnel and documentation ranges.
|
|
48
|
+
return /^[23]/.test(normalized) && !/^2001:(?:0:|db8:)/.test(normalized) && !normalized.startsWith("2002:");
|
|
49
|
+
}
|
|
50
|
+
async function resolvePublicAddresses(hostname, resolve) {
|
|
51
|
+
hostname = hostname.replace(/^\[|\]$/g, "");
|
|
52
|
+
if (hostname.toLowerCase() === "localhost") {
|
|
53
|
+
throw new BlockedError("localhost is not a public website target");
|
|
54
|
+
}
|
|
55
|
+
const addresses = isIP(hostname) ? [hostname] : await resolve(hostname);
|
|
56
|
+
if (addresses.length === 0 || addresses.some((address) => !isPublicAddress(address))) {
|
|
57
|
+
throw new BlockedError("target resolved to a non-public address");
|
|
58
|
+
}
|
|
59
|
+
return addresses;
|
|
60
|
+
}
|
|
61
|
+
async function defaultResolver(hostname) {
|
|
62
|
+
const entries = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
63
|
+
return entries.map((entry) => entry.address);
|
|
64
|
+
}
|
|
65
|
+
function requestWithPinnedLookup(url, addresses, signal) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
68
|
+
const request = transport.request(url, {
|
|
69
|
+
method: "GET",
|
|
70
|
+
family: isIP(addresses[0]) || 4,
|
|
71
|
+
headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.1" },
|
|
72
|
+
lookup: (_hostname, _options, callback) => callback(null, addresses[0], isIP(addresses[0]) || 4)
|
|
73
|
+
}, (incoming) => {
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let total = 0;
|
|
76
|
+
incoming.on("data", (chunk) => {
|
|
77
|
+
total += chunk.length;
|
|
78
|
+
if (total > MAX_HTML_BYTES) {
|
|
79
|
+
request.destroy(new Error("response exceeded the maximum allowed size"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
chunks.push(chunk);
|
|
83
|
+
});
|
|
84
|
+
incoming.on("error", reject);
|
|
85
|
+
incoming.on("end", () => {
|
|
86
|
+
const headers = new Headers();
|
|
87
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
88
|
+
if (value !== undefined) {
|
|
89
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const status = incoming.statusCode ?? 500;
|
|
93
|
+
resolve(new Response([204, 205, 304].includes(status) ? null : Buffer.concat(chunks), { status, headers }));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const abort = () => request.destroy(new TimeoutError("request timed out"));
|
|
97
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
98
|
+
request.on("close", () => signal.removeEventListener("abort", abort));
|
|
99
|
+
request.on("error", reject);
|
|
100
|
+
request.end();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function readLimitedText(response) {
|
|
104
|
+
if (!response.body)
|
|
105
|
+
return "";
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let length = 0;
|
|
109
|
+
try {
|
|
110
|
+
for (;;) {
|
|
111
|
+
const { value, done } = await reader.read();
|
|
112
|
+
if (done)
|
|
113
|
+
break;
|
|
114
|
+
length += value.length;
|
|
115
|
+
if (length > MAX_HTML_BYTES)
|
|
116
|
+
throw new Error("response exceeded the maximum allowed size");
|
|
117
|
+
chunks.push(value);
|
|
118
|
+
}
|
|
119
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await reader.cancel();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function isRedirect(response) {
|
|
126
|
+
return response.status >= 300 && response.status < 400;
|
|
127
|
+
}
|
|
128
|
+
function contactLinks(html, baseUrl, origin, maxCount) {
|
|
129
|
+
const $ = load(html);
|
|
130
|
+
const candidates = [];
|
|
131
|
+
const signal = /contact|contact-us|iletisim|iletişim|kontakt|impressum|about|about-us|über|uber/i;
|
|
132
|
+
for (const anchor of $("a[href]").toArray()) {
|
|
133
|
+
const href = $(anchor).attr("href")?.trim();
|
|
134
|
+
if (!href || /^(mailto:|tel:|javascript:)/i.test(href)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const label = `${$(anchor).text()} ${$(anchor).attr("rel") ?? ""} ${href}`;
|
|
138
|
+
if (!signal.test(label)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const candidate = new URL(href, baseUrl);
|
|
143
|
+
candidate.hash = "";
|
|
144
|
+
if (candidate.origin === origin && /^https?:$/.test(candidate.protocol) && !candidate.username && !candidate.password) {
|
|
145
|
+
candidates.push(candidate.toString());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return Array.from(new Set(candidates)).slice(0, maxCount);
|
|
153
|
+
}
|
|
154
|
+
function normalizeEmail(value) {
|
|
155
|
+
if (typeof value !== "string") {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const email = value.trim().replace(/^mailto:/i, "").split("?")[0]?.toLowerCase();
|
|
159
|
+
return email && /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email) ? email : undefined;
|
|
160
|
+
}
|
|
161
|
+
function normalizePhone(value) {
|
|
162
|
+
if (typeof value !== "string") {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const phone = value.trim().replace(/^tel:/i, "").replace(/[^\d+]/g, "");
|
|
166
|
+
return phone.replace(/\D/g, "").length >= 7 ? phone : undefined;
|
|
167
|
+
}
|
|
168
|
+
function socialProfile(value) {
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const url = new URL(value);
|
|
174
|
+
const host = url.hostname.replace(/^www\./i, "").toLowerCase();
|
|
175
|
+
return ["facebook.com", "instagram.com", "linkedin.com", "x.com", "twitter.com", "tiktok.com", "youtube.com"].some((name) => host === name || host.endsWith(`.${name}`)) ? url.toString() : undefined;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function extractSchemaContact(html) {
|
|
182
|
+
const $ = load(html);
|
|
183
|
+
const emails = [];
|
|
184
|
+
const phones = [];
|
|
185
|
+
const socials = [];
|
|
186
|
+
const visit = (value) => {
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
value.forEach(visit);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!value || typeof value !== "object") {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const record = value;
|
|
195
|
+
const email = normalizeEmail(record.email);
|
|
196
|
+
const phone = normalizePhone(record.telephone);
|
|
197
|
+
if (email)
|
|
198
|
+
emails.push(email);
|
|
199
|
+
if (phone)
|
|
200
|
+
phones.push(phone);
|
|
201
|
+
const sameAs = Array.isArray(record.sameAs) ? record.sameAs : [record.sameAs];
|
|
202
|
+
sameAs.map(socialProfile).filter((url) => Boolean(url)).forEach((url) => socials.push(url));
|
|
203
|
+
visit(record.contactPoint);
|
|
204
|
+
visit(record["@graph"]);
|
|
205
|
+
};
|
|
206
|
+
for (const script of $("script[type='application/ld+json']").toArray()) {
|
|
207
|
+
try {
|
|
208
|
+
visit(JSON.parse($(script).text()));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { publicEmail: emails[0], publicPhone: phones[0], socialProfiles: Array.from(new Set(socials)) };
|
|
215
|
+
}
|
|
216
|
+
function mergeContacts(entries) {
|
|
217
|
+
const populated = entries.filter(({ contact }) => contact.publicEmail || contact.publicPhone || contact.whatsappUrl || contact.contactPageUrl || contact.socialProfiles.length > 0);
|
|
218
|
+
if (populated.length === 0) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const first = (key) => populated.map(({ contact }) => contact[key]).find(Boolean);
|
|
222
|
+
const socialProfiles = Array.from(new Set(populated.flatMap(({ contact }) => contact.socialProfiles)));
|
|
223
|
+
const channelCount = [first("publicEmail"), first("publicPhone"), first("whatsappUrl"), first("contactPageUrl"), ...socialProfiles].filter(Boolean).length;
|
|
224
|
+
const publicEmail = first("publicEmail");
|
|
225
|
+
const publicPhone = first("publicPhone");
|
|
226
|
+
return {
|
|
227
|
+
publicEmail,
|
|
228
|
+
publicPhone,
|
|
229
|
+
whatsappUrl: first("whatsappUrl"),
|
|
230
|
+
contactPageUrl: first("contactPageUrl"),
|
|
231
|
+
socialProfiles,
|
|
232
|
+
contactConfidence: publicEmail && publicPhone || channelCount >= 3 ? "High" : "Medium",
|
|
233
|
+
contactSource: populated.map(({ pageUrl }) => pageUrl).join(", ")
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function combinePageContact(html, pageUrl) {
|
|
237
|
+
const contact = extractPublicContact(html, pageUrl);
|
|
238
|
+
const schema = extractSchemaContact(html);
|
|
239
|
+
return {
|
|
240
|
+
...contact,
|
|
241
|
+
publicEmail: contact.publicEmail ?? schema.publicEmail,
|
|
242
|
+
publicPhone: contact.publicPhone ?? schema.publicPhone,
|
|
243
|
+
socialProfiles: Array.from(new Set([...contact.socialProfiles, ...schema.socialProfiles]))
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
export async function enrichWebsite(url, options = {}) {
|
|
247
|
+
const startedAt = Date.now();
|
|
248
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
249
|
+
const maxPages = Math.max(1, Math.min(options.maxPages ?? 3, 3));
|
|
250
|
+
const resolve = options.resolve ?? defaultResolver;
|
|
251
|
+
const robotsRules = new Map();
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const sourceUrls = [];
|
|
254
|
+
let pagesFetched = 0;
|
|
255
|
+
const finish = (status, values = {}) => ({
|
|
256
|
+
status,
|
|
257
|
+
...values,
|
|
258
|
+
sourceUrls,
|
|
259
|
+
pagesFetched,
|
|
260
|
+
...(warnings.length ? { warnings } : {}),
|
|
261
|
+
durationMs: Date.now() - startedAt
|
|
262
|
+
});
|
|
263
|
+
const remainingMs = () => {
|
|
264
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
265
|
+
if (remaining <= 0)
|
|
266
|
+
throw new TimeoutError("operation timed out");
|
|
267
|
+
return remaining;
|
|
268
|
+
};
|
|
269
|
+
const request = async (target) => {
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const requestTimeout = remainingMs();
|
|
272
|
+
const timer = setTimeout(() => controller.abort(), requestTimeout);
|
|
273
|
+
let raceTimer;
|
|
274
|
+
try {
|
|
275
|
+
const operation = (async () => {
|
|
276
|
+
const addresses = await resolvePublicAddresses(target.hostname, resolve);
|
|
277
|
+
if (controller.signal.aborted)
|
|
278
|
+
throw new TimeoutError("request timed out");
|
|
279
|
+
const response = options.fetch
|
|
280
|
+
? await options.fetch(target.toString(), { redirect: "manual", signal: controller.signal, headers: { "user-agent": USER_AGENT } })
|
|
281
|
+
: await requestWithPinnedLookup(target, addresses, controller.signal);
|
|
282
|
+
const body = await readLimitedText(response);
|
|
283
|
+
return new Response([204, 205, 304].includes(response.status) ? null : body, { status: response.status, headers: response.headers });
|
|
284
|
+
})();
|
|
285
|
+
return await Promise.race([
|
|
286
|
+
operation,
|
|
287
|
+
new Promise((_resolve, reject) => {
|
|
288
|
+
raceTimer = setTimeout(() => reject(new TimeoutError("request timed out")), requestTimeout);
|
|
289
|
+
})
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
if (raceTimer)
|
|
295
|
+
clearTimeout(raceTimer);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const allowedByRobots = async (target) => {
|
|
299
|
+
const cacheKey = target.origin;
|
|
300
|
+
const cached = robotsRules.get(cacheKey);
|
|
301
|
+
if (cached) {
|
|
302
|
+
if (cached.isAllowed(target.toString(), USER_AGENT) === false)
|
|
303
|
+
throw new BlockedError("robots.txt disallows this URL");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
let robotsUrl = new URL("/robots.txt", target.origin);
|
|
307
|
+
let response;
|
|
308
|
+
try {
|
|
309
|
+
response = await request(robotsUrl);
|
|
310
|
+
for (let count = 0; isRedirect(response) && count < MAX_REDIRECTS; count++) {
|
|
311
|
+
const location = response.headers.get("location");
|
|
312
|
+
if (!location)
|
|
313
|
+
break;
|
|
314
|
+
const next = new URL(location, robotsUrl);
|
|
315
|
+
if (!/^https?:$/.test(next.protocol) || next.username || next.password || !sameBusinessHost(target.hostname, next.hostname))
|
|
316
|
+
throw new BlockedError("robots redirect left the business website");
|
|
317
|
+
robotsUrl = next;
|
|
318
|
+
response = await request(robotsUrl);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error instanceof BlockedError)
|
|
323
|
+
throw error;
|
|
324
|
+
if (error instanceof TimeoutError)
|
|
325
|
+
throw error;
|
|
326
|
+
throw new BlockedError("robots.txt could not be reached");
|
|
327
|
+
}
|
|
328
|
+
if (response.status === 404) {
|
|
329
|
+
robotsRules.set(cacheKey, robotsParser(new URL("/robots.txt", target.origin).toString(), ""));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (response.status === 403 || response.status < 200 || response.status >= 300) {
|
|
333
|
+
throw new BlockedError("robots.txt did not permit crawling");
|
|
334
|
+
}
|
|
335
|
+
const policy = robotsParser(new URL("/robots.txt", target.origin).toString(), await readLimitedText(response));
|
|
336
|
+
robotsRules.set(cacheKey, policy);
|
|
337
|
+
if (policy.isAllowed(target.toString(), USER_AGENT) === false) {
|
|
338
|
+
throw new BlockedError("robots.txt disallows this URL");
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const fetchPage = async (requestedUrl, homepageHost) => {
|
|
342
|
+
let current = new URL(requestedUrl);
|
|
343
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
344
|
+
if (!/^https?:$/.test(current.protocol) || current.username || current.password || !sameBusinessHost(homepageHost, current.hostname)) {
|
|
345
|
+
throw new BlockedError("redirect left the business website");
|
|
346
|
+
}
|
|
347
|
+
await allowedByRobots(current);
|
|
348
|
+
const response = await request(current);
|
|
349
|
+
if (isRedirect(response)) {
|
|
350
|
+
const location = response.headers.get("location");
|
|
351
|
+
if (!location)
|
|
352
|
+
throw new Error("redirect response had no location");
|
|
353
|
+
current = new URL(location, current);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (response.status === 403)
|
|
357
|
+
throw new BlockedError("website denied the request");
|
|
358
|
+
if (response.status === 429)
|
|
359
|
+
throw new Error("website rate limited the request");
|
|
360
|
+
if (!response.ok)
|
|
361
|
+
throw new Error(`website returned HTTP ${response.status}`);
|
|
362
|
+
const contentType = response.headers.get("content-type")?.toLowerCase();
|
|
363
|
+
if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) {
|
|
364
|
+
throw new Error("website response was not HTML");
|
|
365
|
+
}
|
|
366
|
+
const html = await readLimitedText(response);
|
|
367
|
+
pagesFetched += 1;
|
|
368
|
+
if (!sourceUrls.includes(current.toString()))
|
|
369
|
+
sourceUrls.push(current.toString());
|
|
370
|
+
return { url: requestedUrl, finalUrl: current.toString(), statusCode: response.status, headers: normalizeHeaders(response.headers), html };
|
|
371
|
+
}
|
|
372
|
+
throw new Error(`exceeded redirect limit of ${MAX_REDIRECTS}`);
|
|
373
|
+
};
|
|
374
|
+
try {
|
|
375
|
+
const initial = new URL(url);
|
|
376
|
+
if (!/^https?:$/.test(initial.protocol) || initial.username || initial.password) {
|
|
377
|
+
throw new BlockedError("only public HTTP(S) URLs are allowed");
|
|
378
|
+
}
|
|
379
|
+
const homepage = await fetchPage(initial.toString(), initial.hostname);
|
|
380
|
+
const contactEntries = [{ contact: combinePageContact(homepage.html, homepage.finalUrl), pageUrl: homepage.finalUrl }];
|
|
381
|
+
const followUps = contactLinks(homepage.html, homepage.finalUrl, new URL(homepage.finalUrl).origin, maxPages - 1);
|
|
382
|
+
for (const followUp of followUps) {
|
|
383
|
+
try {
|
|
384
|
+
const page = await fetchPage(followUp, initial.hostname);
|
|
385
|
+
contactEntries.push({ contact: combinePageContact(page.html, page.finalUrl), pageUrl: page.finalUrl });
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
warnings.push(`${followUp}: ${error instanceof Error ? error.message : "could not read page"}`);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return finish("success", { snapshot: homepage, contact: mergeContacts(contactEntries) });
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (error instanceof BlockedError)
|
|
396
|
+
return finish("blocked", { error: error.message });
|
|
397
|
+
return finish("failed", { error: error instanceof Error ? error.message : "website enrichment failed" });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
//# sourceMappingURL=website-enrichment.js.map
|