@fulldotdev/scan 0.1.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/README.md +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { srcsetUrls } from "./srcset.js";
|
|
2
|
+
import { crawlUrl, linkCandidates } from "./crawl-scope.js";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { sameSite, slashTwin } from "./network.js";
|
|
5
|
+
import { extractPage, isHtml, resolveLink, markdownEvidence, markdownLinks, textSimilarity, } from "./html.js";
|
|
6
|
+
import { crawlerPolicy, fieldData, xmlParser } from "./site.js";
|
|
7
|
+
import { XMLValidator } from "fast-xml-parser";
|
|
8
|
+
import { httpEvidence, } from "./types.js";
|
|
9
|
+
// Deterministic 5% sample so suffix probing does not triple requests site-wide.
|
|
10
|
+
export function sampledForMarkdown(url) {
|
|
11
|
+
return createHash("sha256").update(url).digest()[0] < 13;
|
|
12
|
+
}
|
|
13
|
+
// Compact evidence of a fetched HTML destination that is not crawled as a
|
|
14
|
+
// page in its own right (a trailing-slash twin, an external page).
|
|
15
|
+
function htmlSummary(response) {
|
|
16
|
+
const html = extractPage(response);
|
|
17
|
+
return {
|
|
18
|
+
title: html.title,
|
|
19
|
+
ids: html.ids,
|
|
20
|
+
canonicals: html.canonicals,
|
|
21
|
+
robots: html.robots,
|
|
22
|
+
xRobotsTag: html.xRobotsTag,
|
|
23
|
+
textHash: html.textHash,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function resourceEvidence(response) {
|
|
27
|
+
const data = {
|
|
28
|
+
http: httpEvidence(response),
|
|
29
|
+
sha256: response.raw
|
|
30
|
+
? createHash("sha256").update(response.raw).digest("hex")
|
|
31
|
+
: null,
|
|
32
|
+
hashScope: response.truncated ? "prefix-only" : "entire-decoded-response",
|
|
33
|
+
declaredLength: response.headers["content-length"] ?? null,
|
|
34
|
+
cacheControl: response.headers["cache-control"] ?? null,
|
|
35
|
+
etag: response.headers.etag ?? null,
|
|
36
|
+
lastModified: response.headers["last-modified"] ?? null,
|
|
37
|
+
sniffedType: response.raw?.subarray(0, 5).toString() === "%PDF-"
|
|
38
|
+
? "pdf"
|
|
39
|
+
: response.raw?.subarray(0, 8).toString("hex") === "89504e470d0a1a0a"
|
|
40
|
+
? "png"
|
|
41
|
+
: response.raw?.subarray(0, 3).toString("hex") === "ffd8ff"
|
|
42
|
+
? "jpeg"
|
|
43
|
+
: response.raw?.subarray(0, 4).toString("hex") === "52494646"
|
|
44
|
+
? "webp"
|
|
45
|
+
: response.raw?.subarray(0, 4).toString("hex") === "47494638"
|
|
46
|
+
? "gif"
|
|
47
|
+
: null,
|
|
48
|
+
};
|
|
49
|
+
if (response.outcome === "ok" && isHtml(response))
|
|
50
|
+
data.html = htmlSummary(response);
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
export async function collectPage(scan, url, http, probeMarkdown = true) {
|
|
54
|
+
const response = await http.get(url);
|
|
55
|
+
if (response.outcome !== "ok" || !isHtml(response))
|
|
56
|
+
return {
|
|
57
|
+
observations: [
|
|
58
|
+
{
|
|
59
|
+
kind: "page",
|
|
60
|
+
key: url,
|
|
61
|
+
data: { http: httpEvidence(response), html: false },
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const page = extractPage(response);
|
|
66
|
+
const observations = [
|
|
67
|
+
{ kind: "page", key: url, data: page },
|
|
68
|
+
];
|
|
69
|
+
const candidates = [];
|
|
70
|
+
const twin = slashTwin(url);
|
|
71
|
+
if (twin) {
|
|
72
|
+
const alternate = await http.get(twin);
|
|
73
|
+
const alternatePage = alternate.outcome === "ok" && isHtml(alternate)
|
|
74
|
+
? extractPage(alternate)
|
|
75
|
+
: null;
|
|
76
|
+
const canonical = (value) => value.canonicals.flatMap((entry) => entry.href ? [crawlUrl(entry.href, scan)] : []);
|
|
77
|
+
let result;
|
|
78
|
+
if ([404, 410].includes(alternate.status ?? 0))
|
|
79
|
+
result = "absent";
|
|
80
|
+
else if (alternate.outcome !== "ok")
|
|
81
|
+
result = "unknown";
|
|
82
|
+
else if (alternate.finalUrl === response.finalUrl &&
|
|
83
|
+
(alternate.redirects.length || response.redirects.length))
|
|
84
|
+
result = "redirected";
|
|
85
|
+
else if (response.truncated || alternate.truncated || !alternatePage)
|
|
86
|
+
result = "unknown";
|
|
87
|
+
else if (alternate.redirects.length || response.redirects.length)
|
|
88
|
+
result = "different-redirect-target";
|
|
89
|
+
else if (!page.mainText || !alternatePage.mainText)
|
|
90
|
+
result = "unknown";
|
|
91
|
+
else if (page.textHash !== alternatePage.textHash)
|
|
92
|
+
result = "different-content";
|
|
93
|
+
else {
|
|
94
|
+
const originals = canonical(page), alternates = canonical(alternatePage);
|
|
95
|
+
result =
|
|
96
|
+
originals.length === 1 &&
|
|
97
|
+
alternates.length === 1 &&
|
|
98
|
+
originals[0] === alternates[0] &&
|
|
99
|
+
[url, twin].includes(originals[0])
|
|
100
|
+
? "canonicalized"
|
|
101
|
+
: "duplicate";
|
|
102
|
+
}
|
|
103
|
+
observations.push({
|
|
104
|
+
kind: "slash-variant",
|
|
105
|
+
key: url.endsWith("/") ? url.slice(0, -1) : url,
|
|
106
|
+
data: {
|
|
107
|
+
url,
|
|
108
|
+
alternateUrl: twin,
|
|
109
|
+
result,
|
|
110
|
+
original: {
|
|
111
|
+
http: httpEvidence(response),
|
|
112
|
+
canonicals: canonical(page),
|
|
113
|
+
textHash: page.textHash,
|
|
114
|
+
},
|
|
115
|
+
alternate: {
|
|
116
|
+
http: httpEvidence(alternate),
|
|
117
|
+
canonicals: alternatePage ? canonical(alternatePage) : [],
|
|
118
|
+
textHash: alternatePage?.textHash ?? null,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
// The twin was fetched here; its resource record spares a second
|
|
123
|
+
// request when another page links to it.
|
|
124
|
+
{ kind: "resource", key: twin, data: resourceEvidence(alternate) });
|
|
125
|
+
}
|
|
126
|
+
for (const link of page.links) {
|
|
127
|
+
// Cloudflare rewrites obfuscated mailto links to /cdn-cgi/l/email-protection,
|
|
128
|
+
// which answers 404 to anything but a browser.
|
|
129
|
+
if (!link.href || new URL(link.href).pathname.startsWith("/cdn-cgi/"))
|
|
130
|
+
continue;
|
|
131
|
+
candidates.push(...linkCandidates(link.href, scan, `link:${url}`));
|
|
132
|
+
}
|
|
133
|
+
for (const link of page.declarations)
|
|
134
|
+
if (link.href) {
|
|
135
|
+
const mapped = /canonical|alternate|next|prev/.test(link.rel);
|
|
136
|
+
const target = mapped ? crawlUrl(link.href, scan) : link.href;
|
|
137
|
+
if (mapped && !/markdown/i.test(link.type ?? ""))
|
|
138
|
+
candidates.push(...linkCandidates(link.href, scan, `${link.rel}:${url}`));
|
|
139
|
+
else
|
|
140
|
+
candidates.push({
|
|
141
|
+
url: target,
|
|
142
|
+
source: `${link.rel}:${url}`,
|
|
143
|
+
kind: "resource",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const image of page.images) {
|
|
147
|
+
if (image.src)
|
|
148
|
+
candidates.push({
|
|
149
|
+
url: image.src,
|
|
150
|
+
source: `image:${url}`,
|
|
151
|
+
kind: "resource",
|
|
152
|
+
});
|
|
153
|
+
for (const entry of srcsetUrls(image.srcset ?? "")) {
|
|
154
|
+
const src = resolveLink(entry, page.base);
|
|
155
|
+
if (src)
|
|
156
|
+
candidates.push({
|
|
157
|
+
url: src,
|
|
158
|
+
source: `srcset:${url}`,
|
|
159
|
+
kind: "resource",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const script of page.scripts)
|
|
164
|
+
if (script.src)
|
|
165
|
+
candidates.push({
|
|
166
|
+
url: script.src,
|
|
167
|
+
source: `script:${url}`,
|
|
168
|
+
kind: "resource",
|
|
169
|
+
});
|
|
170
|
+
for (const meta of page.social)
|
|
171
|
+
if (/^(og:image(?::url)?|twitter:image(?::src)?)$/.test(meta.property ?? meta.name ?? "")) {
|
|
172
|
+
const src = resolveLink(meta.content, page.base);
|
|
173
|
+
if (src)
|
|
174
|
+
candidates.push({
|
|
175
|
+
url: src,
|
|
176
|
+
source: `social-image:${url}`,
|
|
177
|
+
kind: "resource",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (new URL(response.finalUrl).pathname === "/")
|
|
181
|
+
candidates.push({
|
|
182
|
+
url: new URL("/favicon.ico", response.finalUrl).href,
|
|
183
|
+
source: `favicon-fallback:${url}`,
|
|
184
|
+
kind: "resource",
|
|
185
|
+
});
|
|
186
|
+
const robots = await http.getRobots(response.finalUrl);
|
|
187
|
+
observations.push({
|
|
188
|
+
kind: "crawler-policy",
|
|
189
|
+
key: url,
|
|
190
|
+
data: crawlerPolicy(robots, response.finalUrl),
|
|
191
|
+
});
|
|
192
|
+
// Real-user data for this page when a Google API key is configured; the
|
|
193
|
+
// origin record is fetched once by discovery and labelled as fallback.
|
|
194
|
+
const field = await fieldData({ url: response.finalUrl });
|
|
195
|
+
if (field)
|
|
196
|
+
observations.push({ kind: "field-data", key: url, data: field });
|
|
197
|
+
const declared = page.declarations
|
|
198
|
+
.filter((l) => /markdown/i.test(l.type ?? "") || /\.md(?:\?|$)/i.test(l.href ?? ""))
|
|
199
|
+
.map((l) => l.href)
|
|
200
|
+
.filter(Boolean);
|
|
201
|
+
const parsed = new URL(response.finalUrl);
|
|
202
|
+
const appended = new URL(parsed);
|
|
203
|
+
appended.pathname = parsed.pathname.endsWith("/")
|
|
204
|
+
? `${parsed.pathname}index.md`
|
|
205
|
+
: `${parsed.pathname}.md`;
|
|
206
|
+
const replaced = new URL(parsed);
|
|
207
|
+
replaced.pathname = /\.html?$/.test(parsed.pathname)
|
|
208
|
+
? parsed.pathname.replace(/\.html?$/, ".md")
|
|
209
|
+
: parsed.pathname.endsWith("/")
|
|
210
|
+
? `${parsed.pathname}index.html.md`
|
|
211
|
+
: `${parsed.pathname}/index.md`;
|
|
212
|
+
// Declared links are always checked; negotiation and suffix guesses only
|
|
213
|
+
// on the homepage, a sample, or once the site has shown Markdown support.
|
|
214
|
+
const attempts = probeMarkdown
|
|
215
|
+
? [
|
|
216
|
+
...new Set([...declared.slice(0, 2), appended.href, replaced.href]),
|
|
217
|
+
].slice(0, 3)
|
|
218
|
+
: declared.slice(0, 1);
|
|
219
|
+
const alternatives = [];
|
|
220
|
+
if (probeMarkdown)
|
|
221
|
+
alternatives.push({
|
|
222
|
+
url: response.finalUrl,
|
|
223
|
+
method: "accept-header",
|
|
224
|
+
response: await http.get(response.finalUrl, {
|
|
225
|
+
accept: "text/markdown",
|
|
226
|
+
retry: false,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
observations[0].data.markdownProbe = probeMarkdown
|
|
230
|
+
? "full"
|
|
231
|
+
: attempts.length
|
|
232
|
+
? "declared-only"
|
|
233
|
+
: "skipped";
|
|
234
|
+
if (!alternatives.some((a) => markdownEvidence(a.response.body, a.response.headers["content-type"])
|
|
235
|
+
.usable)) {
|
|
236
|
+
for (const candidate of attempts) {
|
|
237
|
+
const alternative = await http.get(candidate, { retry: false });
|
|
238
|
+
alternatives.push({
|
|
239
|
+
url: candidate,
|
|
240
|
+
method: declared.includes(candidate) ? "declared-link" : "suffix-probe",
|
|
241
|
+
response: alternative,
|
|
242
|
+
});
|
|
243
|
+
if (alternative.outcome === "ok" &&
|
|
244
|
+
markdownEvidence(alternative.body, alternative.headers["content-type"])
|
|
245
|
+
.usable)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const alternative of alternatives) {
|
|
250
|
+
const evidence = markdownEvidence(alternative.response.body, alternative.response.headers["content-type"]);
|
|
251
|
+
const usable = alternative.response.outcome === "ok" &&
|
|
252
|
+
evidence.usable &&
|
|
253
|
+
!alternative.response.truncated;
|
|
254
|
+
observations.push({
|
|
255
|
+
kind: "markdown",
|
|
256
|
+
key: `${url}|${alternative.method}|${alternative.url}`,
|
|
257
|
+
data: {
|
|
258
|
+
pageUrl: url,
|
|
259
|
+
method: alternative.method,
|
|
260
|
+
http: httpEvidence(alternative.response),
|
|
261
|
+
...evidence,
|
|
262
|
+
usable,
|
|
263
|
+
comparison: usable
|
|
264
|
+
? {
|
|
265
|
+
method: "token-set Jaccard; heuristic, not semantic equivalence",
|
|
266
|
+
similarity: textSimilarity(page.mainText, alternative.response.body),
|
|
267
|
+
htmlTextCharacters: page.mainText.length,
|
|
268
|
+
markdownCharacters: alternative.response.body.length,
|
|
269
|
+
}
|
|
270
|
+
: null,
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
if (usable)
|
|
274
|
+
for (const link of markdownLinks(alternative.response.body, alternative.response.finalUrl))
|
|
275
|
+
if (link.url) {
|
|
276
|
+
candidates.push(...linkCandidates(link.url, scan, `markdown:${url}`));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { observations, candidates };
|
|
280
|
+
}
|
|
281
|
+
export async function collectResource(url, http, scan) {
|
|
282
|
+
const response = await http.get(url, {
|
|
283
|
+
maxBytes: http.options.maxResourceBytes,
|
|
284
|
+
});
|
|
285
|
+
const contentType = response.headers["content-type"] ?? "";
|
|
286
|
+
const data = resourceEvidence(response);
|
|
287
|
+
const observations = [
|
|
288
|
+
{ kind: "resource", key: url, data },
|
|
289
|
+
];
|
|
290
|
+
const candidates = [];
|
|
291
|
+
// A file link that turns out to be an HTML page of the scanned site is
|
|
292
|
+
// crawled as a page after all.
|
|
293
|
+
if (scan &&
|
|
294
|
+
data.html &&
|
|
295
|
+
sameSite(url, scan.url) &&
|
|
296
|
+
crawlUrl(url, scan) === url)
|
|
297
|
+
candidates.push({
|
|
298
|
+
url: crawlUrl(url, scan),
|
|
299
|
+
source: `html-resource:${url}`,
|
|
300
|
+
kind: "page",
|
|
301
|
+
});
|
|
302
|
+
if (response.outcome === "ok" &&
|
|
303
|
+
!response.truncated &&
|
|
304
|
+
(/(?:xml|rss|atom)/i.test(contentType) ||
|
|
305
|
+
/<(rss|feed)[\s>]/i.test(response.body))) {
|
|
306
|
+
try {
|
|
307
|
+
const valid = !/<!DOCTYPE|<!ENTITY/i.test(response.body) &&
|
|
308
|
+
XMLValidator.validate(response.body) === true;
|
|
309
|
+
const xml = valid ? xmlParser.parse(response.body) : null;
|
|
310
|
+
if (xml?.rss || xml?.feed) {
|
|
311
|
+
const entries = [
|
|
312
|
+
xml.rss?.channel?.item ?? xml.feed?.entry ?? [],
|
|
313
|
+
].flat();
|
|
314
|
+
const links = entries.flatMap((entry) => {
|
|
315
|
+
const values = [entry.link ?? []].flat();
|
|
316
|
+
return values
|
|
317
|
+
.map((l) => resolveLink(typeof l === "string" ? l : l["@_href"], url))
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
});
|
|
320
|
+
observations.push({
|
|
321
|
+
kind: "feed",
|
|
322
|
+
key: url,
|
|
323
|
+
data: {
|
|
324
|
+
valid,
|
|
325
|
+
format: xml.rss ? "rss" : "atom",
|
|
326
|
+
entries: entries.length,
|
|
327
|
+
links,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
for (const link of links)
|
|
331
|
+
if (link) {
|
|
332
|
+
candidates.push(...(scan
|
|
333
|
+
? linkCandidates(link, scan, `feed:${url}`)
|
|
334
|
+
: [
|
|
335
|
+
{
|
|
336
|
+
url: link,
|
|
337
|
+
source: `feed:${url}`,
|
|
338
|
+
kind: "resource",
|
|
339
|
+
},
|
|
340
|
+
]));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else
|
|
344
|
+
observations.push({
|
|
345
|
+
kind: "xml-resource",
|
|
346
|
+
key: url,
|
|
347
|
+
data: { valid, recognizedFeed: false },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
observations.push({
|
|
352
|
+
kind: "xml-resource",
|
|
353
|
+
key: url,
|
|
354
|
+
data: { valid: false, error: String(error) },
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { observations, candidates };
|
|
359
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Candidate, Scan } from "./types.js";
|
|
2
|
+
export declare const assetExtension: RegExp;
|
|
3
|
+
export declare function isPaginationQuery(url: string): boolean;
|
|
4
|
+
export declare function crawlUrl(href: string, scan: Pick<Scan, "url" | "options">): string;
|
|
5
|
+
export declare function linkCandidates(href: string, scan: Pick<Scan, "url" | "options">, source: string): Candidate[];
|
|
6
|
+
export type ScopeSkip = "url-too-long" | "path-too-deep" | "repeating-path" | "external";
|
|
7
|
+
export declare function scopeRule(url: string): ScopeSkip | null;
|
|
8
|
+
export declare function candidateKind(href: string, scan: Pick<Scan, "url" | "options">): "page" | "resource";
|
|
9
|
+
export declare function pageType(url: string, page?: {
|
|
10
|
+
structuredTypes?: string[];
|
|
11
|
+
pagination?: {
|
|
12
|
+
next: string | null;
|
|
13
|
+
numbered: string[];
|
|
14
|
+
} | null;
|
|
15
|
+
productLinks?: number;
|
|
16
|
+
}): "home" | "product" | "collection" | "article" | "search" | "page";
|
|
17
|
+
export declare function templateGroup(url: string): string;
|
|
18
|
+
export declare function socialPlatform(url: string): {
|
|
19
|
+
platform: string;
|
|
20
|
+
blocksBots: boolean;
|
|
21
|
+
} | null;
|
|
22
|
+
export declare function isMapLink(url: string): boolean;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { sameSite } from "./network.js";
|
|
2
|
+
// Which discovered URLs are crawled as pages, checked as files, or recorded
|
|
3
|
+
// but not followed. Count caps are optional; these rules are what keeps the
|
|
4
|
+
// crawl finite on sites with infinite URL spaces.
|
|
5
|
+
export const assetExtension = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|jpe?g|png|gif|svg|webp|avif|ico|woff2?|ttf|otf|mp4|webm|mp3|css|js|mjs|xml|json|txt|md|rss|atom)$/i;
|
|
6
|
+
// Query parameters that only carry campaign or session identity.
|
|
7
|
+
const trackingParam = /^(utm_\w+|gclid|dclid|fbclid|msclkid|yclid|_ga|_gl|mc_cid|mc_eid|phpsessid|jsessionid|sessionid|session_id)$/i;
|
|
8
|
+
// Parameters that page through one listing.
|
|
9
|
+
const paginationParam = /^(page|p|pg|paged|pagina|pagination|seite|side|start|offset|from)$/i;
|
|
10
|
+
export function isPaginationQuery(url) {
|
|
11
|
+
const params = [...new URL(url).searchParams.keys()];
|
|
12
|
+
return params.length > 0 && params.every((k) => paginationParam.test(k));
|
|
13
|
+
}
|
|
14
|
+
// Same URL space without campaign or session parameters, with parameters
|
|
15
|
+
// sorted so the same page is discovered once. Only applied to pages of the
|
|
16
|
+
// scanned site; external destinations are checked exactly as linked.
|
|
17
|
+
export function crawlUrl(href, scan) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(href);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return href;
|
|
24
|
+
}
|
|
25
|
+
if (!sameSite(url.href, scan.url))
|
|
26
|
+
return href;
|
|
27
|
+
const kept = [...url.searchParams]
|
|
28
|
+
.filter(([k]) => !trackingParam.test(k))
|
|
29
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
30
|
+
url.search = kept.length ? `?${new URLSearchParams(kept).toString()}` : "";
|
|
31
|
+
return url.href;
|
|
32
|
+
}
|
|
33
|
+
// Keep page deduplication separate from destination verification: a tracking
|
|
34
|
+
// variant can return a broken response even when its clean page works.
|
|
35
|
+
export function linkCandidates(href, scan, source) {
|
|
36
|
+
const original = href;
|
|
37
|
+
const target = crawlUrl(href, scan);
|
|
38
|
+
const candidates = [
|
|
39
|
+
{ url: target, source, kind: candidateKind(target, scan) },
|
|
40
|
+
];
|
|
41
|
+
if (original !== target)
|
|
42
|
+
candidates.push({ url: original, source, kind: "resource" });
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
45
|
+
// Rules that never need the rest of the crawl: URL shape alone.
|
|
46
|
+
export function scopeRule(url) {
|
|
47
|
+
if (url.length > 2000)
|
|
48
|
+
return "url-too-long";
|
|
49
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
50
|
+
if (segments.length > 12)
|
|
51
|
+
return "path-too-deep";
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const s of segments)
|
|
54
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
55
|
+
if ([...counts.values()].some((n) => n >= 3))
|
|
56
|
+
return "repeating-path";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
export function candidateKind(href, scan) {
|
|
60
|
+
const url = crawlUrl(href, scan);
|
|
61
|
+
return sameSite(url, scan.url) && !assetExtension.test(new URL(url).pathname)
|
|
62
|
+
? "page"
|
|
63
|
+
: "resource";
|
|
64
|
+
}
|
|
65
|
+
// Coarse page type from URL shape and page evidence; used to compare pages
|
|
66
|
+
// with their peers, never to infer business intent.
|
|
67
|
+
export function pageType(url, page) {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
if (parsed.pathname === "/" && !parsed.search)
|
|
70
|
+
return "home";
|
|
71
|
+
const types = page?.structuredTypes ?? [];
|
|
72
|
+
if (types.some((t) => /^Product(Group)?$/.test(t)))
|
|
73
|
+
return "product";
|
|
74
|
+
if (types.some((t) => /^(Article|NewsArticle|BlogPosting)$/.test(t)))
|
|
75
|
+
return "article";
|
|
76
|
+
if (types.some((t) => /^(CollectionPage|ItemList|SearchResultsPage)$/.test(t)))
|
|
77
|
+
return "collection";
|
|
78
|
+
if (/^\/(search|zoeken|suche|recherche)(\/|$)/i.test(parsed.pathname))
|
|
79
|
+
return "search";
|
|
80
|
+
if (/\/(products?|producten|produkte|produits|item|artikel)\/[^/]+\/?$/i.test(parsed.pathname))
|
|
81
|
+
return "product";
|
|
82
|
+
if (/\/(collections?|categor(y|ies|ie|ieen)|shop|kategorie|catalog(ue)?|tags?)(\/|$)/i.test(parsed.pathname) ||
|
|
83
|
+
page?.pagination?.next ||
|
|
84
|
+
(page?.pagination?.numbered.length ?? 0) > 0 ||
|
|
85
|
+
(page?.productLinks ?? 0) >= 6)
|
|
86
|
+
return "collection";
|
|
87
|
+
if (/\/(blog|news|nieuws|artikelen|articles|posts?|journal|magazine)\/[^/]+/i.test(parsed.pathname))
|
|
88
|
+
return "article";
|
|
89
|
+
return "page";
|
|
90
|
+
}
|
|
91
|
+
// URL template group: sibling pages under the same parent path.
|
|
92
|
+
export function templateGroup(url) {
|
|
93
|
+
const path = new URL(url).pathname;
|
|
94
|
+
const parts = path.split("/").filter(Boolean);
|
|
95
|
+
return parts.length < 2 ? path : parts.slice(0, -1).join("/") + "/*";
|
|
96
|
+
}
|
|
97
|
+
// Public profile platforms and map services linked from pages. Platforms
|
|
98
|
+
// that block automated requests make a link unverifiable, not broken.
|
|
99
|
+
const socialPlatforms = [
|
|
100
|
+
[/(^|\.)facebook\.com$/, "Facebook", true],
|
|
101
|
+
[/(^|\.)instagram\.com$/, "Instagram", true],
|
|
102
|
+
[/(^|\.)linkedin\.com$/, "LinkedIn", true],
|
|
103
|
+
[/(^|\.)(x|twitter)\.com$/, "X", true],
|
|
104
|
+
[/(^|\.)youtube\.com$/, "YouTube", false],
|
|
105
|
+
[/(^|\.)tiktok\.com$/, "TikTok", true],
|
|
106
|
+
[/(^|\.)pinterest\.[a-z.]+$/, "Pinterest", true],
|
|
107
|
+
[/(^|\.)threads\.(net|com)$/, "Threads", true],
|
|
108
|
+
[/(^|\.)github\.com$/, "GitHub", false],
|
|
109
|
+
[/(^|\.)vimeo\.com$/, "Vimeo", false],
|
|
110
|
+
[/(^|\.)bsky\.app$/, "Bluesky", false],
|
|
111
|
+
[/(^|\.)wa\.me$/, "WhatsApp", true],
|
|
112
|
+
[/(^|\.)snapchat\.com$/, "Snapchat", true],
|
|
113
|
+
];
|
|
114
|
+
const mapHosts = /(^|\.)(maps\.google\.[a-z.]+|google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl|openstreetmap\.org|maps\.apple\.com|waze\.com)$/;
|
|
115
|
+
// Profile addresses only: one path segment (two for /in/, /company/,
|
|
116
|
+
// /channel/, /user/ and /c/ forms). Deeper paths are posts or repositories.
|
|
117
|
+
export function socialPlatform(url) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
121
|
+
const match = socialPlatforms.find(([pattern]) => pattern.test(host));
|
|
122
|
+
if (!match)
|
|
123
|
+
return null;
|
|
124
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
125
|
+
const profile = segments.length === 1 ||
|
|
126
|
+
(segments.length === 2 &&
|
|
127
|
+
/^(in|company|school|channel|user|c|pages|groups)$/.test(segments[0]));
|
|
128
|
+
return profile ? { platform: match[1], blocksBots: match[2] } : null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function isMapLink(url) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = new URL(url);
|
|
137
|
+
return (mapHosts.test(parsed.hostname) &&
|
|
138
|
+
(/maps/.test(parsed.hostname) ||
|
|
139
|
+
/\/maps/.test(parsed.pathname) ||
|
|
140
|
+
/goo\.gl/.test(parsed.hostname)));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function env(name: string): string | undefined;
|