@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.
Files changed (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. 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
+ }