@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,645 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { normalizeUrl } from "./network.js";
|
|
5
|
+
import { httpEvidence } from "./types.js";
|
|
6
|
+
const clean = (value) => (value ?? "").replace(/\s+/g, " ").trim();
|
|
7
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
export function resolveLink(value, base) {
|
|
9
|
+
try {
|
|
10
|
+
return value ? normalizeUrl(value, base) : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function markdownLinks(body, base) {
|
|
17
|
+
const links = [];
|
|
18
|
+
const tokens = marked.lexer(body);
|
|
19
|
+
marked.walkTokens(tokens, (token) => {
|
|
20
|
+
if (token.type === "link" || token.type === "image")
|
|
21
|
+
links.push({ text: token.text, url: resolveLink(token.href, base) });
|
|
22
|
+
});
|
|
23
|
+
return links;
|
|
24
|
+
}
|
|
25
|
+
const nextText = /^(next|volgende|weiter|suivant|siguiente|próxima|proxima|avanti|»|›|>|→|next page|older( posts)?|more)$/i;
|
|
26
|
+
const prevText = /^(previous|prev|vorige|zurück|précédent|anterior|indietro|«|‹|<|←|newer( posts)?)$/i;
|
|
27
|
+
export const loadMoreText = /^(load more|show more|view more|see more|more (products|items|results|posts)|meer laden|toon meer|laad meer|meer (producten|artikelen|resultaten)|mehr laden|mehr anzeigen|voir plus|afficher plus|charger plus|ver más|cargar más|mostra altri|carica altro)$/i;
|
|
28
|
+
const inStockText = /\b(in stock|in voorraad|op voorraad|auf lager|lieferbar|en stock|disponible|available now|ships? (today|within)|leverbaar|direct leverbaar|beschikbaar|verfügbar)\b/i;
|
|
29
|
+
const outOfStockText = /\b(out of stock|sold out|uitverkocht|niet (op|in) voorraad|niet leverbaar|nicht (verfügbar|lieferbar|auf lager)|ausverkauft|épuisé|rupture de stock|agotado|esaurito|currently unavailable|tijdelijk uitverkocht)\b/i;
|
|
30
|
+
const currencySymbols = {
|
|
31
|
+
"€": "EUR",
|
|
32
|
+
// A bare dollar sign does not identify a currency.
|
|
33
|
+
$: "$",
|
|
34
|
+
"£": "GBP",
|
|
35
|
+
"¥": "JPY",
|
|
36
|
+
"₹": "INR",
|
|
37
|
+
zł: "PLN",
|
|
38
|
+
Kč: "CZK",
|
|
39
|
+
kr: "SEK/NOK/DKK",
|
|
40
|
+
CHF: "CHF",
|
|
41
|
+
EUR: "EUR",
|
|
42
|
+
USD: "USD",
|
|
43
|
+
GBP: "GBP",
|
|
44
|
+
CAD: "CAD",
|
|
45
|
+
AUD: "AUD",
|
|
46
|
+
SEK: "SEK",
|
|
47
|
+
NOK: "NOK",
|
|
48
|
+
DKK: "DKK",
|
|
49
|
+
PLN: "PLN",
|
|
50
|
+
CZK: "CZK",
|
|
51
|
+
JPY: "JPY",
|
|
52
|
+
};
|
|
53
|
+
const priceRegex = /(?:(€|\$|£|¥|₹|CHF|EUR|USD|GBP|CAD|AUD|SEK|NOK|DKK|PLN|CZK|JPY)\s?(\d{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)(?:,-)?)|(?:(\d{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)(?:,-)?\s?(€|£|EUR|USD|GBP|kr|zł|Kč|CHF|SEK|NOK|DKK|PLN|CZK))/g;
|
|
54
|
+
export function parseAmount(raw) {
|
|
55
|
+
const text = raw.replace(/\s/g, "").replace(/,-$/, "");
|
|
56
|
+
const lastComma = text.lastIndexOf(","), lastDot = text.lastIndexOf(".");
|
|
57
|
+
let normalized;
|
|
58
|
+
if (lastComma > lastDot) {
|
|
59
|
+
// 1.234,56 or 12,50
|
|
60
|
+
normalized =
|
|
61
|
+
text.length - lastComma - 1 <= 2
|
|
62
|
+
? text.replace(/\./g, "").replace(",", ".")
|
|
63
|
+
: text.replace(/,/g, "");
|
|
64
|
+
}
|
|
65
|
+
else if (lastDot > lastComma) {
|
|
66
|
+
normalized =
|
|
67
|
+
text.length - lastDot - 1 <= 2
|
|
68
|
+
? text.replace(/,/g, "")
|
|
69
|
+
: text.replace(/\./g, "");
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
normalized = text;
|
|
73
|
+
const value = Number(normalized);
|
|
74
|
+
return Number.isFinite(value) ? value : null;
|
|
75
|
+
}
|
|
76
|
+
// Visible prices: currency-marked amounts in the main text. Heuristic; a
|
|
77
|
+
// listing page carries many, a product page usually one or a few.
|
|
78
|
+
export function visiblePrices(text) {
|
|
79
|
+
const prices = [];
|
|
80
|
+
for (const match of text.matchAll(priceRegex)) {
|
|
81
|
+
if (prices.length >= 100)
|
|
82
|
+
break;
|
|
83
|
+
const symbol = match[1] ?? match[4];
|
|
84
|
+
const amount = parseAmount(match[2] ?? match[3]);
|
|
85
|
+
prices.push({
|
|
86
|
+
raw: match[0].trim(),
|
|
87
|
+
currency: currencySymbols[symbol] ?? symbol,
|
|
88
|
+
amount,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return prices;
|
|
92
|
+
}
|
|
93
|
+
export function extractPage(response, representation = "original-response-html") {
|
|
94
|
+
const $ = cheerio.load(response.body);
|
|
95
|
+
const base = resolveLink($("base[href]").first().attr("href"), response.finalUrl) ??
|
|
96
|
+
response.finalUrl;
|
|
97
|
+
const meta = $("meta")
|
|
98
|
+
.toArray()
|
|
99
|
+
.map((el) => ({
|
|
100
|
+
name: $(el).attr("name"),
|
|
101
|
+
property: $(el).attr("property"),
|
|
102
|
+
httpEquiv: $(el).attr("http-equiv"),
|
|
103
|
+
content: $(el).attr("content") ?? "",
|
|
104
|
+
}));
|
|
105
|
+
const declarations = $("link")
|
|
106
|
+
.toArray()
|
|
107
|
+
.map((el) => ({
|
|
108
|
+
rel: $(el).attr("rel") ?? "",
|
|
109
|
+
href: resolveLink($(el).attr("href"), base),
|
|
110
|
+
rawHref: $(el).attr("href"),
|
|
111
|
+
type: $(el).attr("type"),
|
|
112
|
+
hreflang: $(el).attr("hreflang"),
|
|
113
|
+
sizes: $(el).attr("sizes"),
|
|
114
|
+
fetchpriority: $(el).attr("fetchpriority"),
|
|
115
|
+
}));
|
|
116
|
+
const inPagination = (el) => $(el).closest('nav[aria-label*="pagin" i],nav[class*="pagin" i],[class*="pagination" i],[class*="pager" i],[role="navigation"][class*="page" i],.paginate,.page-numbers').length > 0;
|
|
117
|
+
const links = $("a[href],area[href]")
|
|
118
|
+
.toArray()
|
|
119
|
+
.map((el) => {
|
|
120
|
+
const raw = $(el).attr("href");
|
|
121
|
+
let fragment = null;
|
|
122
|
+
try {
|
|
123
|
+
fragment = new URL(raw, base).hash.slice(1) || null;
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
const text = clean($(el).text()).slice(0, 500);
|
|
127
|
+
const imageAlt = clean($(el)
|
|
128
|
+
.find("img[alt]")
|
|
129
|
+
.toArray()
|
|
130
|
+
.map((img) => $(img).attr("alt"))
|
|
131
|
+
.join(" "));
|
|
132
|
+
return {
|
|
133
|
+
href: resolveLink(raw, base),
|
|
134
|
+
rawHref: raw,
|
|
135
|
+
text,
|
|
136
|
+
// Accessible name fallback order: text, aria-label, image alt, title.
|
|
137
|
+
name: text ||
|
|
138
|
+
clean($(el).attr("aria-label")) ||
|
|
139
|
+
imageAlt ||
|
|
140
|
+
clean($(el).attr("title")) ||
|
|
141
|
+
"",
|
|
142
|
+
rel: $(el).attr("rel") ?? "",
|
|
143
|
+
target: $(el).attr("target") ?? "",
|
|
144
|
+
hreflang: $(el).attr("hreflang") || undefined,
|
|
145
|
+
fragment,
|
|
146
|
+
location: $(el).closest("header,nav,footer,aside").get(0)?.tagName ?? "content",
|
|
147
|
+
pagination: inPagination(el) || undefined,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
const images = $("img")
|
|
151
|
+
.toArray()
|
|
152
|
+
.map((el) => ({
|
|
153
|
+
src: resolveLink($(el).attr("src"), base),
|
|
154
|
+
alt: $(el).attr("alt") ?? null,
|
|
155
|
+
width: $(el).attr("width"),
|
|
156
|
+
height: $(el).attr("height"),
|
|
157
|
+
loading: $(el).attr("loading"),
|
|
158
|
+
fetchpriority: $(el).attr("fetchpriority"),
|
|
159
|
+
srcset: $(el).attr("srcset"),
|
|
160
|
+
sources: $(el)
|
|
161
|
+
.parent("picture")
|
|
162
|
+
.children("source")
|
|
163
|
+
.toArray()
|
|
164
|
+
.map((source) => ({
|
|
165
|
+
srcset: $(source).attr("srcset"),
|
|
166
|
+
sizes: $(source).attr("sizes"),
|
|
167
|
+
type: $(source).attr("type"),
|
|
168
|
+
media: $(source).attr("media"),
|
|
169
|
+
})),
|
|
170
|
+
sizes: $(el).attr("sizes"),
|
|
171
|
+
}));
|
|
172
|
+
const scripts = $("script[src]")
|
|
173
|
+
.toArray()
|
|
174
|
+
.map((el) => ({
|
|
175
|
+
src: resolveLink($(el).attr("src"), base),
|
|
176
|
+
type: $(el).attr("type"),
|
|
177
|
+
async: $(el).attr("async") !== undefined,
|
|
178
|
+
defer: $(el).attr("defer") !== undefined,
|
|
179
|
+
}));
|
|
180
|
+
const styles = declarations.filter((l) => l.rel.split(/\s+/).includes("stylesheet"));
|
|
181
|
+
const forms = $("form")
|
|
182
|
+
.toArray()
|
|
183
|
+
.map((el, index) => {
|
|
184
|
+
const form = $(el);
|
|
185
|
+
const formId = form.attr("id");
|
|
186
|
+
const controls = $("input,select,textarea,button")
|
|
187
|
+
.toArray()
|
|
188
|
+
.filter((control) => {
|
|
189
|
+
const owner = $(control).attr("form");
|
|
190
|
+
return owner
|
|
191
|
+
? owner === formId
|
|
192
|
+
: $(control).closest("form").get(0) === el;
|
|
193
|
+
})
|
|
194
|
+
.map((control) => {
|
|
195
|
+
const field = $(control);
|
|
196
|
+
const id = field.attr("id");
|
|
197
|
+
const labelIds = (field.attr("aria-labelledby") ?? "")
|
|
198
|
+
.split(/\s+/)
|
|
199
|
+
.filter(Boolean);
|
|
200
|
+
const labels = $("label")
|
|
201
|
+
.toArray()
|
|
202
|
+
.filter((label) => id && $(label).attr("for") === id)
|
|
203
|
+
.map((label) => clean($(label).text()));
|
|
204
|
+
const wrapped = clean(field.closest("label").text());
|
|
205
|
+
if (wrapped)
|
|
206
|
+
labels.push(wrapped);
|
|
207
|
+
const referencedLabels = labelIds.map((id) => clean($("[id]")
|
|
208
|
+
.filter((_, n) => $(n).attr("id") === id)
|
|
209
|
+
.text()));
|
|
210
|
+
return {
|
|
211
|
+
tag: control.tagName,
|
|
212
|
+
id,
|
|
213
|
+
name: field.attr("name"),
|
|
214
|
+
type: field.attr("type") ??
|
|
215
|
+
(control.tagName === "button" ? "submit" : "text"),
|
|
216
|
+
labels,
|
|
217
|
+
ariaLabel: field.attr("aria-label"),
|
|
218
|
+
referencedLabels,
|
|
219
|
+
hasAccessibleName: !!(labels.some(Boolean) ||
|
|
220
|
+
field.attr("aria-label") ||
|
|
221
|
+
referencedLabels.some(Boolean) ||
|
|
222
|
+
(control.tagName === "button" && clean(field.text())) ||
|
|
223
|
+
(["submit", "reset", "button"].includes(field.attr("type") ?? "") &&
|
|
224
|
+
field.attr("value"))),
|
|
225
|
+
required: field.attr("required") !== undefined,
|
|
226
|
+
disabled: field.attr("disabled") !== undefined,
|
|
227
|
+
autocomplete: field.attr("autocomplete"),
|
|
228
|
+
pattern: field.attr("pattern"),
|
|
229
|
+
min: field.attr("min"),
|
|
230
|
+
max: field.attr("max"),
|
|
231
|
+
minLength: field.attr("minlength"),
|
|
232
|
+
maxLength: field.attr("maxlength"),
|
|
233
|
+
actionOverride: resolveLink(field.attr("formaction"), base),
|
|
234
|
+
methodOverride: field.attr("formmethod"),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
const action = resolveLink(form.attr("action") || response.finalUrl, base);
|
|
238
|
+
return {
|
|
239
|
+
index,
|
|
240
|
+
id: formId,
|
|
241
|
+
action,
|
|
242
|
+
rawAction: form.attr("action") ?? null,
|
|
243
|
+
method: (form.attr("method") ?? "get").toLowerCase(),
|
|
244
|
+
noValidate: form.attr("novalidate") !== undefined,
|
|
245
|
+
enctype: form.attr("enctype") ?? "application/x-www-form-urlencoded",
|
|
246
|
+
controls,
|
|
247
|
+
insecureDestination: action?.startsWith("http:") ?? false,
|
|
248
|
+
verification: "markup-only; no submission performed; JavaScript handlers not verified",
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
const structuredData = $('script[type="application/ld+json"]')
|
|
252
|
+
.toArray()
|
|
253
|
+
.map((el, index) => {
|
|
254
|
+
const raw = $(el).text();
|
|
255
|
+
try {
|
|
256
|
+
const data = JSON.parse(raw);
|
|
257
|
+
const errors = [];
|
|
258
|
+
const check = (node, path) => {
|
|
259
|
+
if (Array.isArray(node)) {
|
|
260
|
+
node.forEach((n, i) => check(n, `${path}[${i}]`));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!node || typeof node !== "object") {
|
|
264
|
+
errors.push(`${path}: expected object or array`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (node["@type"] !== undefined &&
|
|
268
|
+
!(typeof node["@type"] === "string" ||
|
|
269
|
+
(Array.isArray(node["@type"]) &&
|
|
270
|
+
node["@type"].every((t) => typeof t === "string"))))
|
|
271
|
+
errors.push(`${path}.@type: expected string or string array`);
|
|
272
|
+
if (node["@id"] !== undefined && typeof node["@id"] !== "string")
|
|
273
|
+
errors.push(`${path}.@id: expected string`);
|
|
274
|
+
if (node["@graph"])
|
|
275
|
+
check(node["@graph"], `${path}.@graph`);
|
|
276
|
+
};
|
|
277
|
+
check(data, "$");
|
|
278
|
+
return {
|
|
279
|
+
index,
|
|
280
|
+
raw,
|
|
281
|
+
data,
|
|
282
|
+
parseValid: true,
|
|
283
|
+
structuralErrors: errors,
|
|
284
|
+
validationScope: "JSON syntax and JSON-LD @type/@id/@graph shapes only; not Google rich-result eligibility",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return { index, raw, parseValid: false, error: String(error) };
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
const microdata = $("[itemscope],[itemprop]")
|
|
292
|
+
.toArray()
|
|
293
|
+
.map((el) => ({
|
|
294
|
+
tag: el.tagName,
|
|
295
|
+
type: $(el).attr("itemtype"),
|
|
296
|
+
property: $(el).attr("itemprop"),
|
|
297
|
+
id: $(el).attr("itemid"),
|
|
298
|
+
content: $(el).attr("content") ??
|
|
299
|
+
$(el).attr("href") ??
|
|
300
|
+
clean($(el).text()).slice(0, 1000),
|
|
301
|
+
}));
|
|
302
|
+
const rdfa = $("[typeof],[property]")
|
|
303
|
+
.not("meta")
|
|
304
|
+
.toArray()
|
|
305
|
+
.map((el) => ({
|
|
306
|
+
tag: el.tagName,
|
|
307
|
+
type: $(el).attr("typeof"),
|
|
308
|
+
property: $(el).attr("property"),
|
|
309
|
+
resource: $(el).attr("resource"),
|
|
310
|
+
content: $(el).attr("content") ?? clean($(el).text()).slice(0, 1000),
|
|
311
|
+
}));
|
|
312
|
+
const headings = $("h1,h2,h3,h4,h5,h6")
|
|
313
|
+
.toArray()
|
|
314
|
+
.map((el) => ({
|
|
315
|
+
level: Number(el.tagName.slice(1)),
|
|
316
|
+
text: clean($(el).text()),
|
|
317
|
+
}));
|
|
318
|
+
const ids = $("[id],a[name]")
|
|
319
|
+
.toArray()
|
|
320
|
+
.map((el) => $(el).attr("id") ?? $(el).attr("name"))
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
const body = $("body").clone();
|
|
323
|
+
body.find("script,style,noscript,template,svg").remove();
|
|
324
|
+
body.find("p,div,section,article,li,h1,h2,h3,h4,h5,h6,br,td,th").append(" ");
|
|
325
|
+
const bodyText = clean(body.text());
|
|
326
|
+
const content = $("main,article").first().clone();
|
|
327
|
+
content.find("script,style,noscript,template,nav,footer,header,svg").remove();
|
|
328
|
+
content
|
|
329
|
+
.find("p,div,section,article,li,h1,h2,h3,h4,h5,h6,br,td,th")
|
|
330
|
+
.append(" ");
|
|
331
|
+
const mainText = clean(content.text()) || bodyText;
|
|
332
|
+
// Plain readability facts, the deterministic subset of what content
|
|
333
|
+
// plugins check: paragraph and sentence length.
|
|
334
|
+
const paragraphText = (content.length ? content : body)
|
|
335
|
+
.find("p")
|
|
336
|
+
.toArray()
|
|
337
|
+
.map((el) => clean($(el).text()))
|
|
338
|
+
.filter(Boolean);
|
|
339
|
+
const paragraphs = paragraphText
|
|
340
|
+
.map((text) => text.split(/\s+/).filter(Boolean).length)
|
|
341
|
+
.filter((n) => n > 0)
|
|
342
|
+
.slice(0, 500);
|
|
343
|
+
const sentences = paragraphText
|
|
344
|
+
.flatMap((text) => text.split(/(?<=[.!?])\s+/))
|
|
345
|
+
.map((s) => s.split(/\s+/).filter(Boolean).length)
|
|
346
|
+
.filter((n) => n > 0);
|
|
347
|
+
const readability = {
|
|
348
|
+
paragraphs: paragraphs.length,
|
|
349
|
+
longestParagraphWords: paragraphs.length ? Math.max(...paragraphs) : 0,
|
|
350
|
+
paragraphsOver150: paragraphs.filter((n) => n > 150).length,
|
|
351
|
+
sentences: sentences.length,
|
|
352
|
+
longSentenceShare: sentences.length
|
|
353
|
+
? Math.round((sentences.filter((n) => n > 20).length / sentences.length) * 100) / 100
|
|
354
|
+
: 0,
|
|
355
|
+
};
|
|
356
|
+
const metaValues = (name) => meta.filter((m) => m.name?.toLowerCase() === name).map((m) => m.content);
|
|
357
|
+
// Pagination: rel=next/prev declarations and anchors, numbered links in a
|
|
358
|
+
// pagination container, and next/previous link text.
|
|
359
|
+
const relLink = (rel) => declarations.find((l) => l.rel.split(/\s+/).includes(rel))?.href ??
|
|
360
|
+
links.find((l) => l.rel.split(/\s+/).includes(rel))?.href ??
|
|
361
|
+
null;
|
|
362
|
+
const numbered = [
|
|
363
|
+
...new Set(links
|
|
364
|
+
.filter((l) => l.href &&
|
|
365
|
+
l.pagination &&
|
|
366
|
+
/^\d{1,4}$/.test(l.text) &&
|
|
367
|
+
l.href !== response.finalUrl)
|
|
368
|
+
.map((l) => l.href)),
|
|
369
|
+
];
|
|
370
|
+
const next = relLink("next") ??
|
|
371
|
+
links.find((l) => l.href && nextText.test(l.text))?.href ??
|
|
372
|
+
null;
|
|
373
|
+
const prev = relLink("prev") ??
|
|
374
|
+
links.find((l) => l.href && prevText.test(l.text))?.href ??
|
|
375
|
+
null;
|
|
376
|
+
const pagination = next || prev || numbered.length
|
|
377
|
+
? {
|
|
378
|
+
next,
|
|
379
|
+
prev,
|
|
380
|
+
numbered: numbered.slice(0, 50),
|
|
381
|
+
source: relLink("next")
|
|
382
|
+
? "rel-next"
|
|
383
|
+
: numbered.length
|
|
384
|
+
? "pagination-container"
|
|
385
|
+
: "link-text",
|
|
386
|
+
}
|
|
387
|
+
: null;
|
|
388
|
+
const loadMore = $("button,a[href],[role='button']")
|
|
389
|
+
.toArray()
|
|
390
|
+
.filter((el) => loadMoreText.test(clean($(el).text())) && !$(el).closest("form").length)
|
|
391
|
+
.slice(0, 3)
|
|
392
|
+
.map((el) => ({
|
|
393
|
+
tag: el.tagName,
|
|
394
|
+
text: clean($(el).text()),
|
|
395
|
+
href: resolveLink($(el).attr("href"), base),
|
|
396
|
+
}));
|
|
397
|
+
const phones = [
|
|
398
|
+
...new Set([
|
|
399
|
+
...links
|
|
400
|
+
.filter((l) => /^tel:/i.test(l.rawHref))
|
|
401
|
+
.map((l) => l.rawHref.replace(/^tel:/i, "")),
|
|
402
|
+
// Text numbers must look dialable: an international prefix or a
|
|
403
|
+
// leading zero, so registration and account numbers are left alone.
|
|
404
|
+
...[...bodyText.matchAll(/(?:\+|\b0)\(?\d[\d\s().-]{6,18}\d/g)]
|
|
405
|
+
.map((m) => m[0].trim())
|
|
406
|
+
.filter((p) => {
|
|
407
|
+
const digits = p.replace(/\D/g, "");
|
|
408
|
+
return /^(\+|00)/.test(p)
|
|
409
|
+
? digits.length >= 10 && digits.length <= 15
|
|
410
|
+
: digits.length >= 9 && digits.length <= 11;
|
|
411
|
+
}),
|
|
412
|
+
]),
|
|
413
|
+
].slice(0, 20);
|
|
414
|
+
const emails = [
|
|
415
|
+
...new Set([
|
|
416
|
+
...links
|
|
417
|
+
.filter((l) => /^mailto:/i.test(l.rawHref))
|
|
418
|
+
.map((l) => l.rawHref.replace(/^mailto:/i, "").split("?")[0]),
|
|
419
|
+
...[...bodyText.matchAll(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g)].map((m) => m[0]),
|
|
420
|
+
]),
|
|
421
|
+
].slice(0, 20);
|
|
422
|
+
const prices = visiblePrices(mainText);
|
|
423
|
+
const availability = {
|
|
424
|
+
inStock: inStockText.test(mainText),
|
|
425
|
+
outOfStock: outOfStockText.test(mainText),
|
|
426
|
+
inStockText: mainText.match(inStockText)?.[0] ?? null,
|
|
427
|
+
outOfStockText: mainText.match(outOfStockText)?.[0] ?? null,
|
|
428
|
+
};
|
|
429
|
+
return {
|
|
430
|
+
http: httpEvidence(response),
|
|
431
|
+
base,
|
|
432
|
+
title: clean($("title").first().text()),
|
|
433
|
+
titles: $("title")
|
|
434
|
+
.toArray()
|
|
435
|
+
.map((n) => clean($(n).text())),
|
|
436
|
+
descriptions: metaValues("description"),
|
|
437
|
+
language: $("html").attr("lang") ?? null,
|
|
438
|
+
meta,
|
|
439
|
+
declarations,
|
|
440
|
+
links,
|
|
441
|
+
images,
|
|
442
|
+
scripts,
|
|
443
|
+
styles,
|
|
444
|
+
forms,
|
|
445
|
+
structuredData,
|
|
446
|
+
microdata,
|
|
447
|
+
rdfa,
|
|
448
|
+
headings,
|
|
449
|
+
ids,
|
|
450
|
+
canonicals: declarations.filter((l) => l.rel.split(/\s+/).includes("canonical")),
|
|
451
|
+
hreflangs: declarations.filter((l) => l.hreflang),
|
|
452
|
+
social: meta.filter((m) => m.property?.startsWith("og:") || m.name?.startsWith("twitter:")),
|
|
453
|
+
bodyText,
|
|
454
|
+
mainText,
|
|
455
|
+
wordCount: mainText.split(/\s+/).filter(Boolean).length,
|
|
456
|
+
readability,
|
|
457
|
+
textHash: hash(mainText),
|
|
458
|
+
htmlHash: hash(response.body),
|
|
459
|
+
robots: meta.filter((m) => /robots|bot/i.test(m.name ?? "")),
|
|
460
|
+
xRobotsTag: response.headers["x-robots-tag"] ?? null,
|
|
461
|
+
technologies: detectTechnologies(response.body, response.headers),
|
|
462
|
+
pagination,
|
|
463
|
+
loadMore,
|
|
464
|
+
prices,
|
|
465
|
+
availability,
|
|
466
|
+
contacts: { phones, emails },
|
|
467
|
+
truncated: response.truncated ?? false,
|
|
468
|
+
representation,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const indexDirective = (page) => [
|
|
472
|
+
page.xRobotsTag,
|
|
473
|
+
...page.robots
|
|
474
|
+
.filter((r) => /^(robots|googlebot)$/i.test(r.name ?? ""))
|
|
475
|
+
.map((r) => r.content),
|
|
476
|
+
]
|
|
477
|
+
.filter(Boolean)
|
|
478
|
+
.join(",");
|
|
479
|
+
// What JavaScript changed: the parts crawlers and agents that do not run
|
|
480
|
+
// scripts would miss or see differently.
|
|
481
|
+
export function compareRendered(original, rendered) {
|
|
482
|
+
const set = (values) => new Set(values.filter((v) => !!v));
|
|
483
|
+
const originalLinks = set(original.links.map((l) => l.href)), renderedLinks = set(rendered.links.map((l) => l.href));
|
|
484
|
+
const originalHeadings = set(original.headings.map((h) => `${h.level}:${h.text}`)), renderedHeadings = set(rendered.headings.map((h) => `${h.level}:${h.text}`));
|
|
485
|
+
const types = (page) => page.structuredData.flatMap((s) => s.parseValid ? structuredTypes(s.data) : []);
|
|
486
|
+
const originalTypes = types(original), renderedTypes = types(rendered);
|
|
487
|
+
const diff = (a, b) => [...b].filter((v) => !a.has(v));
|
|
488
|
+
return {
|
|
489
|
+
title: {
|
|
490
|
+
original: original.title,
|
|
491
|
+
rendered: rendered.title,
|
|
492
|
+
same: original.title === rendered.title,
|
|
493
|
+
},
|
|
494
|
+
canonical: {
|
|
495
|
+
original: original.canonicals.map((c) => c.href),
|
|
496
|
+
rendered: rendered.canonicals.map((c) => c.href),
|
|
497
|
+
same: original.canonicals.map((c) => c.href).join("|") ===
|
|
498
|
+
rendered.canonicals.map((c) => c.href).join("|"),
|
|
499
|
+
},
|
|
500
|
+
robots: {
|
|
501
|
+
original: indexDirective(original),
|
|
502
|
+
rendered: indexDirective(rendered),
|
|
503
|
+
same: indexDirective(original) === indexDirective(rendered),
|
|
504
|
+
},
|
|
505
|
+
language: {
|
|
506
|
+
original: original.language,
|
|
507
|
+
rendered: rendered.language,
|
|
508
|
+
same: original.language === rendered.language,
|
|
509
|
+
},
|
|
510
|
+
headings: {
|
|
511
|
+
original: originalHeadings.size,
|
|
512
|
+
rendered: renderedHeadings.size,
|
|
513
|
+
added: diff(originalHeadings, renderedHeadings).slice(0, 50),
|
|
514
|
+
removed: diff(renderedHeadings, originalHeadings).slice(0, 50),
|
|
515
|
+
},
|
|
516
|
+
links: {
|
|
517
|
+
original: originalLinks.size,
|
|
518
|
+
rendered: renderedLinks.size,
|
|
519
|
+
added: diff(originalLinks, renderedLinks),
|
|
520
|
+
removed: diff(renderedLinks, originalLinks).slice(0, 200),
|
|
521
|
+
},
|
|
522
|
+
structuredData: {
|
|
523
|
+
original: originalTypes,
|
|
524
|
+
rendered: renderedTypes,
|
|
525
|
+
addedTypes: renderedTypes.filter((t) => !originalTypes.includes(t)),
|
|
526
|
+
removedTypes: originalTypes.filter((t) => !renderedTypes.includes(t)),
|
|
527
|
+
},
|
|
528
|
+
text: {
|
|
529
|
+
originalWords: original.wordCount,
|
|
530
|
+
renderedWords: rendered.wordCount,
|
|
531
|
+
same: original.textHash === rendered.textHash,
|
|
532
|
+
// Share of the rendered words already present without JavaScript.
|
|
533
|
+
ratio: rendered.wordCount
|
|
534
|
+
? Math.round((original.wordCount / rendered.wordCount) * 100) / 100
|
|
535
|
+
: null,
|
|
536
|
+
},
|
|
537
|
+
scope: "Original response HTML compared with the DOM after load and network idle in headless Chrome; personalization and consent overlays can add differences",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// All @type values in a JSON-LD document, including @graph and nested nodes.
|
|
541
|
+
export function structuredTypes(data, depth = 0) {
|
|
542
|
+
if (depth > 6 || !data)
|
|
543
|
+
return [];
|
|
544
|
+
if (Array.isArray(data))
|
|
545
|
+
return data.flatMap((d) => structuredTypes(d, depth + 1));
|
|
546
|
+
if (typeof data !== "object")
|
|
547
|
+
return [];
|
|
548
|
+
const own = [data["@type"]].flat().filter((t) => typeof t === "string");
|
|
549
|
+
return [
|
|
550
|
+
...own,
|
|
551
|
+
...Object.entries(data)
|
|
552
|
+
.filter(([k]) => k !== "@type")
|
|
553
|
+
.flatMap(([, v]) => structuredTypes(v, depth + 1)),
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
export function detectTechnologies(html, headers) {
|
|
557
|
+
const rules = [
|
|
558
|
+
["WordPress", "cms", /wp-content\/|wp-includes\//i, "html"],
|
|
559
|
+
["WooCommerce", "ecommerce", /woocommerce/i, "html"],
|
|
560
|
+
["Shopify", "ecommerce", /cdn\.shopify\.com|Shopify\.shop/i, "html"],
|
|
561
|
+
[
|
|
562
|
+
"Magento",
|
|
563
|
+
"ecommerce",
|
|
564
|
+
/Magento_|mage\/requirejs|\/static\/version\d+\//i,
|
|
565
|
+
"html",
|
|
566
|
+
],
|
|
567
|
+
["Lightspeed", "ecommerce", /webshopapp\.com|lightspeed/i, "html"],
|
|
568
|
+
["Webflow", "cms", /data-wf-(page|site)=/i, "html"],
|
|
569
|
+
["Wix", "cms", /wixstatic\.com|wix-thunderbolt/i, "html"],
|
|
570
|
+
[
|
|
571
|
+
"Squarespace",
|
|
572
|
+
"cms",
|
|
573
|
+
/static\d?\.squarespace\.com|squarespace-cdn\.com/i,
|
|
574
|
+
"html",
|
|
575
|
+
],
|
|
576
|
+
["Next.js", "framework", /_next\/static|__NEXT_DATA__/, "html"],
|
|
577
|
+
["Nuxt", "framework", /__NUXT__|\/_nuxt\//, "html"],
|
|
578
|
+
["Astro", "framework", /astro-island|data-astro-cid-/, "html"],
|
|
579
|
+
["Gatsby", "framework", /___gatsby|gatsby-focus-wrapper/, "html"],
|
|
580
|
+
["Cloudflare", "cdn", /cloudflare/i, "server"],
|
|
581
|
+
["Netlify", "hosting", /Netlify/i, "server"],
|
|
582
|
+
["Vercel", "hosting", /Vercel/i, "server"],
|
|
583
|
+
[
|
|
584
|
+
"Google Tag Manager",
|
|
585
|
+
"analytics",
|
|
586
|
+
/googletagmanager\.com\/gtm\.js/,
|
|
587
|
+
"html",
|
|
588
|
+
],
|
|
589
|
+
[
|
|
590
|
+
"Google Analytics",
|
|
591
|
+
"analytics",
|
|
592
|
+
/google-analytics\.com|googletagmanager\.com\/gtag/,
|
|
593
|
+
"html",
|
|
594
|
+
],
|
|
595
|
+
["Plausible", "analytics", /plausible\.io\/js\//, "html"],
|
|
596
|
+
["PostHog", "analytics", /posthog\.com|i\.posthog\.com/, "html"],
|
|
597
|
+
];
|
|
598
|
+
return rules.flatMap(([name, category, pattern, source]) => {
|
|
599
|
+
const match = pattern.exec(source === "html" ? html : (headers[source] ?? ""));
|
|
600
|
+
return match
|
|
601
|
+
? [
|
|
602
|
+
{
|
|
603
|
+
name,
|
|
604
|
+
category,
|
|
605
|
+
source,
|
|
606
|
+
evidence: match[0],
|
|
607
|
+
confidence: "signature-match",
|
|
608
|
+
version: null,
|
|
609
|
+
},
|
|
610
|
+
]
|
|
611
|
+
: [];
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
export function isHtml(response) {
|
|
615
|
+
return (/text\/html|application\/xhtml\+xml/i.test(response.headers["content-type"] ?? "") || /^\s*(<!doctype html|<html)/i.test(response.body));
|
|
616
|
+
}
|
|
617
|
+
export function markdownEvidence(body, contentType) {
|
|
618
|
+
const looksHtml = /text\/html|application\/xhtml/i.test(contentType ?? "") ||
|
|
619
|
+
/^\s*(<!doctype|<html|<head|<body)/i.test(body);
|
|
620
|
+
const looksMarkdown = /^#{1,6}\s+\S|\[[^\]]+\]\([^\)]+\)/m.test(body);
|
|
621
|
+
return {
|
|
622
|
+
usable: !looksHtml &&
|
|
623
|
+
body.trim().length > 0 &&
|
|
624
|
+
(/text\/(markdown|x-markdown)/i.test(contentType ?? "") || looksMarkdown),
|
|
625
|
+
looksHtml,
|
|
626
|
+
looksMarkdown,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
export function textTokens(s) {
|
|
630
|
+
return new Set(s
|
|
631
|
+
.toLowerCase()
|
|
632
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
633
|
+
.split(/\s+/)
|
|
634
|
+
.filter(Boolean));
|
|
635
|
+
}
|
|
636
|
+
export function textSimilarity(a, b) {
|
|
637
|
+
const left = textTokens(a), right = textTokens(b);
|
|
638
|
+
if (!left.size || !right.size)
|
|
639
|
+
return 0;
|
|
640
|
+
let intersection = 0;
|
|
641
|
+
for (const word of left)
|
|
642
|
+
if (right.has(word))
|
|
643
|
+
intersection++;
|
|
644
|
+
return intersection / (left.size + right.size - intersection);
|
|
645
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function detectLanguage(text: string): string | null;
|
|
2
|
+
export declare function languageOfTag(tag: string | null | undefined): string | null;
|
|
3
|
+
export declare function languageLinks(links: {
|
|
4
|
+
href: string | null;
|
|
5
|
+
text: string;
|
|
6
|
+
hreflang?: string;
|
|
7
|
+
location: string;
|
|
8
|
+
}[]): {
|
|
9
|
+
url: string;
|
|
10
|
+
expected: string;
|
|
11
|
+
text: string;
|
|
12
|
+
location: string;
|
|
13
|
+
}[];
|