@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,747 @@
|
|
|
1
|
+
import { crawlUrl, isMapLink, pageType, socialPlatform, templateGroup, } from "./crawl-scope.js";
|
|
2
|
+
import { detectLanguage, languageLinks, languageOfTag } from "./language.js";
|
|
3
|
+
import { structuredTypes } from "./html.js";
|
|
4
|
+
import { compareOffers, entities, offerFacts, richResultChecks, } from "./structured-data.js";
|
|
5
|
+
import { sameSite } from "./network.js";
|
|
6
|
+
import { setImmediate } from "node:timers/promises";
|
|
7
|
+
const vagueAnchor = /^(click here|here|read more|more|learn more|details|link|this|this page|view|see more|go|continue|lees meer|meer|klik hier|hier|bekijk|mehr|weiterlesen|hier klicken|en savoir plus|lire la suite|ici|leer más|más|ver más)\.?$/i;
|
|
8
|
+
const digitsOf = (phone) => phone.replace(/\D/g, "").replace(/^00/, "");
|
|
9
|
+
// FNV-1a over a string, then a cheap per-seed mix, for MinHash signatures.
|
|
10
|
+
function fnv(text) {
|
|
11
|
+
let h = 0x811c9dc5;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
h ^= text.charCodeAt(i);
|
|
14
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
return h;
|
|
17
|
+
}
|
|
18
|
+
const seeds = Array.from({ length: 64 }, (_, i) => fnv(`seed-${i}`));
|
|
19
|
+
function signature(tokens) {
|
|
20
|
+
const shingles = new Set();
|
|
21
|
+
for (let i = 0; i + 2 < tokens.length; i++)
|
|
22
|
+
shingles.add(`${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`);
|
|
23
|
+
if (!shingles.size)
|
|
24
|
+
for (const t of tokens)
|
|
25
|
+
shingles.add(t);
|
|
26
|
+
const bases = [...shingles].map(fnv);
|
|
27
|
+
return seeds.map((seed) => {
|
|
28
|
+
let min = 0xffffffff;
|
|
29
|
+
for (const base of bases) {
|
|
30
|
+
const h = Math.imul(base ^ seed, 0x9e3779b1) >>> 0;
|
|
31
|
+
if (h < min)
|
|
32
|
+
min = h;
|
|
33
|
+
}
|
|
34
|
+
return min;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function analyze(scan, records, urls) {
|
|
38
|
+
// The analysis job shares the worker's job time limit. Pairwise phases
|
|
39
|
+
// stop at this budget and the coverage says so, rather than pretending.
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const budgetMs = 240000;
|
|
42
|
+
const outOfTime = () => Date.now() - started > budgetMs;
|
|
43
|
+
// HTML pages carry the content analysis; every page record, including
|
|
44
|
+
// broken, blocked and non-HTML answers, feeds the HTTP lookups so link,
|
|
45
|
+
// canonical, hreflang and pagination targets keep their observed status.
|
|
46
|
+
const pageRecords = records.filter((r) => r.kind === "page");
|
|
47
|
+
const pages = pageRecords.filter((r) => r.data.html !== false);
|
|
48
|
+
const byUrl = new Map();
|
|
49
|
+
for (const p of pages) {
|
|
50
|
+
byUrl.set(p.key, p.data);
|
|
51
|
+
byUrl.set(p.data.http.finalUrl, p.data);
|
|
52
|
+
}
|
|
53
|
+
const httpByUrl = new Map();
|
|
54
|
+
for (const p of pageRecords) {
|
|
55
|
+
httpByUrl.set(p.key, p.data.http);
|
|
56
|
+
if (p.data.http?.finalUrl)
|
|
57
|
+
httpByUrl.set(p.data.http.finalUrl, p.data.http);
|
|
58
|
+
}
|
|
59
|
+
const rendered = new Map(records.filter((r) => r.kind === "rendered").map((r) => [r.key, r.data]));
|
|
60
|
+
const resources = new Map(records.filter((r) => r.kind === "resource").map((r) => [r.key, r.data]));
|
|
61
|
+
// Observed HTTP outcome of any URL the crawl touched, page or resource.
|
|
62
|
+
const httpOf = (url) => url ? (httpByUrl.get(url) ?? resources.get(url)?.http ?? null) : null;
|
|
63
|
+
const statusOf = (url) => httpOf(url)?.status ?? null;
|
|
64
|
+
const outcomeOf = (url) => httpOf(url)?.outcome ?? null;
|
|
65
|
+
// Link graph: every internal link from original and rendered HTML,
|
|
66
|
+
// separated into navigation (header, nav, footer, aside) and content.
|
|
67
|
+
const inlinks = new Map();
|
|
68
|
+
const originalInlinks = new Set();
|
|
69
|
+
const contextualInlinks = new Map();
|
|
70
|
+
const outlinks = new Map();
|
|
71
|
+
const anchors = [];
|
|
72
|
+
const vague = [];
|
|
73
|
+
for (const page of pages) {
|
|
74
|
+
const renderedLinks = rendered.get(page.key)?.links ?? [];
|
|
75
|
+
const consider = (link, fromRendered) => {
|
|
76
|
+
if (!link.href)
|
|
77
|
+
return;
|
|
78
|
+
const targetUrl = crawlUrl(link.href, scan);
|
|
79
|
+
if (!sameSite(targetUrl, scan.url))
|
|
80
|
+
return;
|
|
81
|
+
if (targetUrl === page.key || targetUrl === page.data.http.finalUrl)
|
|
82
|
+
return;
|
|
83
|
+
if (!fromRendered)
|
|
84
|
+
originalInlinks.add(targetUrl);
|
|
85
|
+
(inlinks.get(targetUrl) ??
|
|
86
|
+
inlinks.set(targetUrl, new Set()).get(targetUrl)).add(page.key);
|
|
87
|
+
if (link.location === "content")
|
|
88
|
+
(contextualInlinks.get(targetUrl) ??
|
|
89
|
+
contextualInlinks.set(targetUrl, new Set()).get(targetUrl)).add(page.key);
|
|
90
|
+
(outlinks.get(page.key) ??
|
|
91
|
+
outlinks.set(page.key, new Set()).get(page.key)).add(targetUrl);
|
|
92
|
+
};
|
|
93
|
+
for (const link of page.data.links) {
|
|
94
|
+
consider(link, false);
|
|
95
|
+
if (link.href &&
|
|
96
|
+
link.location === "content" &&
|
|
97
|
+
(vagueAnchor.test(link.name ?? link.text ?? "") ||
|
|
98
|
+
!(link.name ?? link.text)))
|
|
99
|
+
vague.push({
|
|
100
|
+
source: page.key,
|
|
101
|
+
target: link.href,
|
|
102
|
+
text: link.name ?? link.text ?? "",
|
|
103
|
+
});
|
|
104
|
+
if (link.fragment && link.href) {
|
|
105
|
+
const targetUrl = crawlUrl(link.href, scan);
|
|
106
|
+
const target = byUrl.get(targetUrl) ??
|
|
107
|
+
resources.get(targetUrl)?.html ??
|
|
108
|
+
resources.get(link.href)?.html;
|
|
109
|
+
let fragment = link.fragment;
|
|
110
|
+
try {
|
|
111
|
+
fragment = decodeURIComponent(fragment);
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
anchors.push({
|
|
115
|
+
source: page.key,
|
|
116
|
+
target: link.href,
|
|
117
|
+
scanTarget: targetUrl,
|
|
118
|
+
fragment,
|
|
119
|
+
present: target ? target.ids.includes(fragment) : null,
|
|
120
|
+
coverage: target ? "checked" : "target-not-inspected",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const link of renderedLinks)
|
|
125
|
+
consider(link, true);
|
|
126
|
+
}
|
|
127
|
+
const home = pages.find((p) => p.key === scan.url) ??
|
|
128
|
+
pages.find((p) => new URL(p.key).pathname === "/");
|
|
129
|
+
const depth = new Map();
|
|
130
|
+
if (home) {
|
|
131
|
+
depth.set(home.key, 0);
|
|
132
|
+
depth.set(home.data.http.finalUrl, 0);
|
|
133
|
+
}
|
|
134
|
+
const queue = home ? [home.key, home.data.http.finalUrl] : [];
|
|
135
|
+
for (let i = 0; i < queue.length; i++) {
|
|
136
|
+
const from = queue[i];
|
|
137
|
+
for (const to of outlinks.get(from) ?? [])
|
|
138
|
+
if (!depth.has(to)) {
|
|
139
|
+
depth.set(to, depth.get(from) + 1);
|
|
140
|
+
queue.push(to);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const productLinkCount = (page) => page.links.filter((l) => l.href &&
|
|
144
|
+
l.location === "content" &&
|
|
145
|
+
/\/(products?|producten|produkte|produits|item|artikel|p)\/[^/]+/i.test(new URL(l.href).pathname)).length;
|
|
146
|
+
const typeOf = new Map();
|
|
147
|
+
for (const page of pages) {
|
|
148
|
+
const types = (page.data.structuredData ?? []).flatMap((s) => s.parseValid ? structuredTypes(s.data) : []);
|
|
149
|
+
typeOf.set(page.key, pageType(page.key, {
|
|
150
|
+
structuredTypes: types,
|
|
151
|
+
pagination: page.data.pagination ?? rendered.get(page.key)?.pagination ?? null,
|
|
152
|
+
productLinks: productLinkCount(page.data),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const graph = urls
|
|
156
|
+
.filter((u) => u.kind === "page")
|
|
157
|
+
.map((u) => {
|
|
158
|
+
const page = byUrl.get(u.url);
|
|
159
|
+
const inSitemap = u.sources.some((s) => s.startsWith("sitemap:"));
|
|
160
|
+
return {
|
|
161
|
+
url: u.url,
|
|
162
|
+
sources: u.sources,
|
|
163
|
+
scheduled: u.scheduled,
|
|
164
|
+
skipReason: u.skipReason ?? null,
|
|
165
|
+
status: page?.http?.status ?? statusOf(u.url),
|
|
166
|
+
outcome: page?.http?.outcome ?? outcomeOf(u.url),
|
|
167
|
+
type: typeOf.get(u.url) ?? (page ? "page" : null),
|
|
168
|
+
group: templateGroup(u.url),
|
|
169
|
+
language: page?.language ?? null,
|
|
170
|
+
detectedLanguage: page ? detectLanguage(page.mainText ?? "") : null,
|
|
171
|
+
inlinkCount: inlinks.get(u.url)?.size ?? 0,
|
|
172
|
+
contextualInlinkCount: contextualInlinks.get(u.url)?.size ?? 0,
|
|
173
|
+
outlinkCount: outlinks.get(u.url)?.size ?? 0,
|
|
174
|
+
clickDepth: depth.get(u.url) ?? null,
|
|
175
|
+
inSitemap,
|
|
176
|
+
linkedFromSite: (inlinks.get(u.url)?.size ?? 0) > 0,
|
|
177
|
+
// Linked only by markup that JavaScript added.
|
|
178
|
+
renderedOnly: inlinks.has(u.url) && !originalInlinks.has(u.url),
|
|
179
|
+
orphanCandidate: inSitemap && !inlinks.has(u.url) && u.url !== home?.key,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
// Peer comparison: pages of one template group and type with far fewer
|
|
183
|
+
// contextual incoming links than the group's median.
|
|
184
|
+
const peerGroups = new Map();
|
|
185
|
+
for (const row of graph)
|
|
186
|
+
if (row.status === 200 && row.type && row.type !== "home") {
|
|
187
|
+
const key = `${row.type}|${row.group}`;
|
|
188
|
+
peerGroups.set(key, [...(peerGroups.get(key) ?? []), row]);
|
|
189
|
+
}
|
|
190
|
+
const fewContextual = [];
|
|
191
|
+
for (const [key, rows] of peerGroups) {
|
|
192
|
+
if (rows.length < 5)
|
|
193
|
+
continue;
|
|
194
|
+
const sorted = rows
|
|
195
|
+
.map((r) => r.contextualInlinkCount)
|
|
196
|
+
.sort((a, b) => a - b);
|
|
197
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
198
|
+
if (median < 2)
|
|
199
|
+
continue;
|
|
200
|
+
for (const row of rows)
|
|
201
|
+
if (row.contextualInlinkCount === 0)
|
|
202
|
+
fewContextual.push({
|
|
203
|
+
url: row.url,
|
|
204
|
+
group: key,
|
|
205
|
+
peers: rows.length,
|
|
206
|
+
peerMedian: median,
|
|
207
|
+
contextualInlinks: 0,
|
|
208
|
+
navigationInlinks: row.inlinkCount,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Exact duplicates, then near-duplicates through MinHash candidate
|
|
212
|
+
// selection and exact word-set Jaccard on the candidates.
|
|
213
|
+
const duplicates = [];
|
|
214
|
+
for (const field of ["title", "description", "textHash"]) {
|
|
215
|
+
const groups = new Map();
|
|
216
|
+
for (const page of pages) {
|
|
217
|
+
const value = field === "description"
|
|
218
|
+
? page.data.descriptions.join("|")
|
|
219
|
+
: page.data[field];
|
|
220
|
+
if (value)
|
|
221
|
+
groups.set(value, [...(groups.get(value) ?? []), page.key]);
|
|
222
|
+
}
|
|
223
|
+
for (const [value, urls] of groups)
|
|
224
|
+
if (urls.length > 1)
|
|
225
|
+
duplicates.push({
|
|
226
|
+
field,
|
|
227
|
+
value: field === "textHash" ? value : value.slice(0, 1000),
|
|
228
|
+
urls,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const eligible = pages.filter((p) => (p.data.wordCount ?? 0) >= 50);
|
|
232
|
+
const tokenLists = eligible.map((page) => page.data.mainText
|
|
233
|
+
.slice(0, 20000)
|
|
234
|
+
.toLowerCase()
|
|
235
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
236
|
+
.split(/\s+/)
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.slice(0, 4000));
|
|
239
|
+
const tokenSets = tokenLists.map((t) => new Set(t));
|
|
240
|
+
// Band buckets per page: bucketKeys[i] names the 16 buckets page i is in.
|
|
241
|
+
const buckets = new Map();
|
|
242
|
+
const bucketKeys = [];
|
|
243
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
244
|
+
const sig = signature(tokenLists[i]);
|
|
245
|
+
const keys = [];
|
|
246
|
+
for (let band = 0; band < 16; band++) {
|
|
247
|
+
const key = `${band}:${sig.slice(band * 4, band * 4 + 4).join(",")}`;
|
|
248
|
+
keys.push(key);
|
|
249
|
+
const members = buckets.get(key);
|
|
250
|
+
if (members)
|
|
251
|
+
members.push(i);
|
|
252
|
+
else
|
|
253
|
+
buckets.set(key, [i]);
|
|
254
|
+
}
|
|
255
|
+
bucketKeys.push(keys);
|
|
256
|
+
if (i % 50 === 0)
|
|
257
|
+
await setImmediate();
|
|
258
|
+
}
|
|
259
|
+
const nearDuplicates = [];
|
|
260
|
+
let comparisons = 0;
|
|
261
|
+
let candidatePairs = 0;
|
|
262
|
+
let pagesCompared = 0;
|
|
263
|
+
// Sampled text is a known bound; running out of time is reported as such.
|
|
264
|
+
const sampled = pages.some((p) => p.data.mainTextTotalLength > 20000);
|
|
265
|
+
let limited = false;
|
|
266
|
+
let limitReason = null;
|
|
267
|
+
// Candidates are enumerated per left page (bucket members with a higher
|
|
268
|
+
// index), so memory stays at one page's candidate set and the time budget
|
|
269
|
+
// is checked as the work proceeds.
|
|
270
|
+
left: for (let a = 0; a < eligible.length; a++) {
|
|
271
|
+
if (outOfTime()) {
|
|
272
|
+
limited = true;
|
|
273
|
+
limitReason = "analysis time budget reached during text comparison";
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
const candidates = new Set();
|
|
277
|
+
for (const key of bucketKeys[a])
|
|
278
|
+
for (const b of buckets.get(key))
|
|
279
|
+
if (b > a)
|
|
280
|
+
candidates.add(b);
|
|
281
|
+
candidatePairs += candidates.size;
|
|
282
|
+
pagesCompared++;
|
|
283
|
+
for (const b of candidates) {
|
|
284
|
+
if (comparisons % 200 === 0) {
|
|
285
|
+
await setImmediate();
|
|
286
|
+
if (outOfTime()) {
|
|
287
|
+
limited = true;
|
|
288
|
+
limitReason = "analysis time budget reached during text comparison";
|
|
289
|
+
break left;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (eligible[a].data.textHash === eligible[b].data.textHash)
|
|
293
|
+
continue;
|
|
294
|
+
comparisons++;
|
|
295
|
+
const one = tokenSets[a], two = tokenSets[b];
|
|
296
|
+
let intersection = 0;
|
|
297
|
+
for (const token of one)
|
|
298
|
+
if (two.has(token))
|
|
299
|
+
intersection++;
|
|
300
|
+
const similarity = intersection / (one.size + two.size - intersection || 1);
|
|
301
|
+
if (similarity >= 0.9) {
|
|
302
|
+
const onlyLeft = [...one].filter((t) => !two.has(t));
|
|
303
|
+
const onlyRight = [...two].filter((t) => !one.has(t));
|
|
304
|
+
nearDuplicates.push({
|
|
305
|
+
urls: [eligible[a].key, eligible[b].key],
|
|
306
|
+
similarity,
|
|
307
|
+
sameGroup: templateGroup(eligible[a].key) === templateGroup(eligible[b].key),
|
|
308
|
+
differences: {
|
|
309
|
+
onlyLeft: onlyLeft.slice(0, 12),
|
|
310
|
+
onlyRight: onlyRight.slice(0, 12),
|
|
311
|
+
},
|
|
312
|
+
// Few differing words between otherwise identical texts: the
|
|
313
|
+
// pattern of a place or product name swapped into a template.
|
|
314
|
+
substitution: onlyLeft.length > 0 &&
|
|
315
|
+
onlyRight.length > 0 &&
|
|
316
|
+
onlyLeft.length <= 8 &&
|
|
317
|
+
onlyRight.length <= 8,
|
|
318
|
+
method: "token-set Jaccard >=0.9 on MinHash candidates, minimum 50 words; heuristic, bounded text sample",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Collections listing the same items: content links of collection pages
|
|
324
|
+
// in the same template group.
|
|
325
|
+
const collections = pages.filter((p) => typeOf.get(p.key) === "collection");
|
|
326
|
+
const itemSets = new Map();
|
|
327
|
+
for (const page of collections) {
|
|
328
|
+
const set = new Set();
|
|
329
|
+
for (const link of [
|
|
330
|
+
...page.data.links,
|
|
331
|
+
...(rendered.get(page.key)?.links ?? []),
|
|
332
|
+
])
|
|
333
|
+
if (link.href && link.location === "content") {
|
|
334
|
+
const target = crawlUrl(link.href, scan);
|
|
335
|
+
if (sameSite(target, scan.url) && typeOf.get(target) === "product")
|
|
336
|
+
set.add(target);
|
|
337
|
+
}
|
|
338
|
+
if (set.size >= 4)
|
|
339
|
+
itemSets.set(page.key, set);
|
|
340
|
+
}
|
|
341
|
+
const overlappingCollections = [];
|
|
342
|
+
let collectionComparisons = 0;
|
|
343
|
+
const collectionKeys = [...itemSets.keys()];
|
|
344
|
+
outer: for (let a = 0; a < collectionKeys.length; a++)
|
|
345
|
+
for (let b = a + 1; b < collectionKeys.length; b++) {
|
|
346
|
+
if (collectionComparisons++ % 500 === 0) {
|
|
347
|
+
await setImmediate();
|
|
348
|
+
if (outOfTime()) {
|
|
349
|
+
limited = true;
|
|
350
|
+
limitReason ??=
|
|
351
|
+
"analysis time budget reached during collection comparison";
|
|
352
|
+
break outer;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const one = itemSets.get(collectionKeys[a]), two = itemSets.get(collectionKeys[b]);
|
|
356
|
+
let shared = 0;
|
|
357
|
+
for (const item of one)
|
|
358
|
+
if (two.has(item))
|
|
359
|
+
shared++;
|
|
360
|
+
const overlap = shared / Math.max(one.size, two.size);
|
|
361
|
+
if (overlap >= 0.9)
|
|
362
|
+
overlappingCollections.push({
|
|
363
|
+
urls: [collectionKeys[a], collectionKeys[b]],
|
|
364
|
+
items: [one.size, two.size],
|
|
365
|
+
shared,
|
|
366
|
+
overlap: Math.round(overlap * 100) / 100,
|
|
367
|
+
method: "shared product links in content, largest-set ratio >=0.9",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// Language: hreflang reciprocity and reachability, language-switch links
|
|
371
|
+
// and the written language of each page.
|
|
372
|
+
const hreflangs = pages.flatMap((page) => page.data.hreflangs.map((link) => {
|
|
373
|
+
const targetUrl = link.href ? crawlUrl(link.href, scan) : null;
|
|
374
|
+
const target = targetUrl ? byUrl.get(targetUrl) : null;
|
|
375
|
+
let languageValid = false;
|
|
376
|
+
try {
|
|
377
|
+
languageValid =
|
|
378
|
+
link.hreflang === "x-default" || !!new Intl.Locale(link.hreflang);
|
|
379
|
+
}
|
|
380
|
+
catch { }
|
|
381
|
+
return {
|
|
382
|
+
source: page.key,
|
|
383
|
+
href: link.href,
|
|
384
|
+
hreflang: link.hreflang,
|
|
385
|
+
scanTarget: targetUrl,
|
|
386
|
+
languageValid,
|
|
387
|
+
targetObserved: !!target,
|
|
388
|
+
targetStatus: statusOf(targetUrl),
|
|
389
|
+
targetOutcome: outcomeOf(targetUrl),
|
|
390
|
+
targetLanguage: target?.language ?? null,
|
|
391
|
+
targetLanguageMatches: target && link.hreflang !== "x-default"
|
|
392
|
+
? languageOfTag(target.language) === languageOfTag(link.hreflang)
|
|
393
|
+
: null,
|
|
394
|
+
reciprocal: target
|
|
395
|
+
? target.hreflangs.some((h) => {
|
|
396
|
+
const href = h.href ? crawlUrl(h.href, scan) : null;
|
|
397
|
+
return href === page.key || href === page.data.http.finalUrl;
|
|
398
|
+
})
|
|
399
|
+
: null,
|
|
400
|
+
};
|
|
401
|
+
}));
|
|
402
|
+
const switches = new Map();
|
|
403
|
+
for (const page of pages)
|
|
404
|
+
for (const link of languageLinks(page.data.links)) {
|
|
405
|
+
const targetUrl = crawlUrl(link.url, scan);
|
|
406
|
+
if (!sameSite(targetUrl, scan.url) ||
|
|
407
|
+
switches.has(`${targetUrl}|${link.expected}`))
|
|
408
|
+
continue;
|
|
409
|
+
const target = byUrl.get(targetUrl);
|
|
410
|
+
switches.set(`${targetUrl}|${link.expected}`, {
|
|
411
|
+
source: page.key,
|
|
412
|
+
url: targetUrl,
|
|
413
|
+
expected: link.expected,
|
|
414
|
+
text: link.text,
|
|
415
|
+
status: statusOf(targetUrl),
|
|
416
|
+
outcome: outcomeOf(targetUrl),
|
|
417
|
+
targetLanguage: target?.language ?? null,
|
|
418
|
+
declaredMatches: target
|
|
419
|
+
? languageOfTag(target.language) === link.expected
|
|
420
|
+
: null,
|
|
421
|
+
detectedLanguage: target ? detectLanguage(target.mainText ?? "") : null,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const canonicals = pages.flatMap((page) => page.data.canonicals.map((link) => {
|
|
425
|
+
const scanTarget = link.href ? crawlUrl(link.href, scan) : null;
|
|
426
|
+
const target = (scanTarget ? byUrl.get(scanTarget) : null) ??
|
|
427
|
+
(scanTarget ? resources.get(scanTarget) : null) ??
|
|
428
|
+
resources.get(link.href);
|
|
429
|
+
return {
|
|
430
|
+
source: page.key,
|
|
431
|
+
target: link.href,
|
|
432
|
+
scanTarget,
|
|
433
|
+
targetHttp: target?.http ?? httpOf(scanTarget) ?? null,
|
|
434
|
+
targetRobots: target?.robots ?? target?.html?.robots ?? null,
|
|
435
|
+
targetXRobots: target?.xRobotsTag ?? target?.html?.xRobotsTag ?? null,
|
|
436
|
+
};
|
|
437
|
+
}));
|
|
438
|
+
// Pagination: next targets, canonical relationships, indexing directives
|
|
439
|
+
// and whether the paginated URLs exist without JavaScript.
|
|
440
|
+
const noindexOf = (page) => /\b(noindex|none)\b/i.test([
|
|
441
|
+
page.xRobotsTag,
|
|
442
|
+
...(page.robots ?? [])
|
|
443
|
+
.filter((r) => /^(robots|googlebot)$/i.test(r.name ?? ""))
|
|
444
|
+
.map((r) => r.content),
|
|
445
|
+
].join(","));
|
|
446
|
+
const paginated = pages.flatMap((page) => {
|
|
447
|
+
const original = page.data.pagination;
|
|
448
|
+
const fromRendered = rendered.get(page.key)?.pagination;
|
|
449
|
+
const pagination = original ?? fromRendered;
|
|
450
|
+
if (!pagination)
|
|
451
|
+
return [];
|
|
452
|
+
const next = pagination.next ? crawlUrl(pagination.next, scan) : null;
|
|
453
|
+
const canonical = page.data.canonicals[0]?.href
|
|
454
|
+
? crawlUrl(page.data.canonicals[0].href, scan)
|
|
455
|
+
: null;
|
|
456
|
+
const self = page.data.http.finalUrl;
|
|
457
|
+
const isFirst = !pagination.prev && !/[?&](page|p|pg|paged|pagina)=(\d+)/.test(self);
|
|
458
|
+
const numbered = (pagination.numbered ?? []).map((u) => crawlUrl(u, scan));
|
|
459
|
+
return [
|
|
460
|
+
{
|
|
461
|
+
url: page.key,
|
|
462
|
+
type: typeOf.get(page.key),
|
|
463
|
+
next,
|
|
464
|
+
prev: pagination.prev ? crawlUrl(pagination.prev, scan) : null,
|
|
465
|
+
numbered,
|
|
466
|
+
source: pagination.source,
|
|
467
|
+
renderedOnly: !original && !!fromRendered,
|
|
468
|
+
nextStatus: statusOf(next),
|
|
469
|
+
nextOutcome: outcomeOf(next),
|
|
470
|
+
nextObserved: !!next && httpOf(next) !== null,
|
|
471
|
+
numberedBroken: numbered.filter((u) => (statusOf(u) ?? 0) >= 400),
|
|
472
|
+
canonical,
|
|
473
|
+
canonicalRelation: !canonical
|
|
474
|
+
? "none"
|
|
475
|
+
: canonical === self || canonical === page.key
|
|
476
|
+
? "self"
|
|
477
|
+
: !isFirst &&
|
|
478
|
+
numbered.includes(canonical) &&
|
|
479
|
+
!/[?&](page|p|pg|paged|pagina)=/.test(canonical)
|
|
480
|
+
? "first-page"
|
|
481
|
+
: "other",
|
|
482
|
+
noindex: noindexOf(page.data),
|
|
483
|
+
isFirst,
|
|
484
|
+
loadMore: page.data.loadMore ?? [],
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
});
|
|
488
|
+
// Structured data per page: entities, documented rich-result properties
|
|
489
|
+
// and the declared offers against the visible page.
|
|
490
|
+
const structured = pages.flatMap((page) => {
|
|
491
|
+
const original = entities(page.data.structuredData ?? []);
|
|
492
|
+
const renderedBlocks = rendered.get(page.key)?.structuredData ?? [];
|
|
493
|
+
const renderedEntities = entities(renderedBlocks);
|
|
494
|
+
const found = original.length ? original : renderedEntities;
|
|
495
|
+
if (!found.length && !(page.data.structuredData ?? []).length)
|
|
496
|
+
return [];
|
|
497
|
+
const evidence = rendered.get(page.key);
|
|
498
|
+
const products = offerFacts(found);
|
|
499
|
+
return [
|
|
500
|
+
{
|
|
501
|
+
url: page.key,
|
|
502
|
+
types: [...new Set(found.map((e) => e.type))],
|
|
503
|
+
source: original.length
|
|
504
|
+
? "original-html"
|
|
505
|
+
: renderedEntities.length
|
|
506
|
+
? "rendered-only"
|
|
507
|
+
: "none",
|
|
508
|
+
parseErrors: (page.data.structuredData ?? []).filter((s) => s.parseValid === false).length,
|
|
509
|
+
structuralErrors: (page.data.structuredData ?? [])
|
|
510
|
+
.flatMap((s) => s.structuralErrors ?? [])
|
|
511
|
+
.slice(0, 20),
|
|
512
|
+
richResults: richResultChecks(found),
|
|
513
|
+
offers: compareOffers(products, {
|
|
514
|
+
prices: evidence?.prices?.length
|
|
515
|
+
? evidence.prices
|
|
516
|
+
: (page.data.prices ?? []),
|
|
517
|
+
availability: evidence?.availability ??
|
|
518
|
+
page.data.availability ?? {
|
|
519
|
+
inStock: false,
|
|
520
|
+
outOfStock: false,
|
|
521
|
+
inStockText: null,
|
|
522
|
+
outOfStockText: null,
|
|
523
|
+
},
|
|
524
|
+
headings: evidence?.headings ?? page.data.headings ?? [],
|
|
525
|
+
title: page.data.title ?? "",
|
|
526
|
+
}),
|
|
527
|
+
visibleSource: evidence?.prices?.length
|
|
528
|
+
? "rendered-dom"
|
|
529
|
+
: "original-html",
|
|
530
|
+
},
|
|
531
|
+
];
|
|
532
|
+
});
|
|
533
|
+
// Business and social identity: what the site says about itself, where it
|
|
534
|
+
// says it, and whether its public profiles answer.
|
|
535
|
+
const organizations = pages.flatMap((page) => entities(page.data.structuredData ?? [])
|
|
536
|
+
.filter((e) => e.types.some((t) => /(Organization|LocalBusiness|Store|Restaurant|Dentist|Physician|MedicalBusiness|ProfessionalService|Corporation|Hotel|Attorney|RealEstateAgent|AutoRepair|Gym|HealthClub|Person)$/.test(t)))
|
|
537
|
+
.map((e) => ({ page: page.key, type: e.type, node: e.node })));
|
|
538
|
+
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
|
|
539
|
+
const declaredNames = new Map();
|
|
540
|
+
const declaredPhones = new Map();
|
|
541
|
+
const declaredAddresses = new Map();
|
|
542
|
+
const declaredHours = new Map();
|
|
543
|
+
const sameAs = new Map();
|
|
544
|
+
const addTo = (map, key, page) => (map.get(key) ?? map.set(key, new Set()).get(key)).add(page);
|
|
545
|
+
for (const org of organizations) {
|
|
546
|
+
if (typeof org.node.name === "string")
|
|
547
|
+
addTo(declaredNames, org.node.name.trim(), org.page);
|
|
548
|
+
for (const phone of asList(org.node.telephone))
|
|
549
|
+
if (typeof phone === "string")
|
|
550
|
+
addTo(declaredPhones, phone, org.page);
|
|
551
|
+
for (const address of asList(org.node.address)) {
|
|
552
|
+
const text = typeof address === "string"
|
|
553
|
+
? address
|
|
554
|
+
: [
|
|
555
|
+
address?.streetAddress,
|
|
556
|
+
address?.postalCode,
|
|
557
|
+
address?.addressLocality,
|
|
558
|
+
address?.addressCountry,
|
|
559
|
+
]
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.join(", ");
|
|
562
|
+
if (text)
|
|
563
|
+
addTo(declaredAddresses, text, org.page);
|
|
564
|
+
}
|
|
565
|
+
for (const hours of [
|
|
566
|
+
...asList(org.node.openingHours),
|
|
567
|
+
...asList(org.node.openingHoursSpecification),
|
|
568
|
+
])
|
|
569
|
+
addTo(declaredHours, typeof hours === "string"
|
|
570
|
+
? hours
|
|
571
|
+
: `${asList(hours?.dayOfWeek)
|
|
572
|
+
.map((d) => String(d).replace(/^.*\//, ""))
|
|
573
|
+
.join(",")} ${hours?.opens ?? ""}-${hours?.closes ?? ""}`, org.page);
|
|
574
|
+
for (const url of asList(org.node.sameAs))
|
|
575
|
+
if (typeof url === "string")
|
|
576
|
+
addTo(sameAs, url, org.page);
|
|
577
|
+
}
|
|
578
|
+
const siteNames = new Set();
|
|
579
|
+
for (const page of pages)
|
|
580
|
+
for (const m of page.data.social ?? [])
|
|
581
|
+
if (m.property === "og:site_name" && m.content)
|
|
582
|
+
siteNames.add(m.content.trim());
|
|
583
|
+
const visiblePhones = new Map();
|
|
584
|
+
const visibleEmails = new Map();
|
|
585
|
+
for (const page of pages) {
|
|
586
|
+
const contacts = page.data.contacts ?? rendered.get(page.key)?.contacts;
|
|
587
|
+
for (const phone of contacts?.phones ?? [])
|
|
588
|
+
addTo(visiblePhones, phone, page.key);
|
|
589
|
+
for (const email of contacts?.emails ?? [])
|
|
590
|
+
addTo(visibleEmails, email, page.key);
|
|
591
|
+
}
|
|
592
|
+
const profiles = new Map();
|
|
593
|
+
const maps = new Map();
|
|
594
|
+
for (const page of pages)
|
|
595
|
+
for (const link of page.data.links) {
|
|
596
|
+
if (!link.href)
|
|
597
|
+
continue;
|
|
598
|
+
const social = socialPlatform(link.href);
|
|
599
|
+
if (social) {
|
|
600
|
+
const entry = profiles.get(link.href) ??
|
|
601
|
+
profiles
|
|
602
|
+
.set(link.href, {
|
|
603
|
+
url: link.href,
|
|
604
|
+
platform: social.platform,
|
|
605
|
+
blocksBots: social.blocksBots,
|
|
606
|
+
pages: new Set(),
|
|
607
|
+
locations: new Set(),
|
|
608
|
+
})
|
|
609
|
+
.get(link.href);
|
|
610
|
+
entry.pages.add(page.key);
|
|
611
|
+
entry.locations.add(link.location);
|
|
612
|
+
}
|
|
613
|
+
else if (isMapLink(link.href))
|
|
614
|
+
addTo(maps, link.href, page.key);
|
|
615
|
+
}
|
|
616
|
+
const profileRows = [...profiles.values()].map((p) => {
|
|
617
|
+
const http = resources.get(p.url)?.http;
|
|
618
|
+
const status = http?.status ?? null;
|
|
619
|
+
const outcome = http?.outcome ?? null;
|
|
620
|
+
return {
|
|
621
|
+
url: p.url,
|
|
622
|
+
platform: p.platform,
|
|
623
|
+
pages: p.pages.size,
|
|
624
|
+
locations: [...p.locations],
|
|
625
|
+
inSameAs: sameAs.has(p.url),
|
|
626
|
+
status,
|
|
627
|
+
outcome,
|
|
628
|
+
verification: !http
|
|
629
|
+
? "not-checked"
|
|
630
|
+
: outcome === "ok" && !p.blocksBots
|
|
631
|
+
? "ok"
|
|
632
|
+
: outcome === "ok"
|
|
633
|
+
? "responded"
|
|
634
|
+
: p.blocksBots
|
|
635
|
+
? "unverifiable"
|
|
636
|
+
: "broken",
|
|
637
|
+
note: p.blocksBots
|
|
638
|
+
? "This platform answers automated requests with blocks, logins or rate limits; a non-200 answer is not evidence that the profile is missing"
|
|
639
|
+
: null,
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
const platformVariants = new Map();
|
|
643
|
+
for (const row of profileRows)
|
|
644
|
+
platformVariants.set(row.platform, [
|
|
645
|
+
...(platformVariants.get(row.platform) ?? []),
|
|
646
|
+
row.url,
|
|
647
|
+
]);
|
|
648
|
+
const normalizedDeclaredPhones = new Set([...declaredPhones.keys()].map(digitsOf));
|
|
649
|
+
const business = {
|
|
650
|
+
names: {
|
|
651
|
+
structured: [...declaredNames].map(([name, pages]) => ({
|
|
652
|
+
name,
|
|
653
|
+
pages: pages.size,
|
|
654
|
+
})),
|
|
655
|
+
siteName: [...siteNames],
|
|
656
|
+
homepageTitle: home?.data.title ?? null,
|
|
657
|
+
},
|
|
658
|
+
phones: {
|
|
659
|
+
structured: [...declaredPhones].map(([phone, pages]) => ({
|
|
660
|
+
phone,
|
|
661
|
+
pages: pages.size,
|
|
662
|
+
})),
|
|
663
|
+
visible: [...visiblePhones]
|
|
664
|
+
.map(([phone, pages]) => ({ phone, pages: pages.size }))
|
|
665
|
+
.slice(0, 30),
|
|
666
|
+
visibleNotDeclared: [...visiblePhones.keys()]
|
|
667
|
+
.filter((p) => normalizedDeclaredPhones.size &&
|
|
668
|
+
!normalizedDeclaredPhones.has(digitsOf(p)))
|
|
669
|
+
.slice(0, 10),
|
|
670
|
+
declaredNotVisible: [...declaredPhones.keys()].filter((p) => ![...visiblePhones.keys()].some((v) => digitsOf(v) === digitsOf(p))),
|
|
671
|
+
},
|
|
672
|
+
emails: [...visibleEmails]
|
|
673
|
+
.map(([email, pages]) => ({ email, pages: pages.size }))
|
|
674
|
+
.slice(0, 20),
|
|
675
|
+
addresses: [...declaredAddresses].map(([address, pages]) => ({
|
|
676
|
+
address,
|
|
677
|
+
pages: pages.size,
|
|
678
|
+
})),
|
|
679
|
+
hours: [...declaredHours]
|
|
680
|
+
.map(([hours, pages]) => ({ hours, pages: pages.size }))
|
|
681
|
+
.slice(0, 30),
|
|
682
|
+
maps: [...maps].map(([url, pages]) => ({
|
|
683
|
+
url,
|
|
684
|
+
pages: pages.size,
|
|
685
|
+
status: statusOf(url),
|
|
686
|
+
})),
|
|
687
|
+
sameAs: [...sameAs].map(([url, pages]) => ({
|
|
688
|
+
url,
|
|
689
|
+
pages: pages.size,
|
|
690
|
+
linkedOnSite: profiles.has(url),
|
|
691
|
+
platform: socialPlatform(url)?.platform ?? null,
|
|
692
|
+
})),
|
|
693
|
+
profiles: profileRows,
|
|
694
|
+
platformVariants: [...platformVariants]
|
|
695
|
+
.filter(([, urls]) => urls.length > 1)
|
|
696
|
+
.map(([platform, urls]) => ({ platform, urls })),
|
|
697
|
+
scope: "Public website evidence only: JSON-LD organization data, visible contact details and linked profiles with their HTTP answers. No listing or search services were queried.",
|
|
698
|
+
};
|
|
699
|
+
return {
|
|
700
|
+
observations: [
|
|
701
|
+
{
|
|
702
|
+
kind: "link-graph",
|
|
703
|
+
key: "site",
|
|
704
|
+
data: {
|
|
705
|
+
pages: graph,
|
|
706
|
+
fewContextual,
|
|
707
|
+
vagueAnchors: vague,
|
|
708
|
+
coverage: {
|
|
709
|
+
pages: pages.length,
|
|
710
|
+
renderedPages: rendered.size,
|
|
711
|
+
scope: "observed crawl only; missing links and unknown depth are not proof of an orphan page",
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
{ kind: "anchors", key: "site", data: anchors },
|
|
716
|
+
{
|
|
717
|
+
kind: "duplicates",
|
|
718
|
+
key: "site",
|
|
719
|
+
data: {
|
|
720
|
+
exact: duplicates,
|
|
721
|
+
near: nearDuplicates,
|
|
722
|
+
collections: overlappingCollections,
|
|
723
|
+
coverage: {
|
|
724
|
+
pages: eligible.length,
|
|
725
|
+
candidatePairs,
|
|
726
|
+
pagesCompared,
|
|
727
|
+
comparisons,
|
|
728
|
+
collectionsCompared: itemSets.size,
|
|
729
|
+
collectionComparisons,
|
|
730
|
+
maxCharacters: 20000,
|
|
731
|
+
maxTokens: 4000,
|
|
732
|
+
textSampled: sampled,
|
|
733
|
+
method: "MinHash (64 hashes, 16 bands) selects candidate pairs; each candidate is compared exactly",
|
|
734
|
+
limited,
|
|
735
|
+
limitReason,
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
{ kind: "hreflang-links", key: "site", data: hreflangs },
|
|
740
|
+
{ kind: "language-links", key: "site", data: [...switches.values()] },
|
|
741
|
+
{ kind: "canonical-targets", key: "site", data: canonicals },
|
|
742
|
+
{ kind: "pagination", key: "site", data: paginated },
|
|
743
|
+
{ kind: "structured-data", key: "site", data: structured },
|
|
744
|
+
{ kind: "business-identity", key: "site", data: business },
|
|
745
|
+
],
|
|
746
|
+
};
|
|
747
|
+
}
|