@agentsean/analyzers 1.0.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 (158) hide show
  1. package/LICENSE +661 -0
  2. package/dist/ai-crawlers.d.ts +19 -0
  3. package/dist/ai-crawlers.d.ts.map +1 -0
  4. package/dist/ai-crawlers.js +111 -0
  5. package/dist/ai-crawlers.js.map +1 -0
  6. package/dist/audit.d.ts +21 -0
  7. package/dist/audit.d.ts.map +1 -0
  8. package/dist/audit.js +87 -0
  9. package/dist/audit.js.map +1 -0
  10. package/dist/catalogue-data.d.ts +4 -0
  11. package/dist/catalogue-data.d.ts.map +1 -0
  12. package/dist/catalogue-data.js +5103 -0
  13. package/dist/catalogue-data.js.map +1 -0
  14. package/dist/catalogue.d.ts +6 -0
  15. package/dist/catalogue.d.ts.map +1 -0
  16. package/dist/catalogue.js +25 -0
  17. package/dist/catalogue.js.map +1 -0
  18. package/dist/data/checks.json +5078 -0
  19. package/dist/data/lighthouse-13-insights.json +40 -0
  20. package/dist/data/schemaorg-vocab.json +89 -0
  21. package/dist/data/supported_types.json +71 -0
  22. package/dist/detectors/all.d.ts +30 -0
  23. package/dist/detectors/all.d.ts.map +1 -0
  24. package/dist/detectors/all.js +1619 -0
  25. package/dist/detectors/all.js.map +1 -0
  26. package/dist/detectors/canon.d.ts +2 -0
  27. package/dist/detectors/canon.d.ts.map +1 -0
  28. package/dist/detectors/canon.js +2 -0
  29. package/dist/detectors/canon.js.map +1 -0
  30. package/dist/detectors/conf.d.ts +2 -0
  31. package/dist/detectors/conf.d.ts.map +1 -0
  32. package/dist/detectors/conf.js +2 -0
  33. package/dist/detectors/conf.js.map +1 -0
  34. package/dist/detectors/crawl.d.ts +2 -0
  35. package/dist/detectors/crawl.d.ts.map +1 -0
  36. package/dist/detectors/crawl.js +2 -0
  37. package/dist/detectors/crawl.js.map +1 -0
  38. package/dist/detectors/cwv.d.ts +2 -0
  39. package/dist/detectors/cwv.d.ts.map +1 -0
  40. package/dist/detectors/cwv.js +2 -0
  41. package/dist/detectors/cwv.js.map +1 -0
  42. package/dist/detectors/direct.d.ts +2 -0
  43. package/dist/detectors/direct.d.ts.map +1 -0
  44. package/dist/detectors/direct.js +2 -0
  45. package/dist/detectors/direct.js.map +1 -0
  46. package/dist/detectors/dup.d.ts +2 -0
  47. package/dist/detectors/dup.d.ts.map +1 -0
  48. package/dist/detectors/dup.js +2 -0
  49. package/dist/detectors/dup.js.map +1 -0
  50. package/dist/detectors/gap.d.ts +2 -0
  51. package/dist/detectors/gap.d.ts.map +1 -0
  52. package/dist/detectors/gap.js +2 -0
  53. package/dist/detectors/gap.js.map +1 -0
  54. package/dist/detectors/href.d.ts +2 -0
  55. package/dist/detectors/href.d.ts.map +1 -0
  56. package/dist/detectors/href.js +2 -0
  57. package/dist/detectors/href.js.map +1 -0
  58. package/dist/detectors/img.d.ts +2 -0
  59. package/dist/detectors/img.d.ts.map +1 -0
  60. package/dist/detectors/img.js +2 -0
  61. package/dist/detectors/img.js.map +1 -0
  62. package/dist/detectors/index.d.ts +27 -0
  63. package/dist/detectors/index.d.ts.map +1 -0
  64. package/dist/detectors/index.js +27 -0
  65. package/dist/detectors/index.js.map +1 -0
  66. package/dist/detectors/js.d.ts +2 -0
  67. package/dist/detectors/js.d.ts.map +1 -0
  68. package/dist/detectors/js.js +2 -0
  69. package/dist/detectors/js.js.map +1 -0
  70. package/dist/detectors/link.d.ts +2 -0
  71. package/dist/detectors/link.d.ts.map +1 -0
  72. package/dist/detectors/link.js +2 -0
  73. package/dist/detectors/link.js.map +1 -0
  74. package/dist/detectors/log.d.ts +2 -0
  75. package/dist/detectors/log.d.ts.map +1 -0
  76. package/dist/detectors/log.js +2 -0
  77. package/dist/detectors/log.js.map +1 -0
  78. package/dist/detectors/mig.d.ts +2 -0
  79. package/dist/detectors/mig.d.ts.map +1 -0
  80. package/dist/detectors/mig.js +2 -0
  81. package/dist/detectors/mig.js.map +1 -0
  82. package/dist/detectors/mob.d.ts +2 -0
  83. package/dist/detectors/mob.d.ts.map +1 -0
  84. package/dist/detectors/mob.js +2 -0
  85. package/dist/detectors/mob.js.map +1 -0
  86. package/dist/detectors/onp.d.ts +2 -0
  87. package/dist/detectors/onp.d.ts.map +1 -0
  88. package/dist/detectors/onp.js +2 -0
  89. package/dist/detectors/onp.js.map +1 -0
  90. package/dist/detectors/page.d.ts +2 -0
  91. package/dist/detectors/page.d.ts.map +1 -0
  92. package/dist/detectors/page.js +2 -0
  93. package/dist/detectors/page.js.map +1 -0
  94. package/dist/detectors/param.d.ts +2 -0
  95. package/dist/detectors/param.d.ts.map +1 -0
  96. package/dist/detectors/param.js +2 -0
  97. package/dist/detectors/param.js.map +1 -0
  98. package/dist/detectors/perf.d.ts +2 -0
  99. package/dist/detectors/perf.d.ts.map +1 -0
  100. package/dist/detectors/perf.js +2 -0
  101. package/dist/detectors/perf.js.map +1 -0
  102. package/dist/detectors/predicates.d.ts +22 -0
  103. package/dist/detectors/predicates.d.ts.map +1 -0
  104. package/dist/detectors/predicates.js +142 -0
  105. package/dist/detectors/predicates.js.map +1 -0
  106. package/dist/detectors/resp.d.ts +2 -0
  107. package/dist/detectors/resp.d.ts.map +1 -0
  108. package/dist/detectors/resp.js +2 -0
  109. package/dist/detectors/resp.js.map +1 -0
  110. package/dist/detectors/robots.d.ts +2 -0
  111. package/dist/detectors/robots.d.ts.map +1 -0
  112. package/dist/detectors/robots.js +2 -0
  113. package/dist/detectors/robots.js.map +1 -0
  114. package/dist/detectors/sd.d.ts +2 -0
  115. package/dist/detectors/sd.d.ts.map +1 -0
  116. package/dist/detectors/sd.js +2 -0
  117. package/dist/detectors/sd.js.map +1 -0
  118. package/dist/detectors/sec.d.ts +2 -0
  119. package/dist/detectors/sec.d.ts.map +1 -0
  120. package/dist/detectors/sec.js +2 -0
  121. package/dist/detectors/sec.js.map +1 -0
  122. package/dist/detectors/smap.d.ts +2 -0
  123. package/dist/detectors/smap.d.ts.map +1 -0
  124. package/dist/detectors/smap.js +2 -0
  125. package/dist/detectors/smap.js.map +1 -0
  126. package/dist/detectors/soft404.d.ts +2 -0
  127. package/dist/detectors/soft404.d.ts.map +1 -0
  128. package/dist/detectors/soft404.js +2 -0
  129. package/dist/detectors/soft404.js.map +1 -0
  130. package/dist/detectors/thin.d.ts +2 -0
  131. package/dist/detectors/thin.d.ts.map +1 -0
  132. package/dist/detectors/thin.js +2 -0
  133. package/dist/detectors/thin.js.map +1 -0
  134. package/dist/index.d.ts +10 -0
  135. package/dist/index.d.ts.map +1 -0
  136. package/dist/index.js +9 -0
  137. package/dist/index.js.map +1 -0
  138. package/dist/openseo-seed.d.ts +16 -0
  139. package/dist/openseo-seed.d.ts.map +1 -0
  140. package/dist/openseo-seed.js +157 -0
  141. package/dist/openseo-seed.js.map +1 -0
  142. package/dist/priority.d.ts +8 -0
  143. package/dist/priority.d.ts.map +1 -0
  144. package/dist/priority.js +53 -0
  145. package/dist/priority.js.map +1 -0
  146. package/dist/schemaorg.d.ts +28 -0
  147. package/dist/schemaorg.d.ts.map +1 -0
  148. package/dist/schemaorg.js +198 -0
  149. package/dist/schemaorg.js.map +1 -0
  150. package/dist/score.d.ts +10 -0
  151. package/dist/score.d.ts.map +1 -0
  152. package/dist/score.js +177 -0
  153. package/dist/score.js.map +1 -0
  154. package/dist/types.d.ts +109 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +2 -0
  157. package/dist/types.js.map +1 -0
  158. package/package.json +51 -0
@@ -0,0 +1,1619 @@
1
+ import { nearDuplicate } from "@agentsean/crawler";
2
+ import { googleSupportedTypes, validateJsonLdBlocks, flattenTypes, } from "../schemaorg.js";
3
+ import { checksByCategory } from "../catalogue.js";
4
+ import { analyzeAiRobots } from "../ai-crawlers.js";
5
+ import { absCanonical, byUrl, gini, hasReq, hops, htmlPages, inRange, isIndexable, nofollowOf, noindexOf, originPath, push, robotsTokens, selfCanonical, stripSlash, } from "./predicates.js";
6
+ const GENERIC_ANCHORS = new Set([
7
+ "click here",
8
+ "read more",
9
+ "here",
10
+ "this",
11
+ "learn more",
12
+ "more",
13
+ ]);
14
+ const FACET_PARAMS = /[?&](sort|order|orderby|view|display|limit|per_page|filter|color|size|price)=/i;
15
+ const SESSION_RE = /[?&](sid|sessionid|phpsessid|jsessionid)=/i;
16
+ const DATE_RE = /[?&](date|month|year|day)=/i;
17
+ export function detectResp(ctx) {
18
+ const out = [];
19
+ const pages = ctx.crawl.pages;
20
+ const map = byUrl(ctx);
21
+ push(out, "RESP.5XX_INTERNAL", pages
22
+ .filter((p) => p.isInternal && inRange(p.statusCode, 500, 599))
23
+ .map((p) => p.url), {});
24
+ push(out, "RESP.4XX_INTERNAL", pages
25
+ .filter((p) => p.isInternal && inRange(p.statusCode, 400, 499) && p.statusCode !== 429)
26
+ .map((p) => p.url), {});
27
+ push(out, "RESP.429", pages.filter((p) => p.statusCode === 429).map((p) => p.url), {});
28
+ push(out, "RESP.NO_RESPONSE", pages.filter((p) => p.statusCode === null).map((p) => p.url), {});
29
+ push(out, "RESP.REDIRECT_CHAIN", pages.filter((p) => hops(p) >= 2).map((p) => p.url), {});
30
+ push(out, "RESP.REDIRECT_CHAIN_LONG", pages.filter((p) => hops(p) >= 5).map((p) => p.url), {});
31
+ push(out, "RESP.REDIRECT_LOOP", pages.filter((p) => p.redirectLoop).map((p) => p.url), {});
32
+ const internal3xx = [];
33
+ for (const p of htmlPages(ctx)) {
34
+ for (const l of p.extract?.links ?? []) {
35
+ if (!l.isInternal)
36
+ continue;
37
+ const t = map.get(l.absUrl);
38
+ if (t && inRange(t.statusCode, 300, 399))
39
+ internal3xx.push(p.url);
40
+ }
41
+ }
42
+ push(out, "RESP.INTERNAL_3XX", internal3xx, {});
43
+ push(out, "RESP.302_PERMANENT", pages.filter((p) => p.statusCode === 302 || p.statusCode === 307).map((p) => p.url), {}, 0.5);
44
+ push(out, "RESP.META_REFRESH", htmlPages(ctx)
45
+ .filter((p) => Boolean(p.extract?.metaRefresh))
46
+ .map((p) => p.url), {});
47
+ push(out, "RESP.HTTP_REFRESH", pages.filter((p) => Boolean(p.headers.refresh)).map((p) => p.url), {});
48
+ push(out, "RESP.JS_REDIRECT", pages
49
+ .filter((p) => p.rendered &&
50
+ p.renderedExtract &&
51
+ p.finalUrl !== p.url &&
52
+ hops(p) === 0 &&
53
+ !p.extract?.metaRefresh)
54
+ .map((p) => p.url), {});
55
+ push(out, "RESP.REDIRECT_TO_404", pages
56
+ .filter((p) => hops(p) >= 1 && inRange(p.statusCode, 400, 599))
57
+ .map((p) => p.url), {});
58
+ push(out, "RESP.REDIRECT_TO_NOINDEX", pages.filter((p) => hops(p) >= 1 && noindexOf(p)).map((p) => p.url), {});
59
+ const toHome = pages.filter((p) => hops(p) >= 1 && originPath(p.finalUrl) === "/");
60
+ if (toHome.length >= 20)
61
+ push(out, "RESP.SOFT_REDIRECT_HOME", toHome.map((p) => p.url), { count: toHome.length });
62
+ push(out, "RESP.MIXED_PROTOCOL_CHAIN", pages
63
+ .filter((p) => {
64
+ const urls = p.redirectChain.map((h) => h.url);
65
+ const proto = urls.some((u) => u.startsWith("http:")) &&
66
+ urls.some((u) => u.startsWith("https:"));
67
+ const host = new Set(urls.map((u) => {
68
+ try {
69
+ return new URL(u).hostname;
70
+ }
71
+ catch {
72
+ return u;
73
+ }
74
+ })).size > 1;
75
+ return proto && host;
76
+ })
77
+ .map((p) => p.url), {});
78
+ const ext4 = [];
79
+ const ext5 = [];
80
+ for (const p of pages) {
81
+ if (p.isInternal)
82
+ continue;
83
+ if (inRange(p.statusCode, 400, 499))
84
+ ext4.push(p.url);
85
+ if (inRange(p.statusCode, 500, 599))
86
+ ext5.push(p.url);
87
+ }
88
+ push(out, "RESP.EXT_4XX", ext4, {});
89
+ push(out, "RESP.EXT_5XX", ext5, {});
90
+ push(out, "RESP.BLOCKED_ROBOTS", pages
91
+ .filter((p) => p.isInternal && !p.robotsAllowed && p.inlinkCount > 0)
92
+ .map((p) => p.url), {});
93
+ push(out, "RESP.BLOCKED_RESOURCE", pages
94
+ .filter((p) => p.extract?.scripts.some((s) => s.absUrl && !pageAllowed(ctx, s.absUrl)))
95
+ .map((p) => p.url), {});
96
+ push(out, "RESP.SLOW_TTFB", pages.filter((p) => p.ttfbMs > 600).map((p) => p.url), {});
97
+ push(out, "RESP.NO_304", pages.filter((p) => p.supports304 === false).map((p) => p.url), {});
98
+ push(out, "RESP.BAD_CONTENT_TYPE", pages
99
+ .filter((p) => p.html !== null && p.contentType !== "" && !/html|xml/i.test(p.contentType))
100
+ .map((p) => p.url), {});
101
+ push(out, "RESP.OVER_2MB_HTML", pages
102
+ .filter((p) => p.html !== null && p.decodedBytes > 2_097_152)
103
+ .map((p) => p.url), {});
104
+ push(out, "RESP.OVER_15MB", pages.filter((p) => p.decodedBytes > 15_728_640).map((p) => p.url), {});
105
+ return out;
106
+ }
107
+ function pageAllowed(ctx, url) {
108
+ if (!ctx.crawl.robots)
109
+ return true;
110
+ const p = ctx.crawl.pages.find((x) => x.url === url);
111
+ if (p)
112
+ return p.robotsAllowed;
113
+ return true;
114
+ }
115
+ export function detectCanon(ctx) {
116
+ const out = [];
117
+ const pages = htmlPages(ctx);
118
+ const map = byUrl(ctx);
119
+ const sitemap = sitemapSet(ctx);
120
+ push(out, "CANON.MISSING", pages
121
+ .filter((p) => p.statusCode === 200 &&
122
+ (p.extract?.canonicalHtml.length ?? 0) === 0 &&
123
+ (p.extract?.canonicalHeader.length ?? 0) === 0)
124
+ .map((p) => p.url), {});
125
+ push(out, "CANON.MULTIPLE_CONFLICT", pages
126
+ .filter((p) => {
127
+ const vals = new Set([
128
+ ...(p.extract?.canonicalHtml ?? []),
129
+ ...(p.extract?.canonicalHeader ?? []),
130
+ ].map((c) => absMaybe(c, p.finalUrl)));
131
+ return vals.size > 1;
132
+ })
133
+ .map((p) => p.url), {});
134
+ push(out, "CANON.MULTIPLE_SAME", pages.filter((p) => (p.extract?.canonicalHtml.length ?? 0) > 1).map((p) => p.url), {});
135
+ push(out, "CANON.HTML_HEADER_MISMATCH", pages
136
+ .filter((p) => {
137
+ const h = p.extract?.canonicalHtml[0];
138
+ const d = p.extract?.canonicalHeader[0];
139
+ return Boolean(h && d && absMaybe(h, p.finalUrl) !== absMaybe(d, p.finalUrl));
140
+ })
141
+ .map((p) => p.url), {});
142
+ push(out, "CANON.OUTSIDE_HEAD", pages.filter((p) => p.extract?.canonicalOutsideHead).map((p) => p.url), {});
143
+ push(out, "CANON.HEAD_BROKEN_EARLY", pages.filter((p) => p.extract?.headBrokenEarly).map((p) => p.url), {});
144
+ push(out, "CANON.RELATIVE", pages
145
+ .filter((p) => (p.extract?.canonicalHtml[0] ?? "").length > 0 &&
146
+ !/^https?:/i.test(p.extract?.canonicalHtml[0] ?? ""))
147
+ .map((p) => p.url), {});
148
+ push(out, "CANON.FRAGMENT", pages
149
+ .filter((p) => (p.extract?.canonicalHtml[0] ?? "").includes("#"))
150
+ .map((p) => p.url), {});
151
+ push(out, "CANON.EMPTY_MALFORMED", pages
152
+ .filter((p) => p.extract?.canonicalHtml.some((c) => !c.trim() || !absMaybe(c, p.finalUrl)))
153
+ .map((p) => p.url), {});
154
+ push(out, "CANON.TO_NON_200", pages
155
+ .filter((p) => {
156
+ const c = absCanonical(p);
157
+ const t = c ? map.get(c) : undefined;
158
+ return Boolean(t && t.statusCode !== null && t.statusCode >= 400);
159
+ })
160
+ .map((p) => p.url), {});
161
+ push(out, "CANON.TO_REDIRECT", pages
162
+ .filter((p) => {
163
+ const c = absCanonical(p);
164
+ const t = c ? map.get(c) : undefined;
165
+ return Boolean(t && inRange(t.statusCode, 300, 399));
166
+ })
167
+ .map((p) => p.url), {});
168
+ push(out, "CANON.TO_NOINDEX", pages
169
+ .filter((p) => {
170
+ const c = absCanonical(p);
171
+ const t = c ? map.get(c) : undefined;
172
+ return Boolean(t && noindexOf(t));
173
+ })
174
+ .map((p) => p.url), {});
175
+ push(out, "CANON.TO_DISALLOWED", pages
176
+ .filter((p) => {
177
+ const c = absCanonical(p);
178
+ const t = c ? map.get(c) : undefined;
179
+ return Boolean(t && !t.robotsAllowed);
180
+ })
181
+ .map((p) => p.url), {});
182
+ push(out, "CANON.CHAIN", pages
183
+ .filter((p) => {
184
+ const c = absCanonical(p);
185
+ if (!c)
186
+ return false;
187
+ const t = map.get(c);
188
+ if (!t)
189
+ return false;
190
+ const cc = absCanonical(t);
191
+ return Boolean(cc && stripSlash(cc) !== stripSlash(t.finalUrl));
192
+ })
193
+ .map((p) => p.url), {});
194
+ push(out, "CANON.LOOP", pages
195
+ .filter((p) => {
196
+ const a = absCanonical(p);
197
+ if (!a)
198
+ return false;
199
+ const t = map.get(a);
200
+ return Boolean(t &&
201
+ stripSlash(absCanonical(t) ?? "") === stripSlash(p.finalUrl) &&
202
+ stripSlash(a) !== stripSlash(p.finalUrl));
203
+ })
204
+ .map((p) => p.url), {});
205
+ push(out, "CANON.TO_HTTP", pages
206
+ .filter((p) => p.finalUrl.startsWith("https:") &&
207
+ (absCanonical(p) ?? "").startsWith("http:"))
208
+ .map((p) => p.url), {});
209
+ push(out, "CANON.TO_HOMEPAGE", pages
210
+ .filter((p) => {
211
+ const c = absCanonical(p);
212
+ if (!c)
213
+ return false;
214
+ try {
215
+ return new URL(c).pathname === "/" && new URL(p.finalUrl).pathname !== "/";
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ })
221
+ .map((p) => p.url), {});
222
+ push(out, "CANON.CANONICALISED", pages.filter((p) => absCanonical(p) && !selfCanonical(p)).map((p) => p.url), {});
223
+ push(out, "CANON.CANONICALISED_IN_SITEMAP", pages
224
+ .filter((p) => absCanonical(p) && !selfCanonical(p) && sitemap.has(p.url))
225
+ .map((p) => p.url), {});
226
+ push(out, "CANON.NOINDEX_AND_CANONICAL", pages
227
+ .filter((p) => noindexOf(p) && absCanonical(p) && !selfCanonical(p))
228
+ .map((p) => p.url), {});
229
+ push(out, "CANON.RENDER_MISMATCH", pages
230
+ .filter((p) => p.renderedExtract &&
231
+ (p.extract?.canonicalHtml[0] ?? "") !==
232
+ (p.renderedExtract.canonicalHtml[0] ?? ""))
233
+ .map((p) => p.url), {});
234
+ push(out, "CANON.RENDER_ONLY", pages
235
+ .filter((p) => p.renderedExtract &&
236
+ (p.extract?.canonicalHtml.length ?? 0) === 0 &&
237
+ p.renderedExtract.canonicalHtml.length > 0)
238
+ .map((p) => p.url), {});
239
+ push(out, "CANON.UNLINKED", pages
240
+ .filter((p) => {
241
+ const c = absCanonical(p);
242
+ const t = c ? map.get(c) : undefined;
243
+ return Boolean(t && t.inlinkCount === 0 && !selfCanonical(p));
244
+ })
245
+ .map((p) => p.url), {});
246
+ push(out, "CANON.CROSS_DOMAIN", pages
247
+ .filter((p) => {
248
+ const c = absCanonical(p);
249
+ if (!c)
250
+ return false;
251
+ try {
252
+ return new URL(c).hostname !== new URL(p.finalUrl).hostname;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ })
258
+ .map((p) => p.url), {});
259
+ push(out, "CANON.PARAM_VARIANT", pages
260
+ .filter((p) => /[?&](utm_|gclid|fbclid|msclkid|_ga)/i.test(p.url) && selfCanonical(p))
261
+ .map((p) => p.url), {});
262
+ if (ctx.crawl.originProbe.wwwSplit)
263
+ push(out, "CANON.WWW_SPLIT", [ctx.crawl.origin], {});
264
+ if (ctx.crawl.originProbe.trailingSlashSplit)
265
+ push(out, "CANON.TRAILING_SLASH_SPLIT", [ctx.crawl.origin], {});
266
+ push(out, "CANON.CASE_SPLIT", pages
267
+ .filter((p) => p.url !== p.url.toLowerCase() && p.statusCode === 200)
268
+ .map((p) => p.url), {});
269
+ push(out, "CANON.INDEX_HTML_SPLIT", pages
270
+ .filter((p) => /\/index\.html?$/i.test(p.url) && p.statusCode === 200)
271
+ .map((p) => p.url), {});
272
+ return out;
273
+ }
274
+ function absMaybe(href, base) {
275
+ try {
276
+ return new URL(href, base).href;
277
+ }
278
+ catch {
279
+ return href;
280
+ }
281
+ }
282
+ function sitemapSet(ctx) {
283
+ return new Set(ctx.crawl.sitemaps.flatMap((s) => s.urls.map((u) => u.loc)));
284
+ }
285
+ export function detectRobots(ctx) {
286
+ const out = [];
287
+ const r = ctx.crawl.robots;
288
+ const origin = ctx.crawl.origin;
289
+ if (!r || r.statusCode === 404 || (r.mode === "allow-all" && (r.raw ?? "") === "")) {
290
+ push(out, "ROBOTS.MISSING", [origin], { status: r?.statusCode ?? null });
291
+ }
292
+ if (r && (r.statusCode ?? 0) >= 500)
293
+ push(out, "ROBOTS.5XX", [origin], { status: r.statusCode });
294
+ if (r && r.redirectHops > 0)
295
+ push(out, "ROBOTS.REDIRECTS", [origin], { hops: r.redirectHops });
296
+ if (r &&
297
+ r.contentType &&
298
+ !/text\/plain/i.test(r.contentType) &&
299
+ r.statusCode === 200) {
300
+ push(out, "ROBOTS.WRONG_CONTENT_TYPE", [origin], { contentType: r.contentType });
301
+ }
302
+ if (r && r.bytes > 512_000)
303
+ push(out, "ROBOTS.OVER_500KIB", [origin], { bytes: r.bytes });
304
+ if (r?.disallowAll)
305
+ push(out, "ROBOTS.DISALLOW_ALL", [origin], {});
306
+ if (r?.hasNoindexDirective)
307
+ push(out, "ROBOTS.NOINDEX_DIRECTIVE", [origin], {});
308
+ if (r?.hasCrawlDelay)
309
+ push(out, "ROBOTS.CRAWL_DELAY", [origin], { crawlDelay: r.crawlDelay });
310
+ if (r && r.sitemaps.length === 0 && r.statusCode === 200)
311
+ push(out, "ROBOTS.NO_SITEMAP_LINE", [origin], {});
312
+ if (r) {
313
+ for (const sm of r.sitemaps) {
314
+ if (!/^https?:\/\//i.test(sm))
315
+ push(out, "ROBOTS.SITEMAP_RELATIVE", [origin], { sitemap: sm });
316
+ }
317
+ }
318
+ const sm404 = ctx.crawl.sitemaps.filter((s) => s.statusCode === 404);
319
+ if (sm404.length)
320
+ push(out, "ROBOTS.SITEMAP_LINE_404", sm404.map((s) => s.url), {});
321
+ if (r && r.unknownDirectives.length)
322
+ push(out, "ROBOTS.SYNTAX_ERROR", [origin], {
323
+ lines: r.unknownDirectives.slice(0, 10),
324
+ });
325
+ if (r &&
326
+ r.groups.some((g) => g.agents.includes("*")) &&
327
+ r.groups.some((g) => g.agents.some((a) => a.includes("googlebot")))) {
328
+ const starIdx = r.raw.toLowerCase().indexOf("user-agent: *");
329
+ const gIdx = r.raw.toLowerCase().indexOf("user-agent: googlebot");
330
+ if (starIdx >= 0 && gIdx > starIdx)
331
+ push(out, "ROBOTS.UA_ORDER_TRAP", [origin], {});
332
+ }
333
+ if (r) {
334
+ const ai = analyzeAiRobots(r.raw);
335
+ if (ai.blockedTraining.length) {
336
+ push(out, "ROBOTS.BLOCKS_AI_UNINTENDED", [origin], { bots: ai.blockedTraining });
337
+ }
338
+ if (ai.blockedCitation.length) {
339
+ push(out, "ROBOTS.BLOCKS_CITATION_CRAWLER", [origin], {
340
+ bots: ai.blockedCitation,
341
+ });
342
+ }
343
+ if (ai.conflatesTrainingAndCitation) {
344
+ push(out, "ROBOTS.CONFLATES_TRAINING_CITATION", [origin], {
345
+ training: ai.blockedTraining,
346
+ citation: ai.blockedCitation,
347
+ });
348
+ }
349
+ }
350
+ if (ctx.previousRobotsHash && r && ctx.previousRobotsHash !== r.hash) {
351
+ push(out, "ROBOTS.CHANGED", [origin], {
352
+ previous: ctx.previousRobotsHash,
353
+ current: r.hash,
354
+ });
355
+ }
356
+ const blockedCss = htmlPages(ctx).filter((p) => (p.extract?.stylesheets.some((s) => s.absUrl && !pageAllowed(ctx, s.absUrl)) ??
357
+ false) ||
358
+ (p.extract?.scripts.some((s) => s.absUrl && !pageAllowed(ctx, s.absUrl)) ??
359
+ false));
360
+ push(out, "ROBOTS.BLOCKS_CSS_JS", blockedCss.map((p) => p.url), {});
361
+ push(out, "ROBOTS.BLOCKS_IMAGES", htmlPages(ctx)
362
+ .filter((p) => p.extract?.images.some((i) => i.absUrl && !pageAllowed(ctx, i.absUrl)))
363
+ .map((p) => p.url), {});
364
+ const smBlocked = [...sitemapSet(ctx)].filter((u) => {
365
+ const p = ctx.crawl.pages.find((x) => x.url === u);
366
+ return p ? !p.robotsAllowed : false;
367
+ });
368
+ push(out, "ROBOTS.BLOCKS_SITEMAP_URLS", smBlocked, {});
369
+ return out;
370
+ }
371
+ export function detectDirect(ctx) {
372
+ const out = [];
373
+ const pages = htmlPages(ctx);
374
+ const sitemap = sitemapSet(ctx);
375
+ if (hasReq(ctx, "gsc") && ctx.gsc?.clicksByUrl) {
376
+ push(out, "DIRECT.NOINDEX_INDEXABLE_TEMPLATE", pages
377
+ .filter((p) => noindexOf(p) && (ctx.gsc?.clicksByUrl?.[p.url] ?? 0) > 0)
378
+ .map((p) => p.url), {});
379
+ }
380
+ push(out, "DIRECT.NOINDEX_UNEXPECTED", pages.filter((p) => noindexOf(p) && sitemap.has(p.url)).map((p) => p.url), {});
381
+ push(out, "DIRECT.NOINDEX_HTML_HEADER_MISMATCH", pages
382
+ .filter((p) => {
383
+ const html = (p.extract?.robotsMeta.join(",") ?? "")
384
+ .toLowerCase()
385
+ .includes("noindex");
386
+ const header = (p.extract?.xRobotsTag ?? "").toLowerCase().includes("noindex");
387
+ return html !== header && (html || header);
388
+ })
389
+ .map((p) => p.url), {});
390
+ push(out, "DIRECT.MULTIPLE_NOINDEX", pages
391
+ .filter((p) => (p.extract?.robotsMeta.filter((m) => /noindex/i.test(m)).length ?? 0) > 1)
392
+ .map((p) => p.url), {});
393
+ push(out, "DIRECT.OUTSIDE_HEAD", pages
394
+ .filter((p) => (p.extract?.robotsMeta.length ?? 0) > 0 && !p.extract?.robotsMetaInHead)
395
+ .map((p) => p.url), {});
396
+ push(out, "DIRECT.RENDER_ONLY_NOINDEX", pages
397
+ .filter((p) => p.renderedExtract &&
398
+ noindexOf(p) &&
399
+ !/\bnoindex\b/i.test(p.renderedExtract.robotsMeta.join(",")))
400
+ .map((p) => p.url), {});
401
+ push(out, "DIRECT.JS_ADDED_NOINDEX", pages
402
+ .filter((p) => p.renderedExtract &&
403
+ !noindexOf(p) &&
404
+ /\bnoindex\b/i.test(p.renderedExtract.robotsMeta.join(",")))
405
+ .map((p) => p.url), {});
406
+ push(out, "DIRECT.NOFOLLOW_SITEWIDE", pages
407
+ .filter((p) => nofollowOf(p) && (p.extract?.links.some((l) => l.isInternal) ?? false))
408
+ .map((p) => p.url), {});
409
+ push(out, "DIRECT.NONE", pages.filter((p) => robotsTokens(p).includes("none")).map((p) => p.url), {});
410
+ push(out, "DIRECT.UNAVAILABLE_AFTER_PAST", pages
411
+ .filter((p) => robotsTokens(p).some((t) => t.startsWith("unavailable_after")))
412
+ .map((p) => p.url), {}, 0.6);
413
+ if (hasReq(ctx, "gsc")) {
414
+ push(out, "DIRECT.NOSNIPPET_ON_MONEY_PAGE", pages
415
+ .filter((p) => robotsTokens(p).includes("nosnippet") &&
416
+ (ctx.gsc?.clicksByUrl?.[p.url] ?? 0) > 0)
417
+ .map((p) => p.url), {});
418
+ }
419
+ push(out, "DIRECT.MAX_SNIPPET_ZERO", pages.filter((p) => robotsTokens(p).includes("max-snippet:0")).map((p) => p.url), {});
420
+ push(out, "DIRECT.NO_MAX_IMAGE_PREVIEW", pages
421
+ .filter((p) => p.extract?.jsonLd.some((j) => /Article|Product/i.test(JSON.stringify(j.parsed))) && !robotsTokens(p).some((t) => t.includes("max-image-preview")))
422
+ .map((p) => p.url), {});
423
+ push(out, "DIRECT.NOIMAGEINDEX", pages.filter((p) => robotsTokens(p).includes("noimageindex")).map((p) => p.url), {});
424
+ const known = new Set([
425
+ "index",
426
+ "noindex",
427
+ "follow",
428
+ "nofollow",
429
+ "none",
430
+ "noarchive",
431
+ "nosnippet",
432
+ "noimageindex",
433
+ "notranslate",
434
+ "unavailable_after",
435
+ "max-snippet",
436
+ "max-image-preview",
437
+ "max-video-preview",
438
+ "all",
439
+ "noodp",
440
+ ]);
441
+ push(out, "DIRECT.INVALID_VALUE", pages
442
+ .filter((p) => robotsTokens(p).some((t) => t && !known.has(t.split(":")[0] ?? t)))
443
+ .map((p) => p.url), {});
444
+ push(out, "DIRECT.DISALLOW_MASKS_NOINDEX", pages.filter((p) => !p.robotsAllowed && noindexOf(p)).map((p) => p.url), {});
445
+ return out;
446
+ }
447
+ export function detectSmap(ctx) {
448
+ const out = [];
449
+ const sms = ctx.crawl.sitemaps;
450
+ const origin = ctx.crawl.origin;
451
+ if (sms.every((s) => s.statusCode === 404 || s.statusCode === null)) {
452
+ push(out, "SMAP.MISSING", [origin], {});
453
+ }
454
+ for (const sm of sms) {
455
+ if (sm.urls.length > 50_000)
456
+ push(out, "SMAP.OVER_50K", [sm.url], { count: sm.urls.length });
457
+ if (sm.bytes > 50 * 1024 * 1024)
458
+ push(out, "SMAP.OVER_50MB", [sm.url], { bytes: sm.bytes });
459
+ if (sm.malformed)
460
+ push(out, "SMAP.MALFORMED_XML", [sm.url], { error: sm.error });
461
+ if (sm.namespace &&
462
+ sm.namespace !== "http://www.sitemaps.org/schemas/sitemap/0.9") {
463
+ push(out, "SMAP.WRONG_NAMESPACE", [sm.url], { namespace: sm.namespace });
464
+ }
465
+ if (sm.statusCode && sm.statusCode !== 200 && sm.statusCode !== 404)
466
+ push(out, "SMAP.NON_200", [sm.url], { status: sm.statusCode });
467
+ if (sm.gzipBroken)
468
+ push(out, "SMAP.GZIP_BROKEN", [sm.url], {});
469
+ if (sm.relativeLocs.length)
470
+ push(out, "SMAP.RELATIVE_URLS", [sm.url], {
471
+ sample: sm.relativeLocs.slice(0, 5),
472
+ });
473
+ const host = (() => {
474
+ try {
475
+ return new URL(sm.url).host;
476
+ }
477
+ catch {
478
+ return "";
479
+ }
480
+ })();
481
+ const cross = sm.urls.filter((u) => {
482
+ try {
483
+ return new URL(u.loc).host !== host;
484
+ }
485
+ catch {
486
+ return false;
487
+ }
488
+ });
489
+ if (cross.length)
490
+ push(out, "SMAP.CROSS_HOST", [sm.url], { count: cross.length });
491
+ if (origin.startsWith("https:") && sm.urls.some((u) => u.loc.startsWith("http:"))) {
492
+ push(out, "SMAP.MIXED_PROTOCOL", [sm.url], {});
493
+ }
494
+ if (sm.urls.some((u) => /[&<>'"]/.test(u.loc) && !/&amp;|&lt;|&gt;|&apos;|&quot;/.test(u.loc))) {
495
+ push(out, "SMAP.UNESCAPED_ENTITIES", [sm.url], {});
496
+ }
497
+ if (sm.urls.length && sm.urls.every((u) => !u.lastmod))
498
+ push(out, "SMAP.LASTMOD_MISSING", [sm.url], {});
499
+ const badLast = sm.urls.filter((u) => u.lastmod && !/^\d{4}-\d{2}-\d{2}/.test(u.lastmod));
500
+ if (badLast.length)
501
+ push(out, "SMAP.LASTMOD_INVALID_FORMAT", [sm.url], {});
502
+ const future = sm.urls.filter((u) => u.lastmod && Date.parse(u.lastmod) > Date.now());
503
+ if (future.length)
504
+ push(out, "SMAP.LASTMOD_FUTURE", [sm.url], {});
505
+ const lastmods = new Set(sm.urls.map((u) => u.lastmod).filter(Boolean));
506
+ if (sm.urls.length > 50 && lastmods.size === 1)
507
+ push(out, "SMAP.LASTMOD_ALL_IDENTICAL", [sm.url], {});
508
+ const today = new Date().toISOString().slice(0, 10);
509
+ if (sm.urls.length > 10 &&
510
+ sm.urls.filter((u) => u.lastmod?.startsWith(today)).length / sm.urls.length > 0.9) {
511
+ push(out, "SMAP.LASTMOD_ALL_TODAY", [sm.url], {});
512
+ }
513
+ if (sm.urls.some((u) => u.priority))
514
+ push(out, "SMAP.PRIORITY_PRESENT", [sm.url], {});
515
+ if (sm.urls.some((u) => u.changefreq))
516
+ push(out, "SMAP.CHANGEFREQ_PRESENT", [sm.url], {});
517
+ if (sm.nestedIndex)
518
+ push(out, "SMAP.INDEX_NESTED_TOO_DEEP", [sm.url], {});
519
+ if (sm.urls.some((u) => u.videoValid === false))
520
+ push(out, "SMAP.VIDEO_EXT_INVALID", [sm.url], {});
521
+ }
522
+ const map = byUrl(ctx);
523
+ const locs = [...sitemapSet(ctx)];
524
+ push(out, "SMAP.CONTAINS_NON_200", locs.filter((u) => {
525
+ const p = map.get(u);
526
+ return p && p.statusCode !== 200 && p.statusCode !== null;
527
+ }), {});
528
+ push(out, "SMAP.CONTAINS_NOINDEX", locs.filter((u) => {
529
+ const p = map.get(u);
530
+ return p && noindexOf(p);
531
+ }), {});
532
+ push(out, "SMAP.CONTAINS_CANONICALISED", locs.filter((u) => {
533
+ const p = map.get(u);
534
+ return p && absCanonical(p) && !selfCanonical(p);
535
+ }), {});
536
+ push(out, "SMAP.CONTAINS_DISALLOWED", locs.filter((u) => {
537
+ const p = map.get(u);
538
+ return p && !p.robotsAllowed;
539
+ }), {});
540
+ const counts = new Map();
541
+ for (const sm of sms)
542
+ for (const u of sm.urls)
543
+ counts.set(u.loc, (counts.get(u.loc) ?? 0) + 1);
544
+ const dups = [...counts.entries()].filter(([, n]) => n > 1).map(([u]) => u);
545
+ push(out, "SMAP.DUP_ACROSS_SITEMAPS", dups, {});
546
+ if (sms.some((s) => s.statusCode === 200) &&
547
+ (ctx.crawl.robots?.sitemaps.length ?? 0) === 0) {
548
+ push(out, "SMAP.NOT_IN_ROBOTS", [origin], {});
549
+ }
550
+ if (hasReq(ctx, "gsc")) {
551
+ if (ctx.gsc && !ctx.gsc.submittedSitemaps?.length)
552
+ push(out, "SMAP.NOT_SUBMITTED_GSC", [origin], {});
553
+ if (ctx.gsc?.sitemapErrors)
554
+ push(out, "SMAP.GSC_ERRORS", [origin], {});
555
+ if (ctx.gsc?.lastDownloaded) {
556
+ const age = Date.now() - Date.parse(ctx.gsc.lastDownloaded);
557
+ if (age > 30 * 86400000)
558
+ push(out, "SMAP.STALE_DOWNLOAD", [origin], {});
559
+ }
560
+ }
561
+ const imageHeavy = htmlPages(ctx).filter((p) => (p.extract?.images.length ?? 0) > 5).length >
562
+ htmlPages(ctx).length / 2;
563
+ if (imageHeavy && sms.every((s) => s.urls.every((u) => u.images.length === 0))) {
564
+ push(out, "SMAP.IMAGE_EXT_MISSING", [origin], {});
565
+ }
566
+ push(out, "SMAP.LASTMOD_CONTRADICTS_CONTENT", [], {});
567
+ push(out, "SMAP.HREFLANG_MISMATCH", [], {});
568
+ return out;
569
+ }
570
+ export function detectCrawl(ctx) {
571
+ const out = [];
572
+ const pages = ctx.crawl.pages.filter((p) => p.isInternal);
573
+ push(out, "CRAWL.DEPTH_GT_3", pages.filter((p) => p.depth > 3).map((p) => p.url), {});
574
+ push(out, "CRAWL.DEPTH_GT_5", pages.filter((p) => p.depth > 5).map((p) => p.url), {});
575
+ push(out, "CRAWL.ORPHAN", pages
576
+ .filter((p) => p.followInlinkCount === 0 && p.depth > 0 && p.statusCode === 200)
577
+ .map((p) => p.url), {});
578
+ push(out, "CRAWL.INFINITE_SPACE", pages
579
+ .filter((p) => {
580
+ const segs = originPath(p.url).split("/").filter(Boolean);
581
+ const counts = new Map();
582
+ for (const s of segs)
583
+ counts.set(s, (counts.get(s) ?? 0) + 1);
584
+ return [...counts.values()].some((n) => n >= 3);
585
+ })
586
+ .map((p) => p.url), {});
587
+ push(out, "CRAWL.CALENDAR_TRAP", pages.filter((p) => DATE_RE.test(p.url)).map((p) => p.url), {});
588
+ push(out, "CRAWL.SESSION_ID", pages.filter((p) => SESSION_RE.test(p.url)).map((p) => p.url), {});
589
+ push(out, "CRAWL.FACET_EXPLOSION", pages.filter((p) => (p.url.match(/[?&]/g)?.length ?? 0) >= 3).map((p) => p.url), {});
590
+ push(out, "CRAWL.SORT_PARAMS", pages.filter((p) => FACET_PARAMS.test(p.url)).map((p) => p.url), {});
591
+ const indexable = pages.filter(isIndexable).length;
592
+ if (pages.length && 1 - indexable / pages.length > 0.4) {
593
+ push(out, "CRAWL.THIN_RATIO", [ctx.crawl.origin], {
594
+ ratio: 1 - indexable / pages.length,
595
+ });
596
+ }
597
+ if (hasReq(ctx, "gsc")) {
598
+ push(out, "CRAWL.DISCOVERED_NOT_INDEXED", Object.entries(ctx.gsc?.coverageStateByUrl ?? {})
599
+ .filter(([, s]) => /discovered/i.test(s))
600
+ .map(([u]) => u), {});
601
+ push(out, "CRAWL.STALE_IMPORTANT", Object.entries(ctx.gsc?.clicksByUrl ?? {})
602
+ .filter(([, c]) => c > 0)
603
+ .map(([u]) => u)
604
+ .filter(() => false), {});
605
+ push(out, "CRAWL.LOW_VALUE_CRAWL_SHARE", [], {});
606
+ }
607
+ push(out, "CRAWL.SLOW_UNDER_LOAD", [], {});
608
+ return out;
609
+ }
610
+ export function detectLink(ctx) {
611
+ const out = [];
612
+ const pages = htmlPages(ctx);
613
+ const map = byUrl(ctx);
614
+ if (hasReq(ctx, "gsc"))
615
+ push(out, "LINK.PR_STARVED_MONEY_PAGE", [], {});
616
+ const wasted = pages.filter((p) => !isIndexable(p) && p.inlinkCount > 5);
617
+ push(out, "LINK.PR_WASTED", wasted.map((p) => p.url), {});
618
+ push(out, "LINK.HUB_OVERLOAD", pages
619
+ .filter((p) => (p.extract?.links.filter((l) => l.isInternal).length ?? 0) > 300)
620
+ .map((p) => p.url), {});
621
+ push(out, "LINK.NO_OUTLINKS", pages
622
+ .filter((p) => (p.extract?.links.filter((l) => l.isInternal).length ?? 0) === 0 &&
623
+ p.statusCode === 200)
624
+ .map((p) => p.url), {});
625
+ push(out, "LINK.NOFOLLOW_INTERNAL", pages
626
+ .filter((p) => p.extract?.links.some((l) => l.isInternal && l.isNofollow))
627
+ .map((p) => p.url), {});
628
+ push(out, "LINK.NOFOLLOW_ONLY_INLINKS", pages
629
+ .filter((p) => p.inlinkCount > 0 && p.followInlinkCount === 0)
630
+ .map((p) => p.url), {});
631
+ push(out, "LINK.JS_ONLY_LINKS", pages
632
+ .filter((p) => p.renderedExtract &&
633
+ (p.renderedExtract.links.length - (p.extract?.links.length ?? 0)) /
634
+ Math.max(1, p.renderedExtract.links.length) >
635
+ 0.3)
636
+ .map((p) => p.url), {});
637
+ push(out, "LINK.EMPTY_ANCHOR", pages
638
+ .filter((p) => p.extract?.links.some((l) => l.isInternal && !l.anchorText))
639
+ .map((p) => p.url), {});
640
+ push(out, "LINK.GENERIC_ANCHOR", pages
641
+ .filter((p) => p.extract?.links.some((l) => GENERIC_ANCHORS.has(l.anchorText.toLowerCase())))
642
+ .map((p) => p.url), {});
643
+ const g = gini(pages.map((p) => p.inlinkCount));
644
+ if (g > 0.85)
645
+ push(out, "LINK.INLINK_CONCENTRATION", [ctx.crawl.origin], { gini: g });
646
+ const broken = [];
647
+ for (const p of pages) {
648
+ for (const l of p.extract?.links ?? []) {
649
+ if (!l.isInternal)
650
+ continue;
651
+ const t = map.get(l.absUrl);
652
+ if (t && t.statusCode !== null && t.statusCode >= 400)
653
+ broken.push(p.url);
654
+ }
655
+ }
656
+ push(out, "LINK.BROKEN_INTERNAL", broken, {});
657
+ push(out, "LINK.BROKEN_FRAGMENT", pages
658
+ .filter((p) => p.extract?.links.some((l) => l.href.includes("#") && l.href.split("#")[1]))
659
+ .map((p) => p.url), {}, 0.4);
660
+ push(out, "LINK.LOCALHOST", pages
661
+ .filter((p) => p.extract?.links.some((l) => /localhost|127\.0\.0\.1|\.local/i.test(l.absUrl)))
662
+ .map((p) => p.url), {});
663
+ push(out, "LINK.UNSAFE_CROSS_ORIGIN", pages.filter((p) => (p.extract?.unsafeTargetBlanks ?? 0) > 0).map((p) => p.url), {});
664
+ push(out, "LINK.PROTOCOL_RELATIVE", pages
665
+ .filter((p) => p.extract?.links.some((l) => l.href.startsWith("//")))
666
+ .map((p) => p.url), {});
667
+ return out;
668
+ }
669
+ export function detectPage(ctx) {
670
+ const out = [];
671
+ const pages = htmlPages(ctx);
672
+ const paginated = pages.filter((p) => /[?&]page=\d+/i.test(p.url) || /\/page\/\d+/i.test(p.url));
673
+ push(out, "PAGE.CANON_TO_P1", paginated
674
+ .filter((p) => {
675
+ const c = absCanonical(p);
676
+ return Boolean(c && /page=1\b|\/page\/1\b/i.test(c) && !/page=1\b|\/page\/1\b/i.test(p.url));
677
+ })
678
+ .map((p) => p.url), {});
679
+ push(out, "PAGE.NOT_IN_ANCHOR", [], {});
680
+ push(out, "PAGE.NON_200", paginated.filter((p) => p.statusCode !== 200).map((p) => p.url), {});
681
+ push(out, "PAGE.UNLINKED", paginated.filter((p) => p.inlinkCount === 0).map((p) => p.url), {});
682
+ push(out, "PAGE.LOOP", paginated.filter((p) => p.extract?.paginationRel.next === p.url).map((p) => p.url), {});
683
+ push(out, "PAGE.SEQUENCE_ERROR", [], {});
684
+ push(out, "PAGE.NOINDEX", paginated.filter(noindexOf).map((p) => p.url), {});
685
+ push(out, "PAGE.INFINITE_SCROLL_NO_URLS", pages
686
+ .filter((p) => p.extract?.spaRootEmpty && !/[?&]page=/.test(p.url))
687
+ .map((p) => p.url), {}, 0.4);
688
+ const titles = new Map();
689
+ for (const p of paginated) {
690
+ const t = p.extract?.title ?? "";
691
+ titles.set(t, [...(titles.get(t) ?? []), p.url]);
692
+ }
693
+ push(out, "PAGE.DUPLICATE_TITLES", [...titles.values()].filter((v) => v.length > 1).flat(), {});
694
+ push(out, "PAGE.VIEW_ALL_MISSING", [], {});
695
+ return out;
696
+ }
697
+ export function detectParam(ctx) {
698
+ const out = [];
699
+ const pages = ctx.crawl.pages.filter((p) => p.isInternal);
700
+ push(out, "PARAM.TRACKING_INDEXABLE", pages
701
+ .filter((p) => /[?&](utm_|gclid|fbclid|msclkid|_ga)/i.test(p.url) && isIndexable(p))
702
+ .map((p) => p.url), {});
703
+ push(out, "PARAM.NON_STANDARD_SEPARATOR", pages
704
+ .filter((p) => /[[\];,]/.test(new URL(p.url, ctx.crawl.origin).search))
705
+ .map((p) => p.url), {});
706
+ push(out, "PARAM.ORDER_VARIANTS", [], {});
707
+ push(out, "PARAM.CASE_VARIANTS", pages
708
+ .filter((p) => /[A-Z]/.test(new URL(p.url, ctx.crawl.origin).search))
709
+ .map((p) => p.url), {});
710
+ push(out, "PARAM.EMPTY_VALUE", pages.filter((p) => /[?&][^=&]+=(&|$)/.test(p.url)).map((p) => p.url), {});
711
+ push(out, "PARAM.DUPLICATE_KEY", pages
712
+ .filter((p) => {
713
+ try {
714
+ const keys = [...new URL(p.url).searchParams.keys()];
715
+ return keys.length !== new Set(keys).size;
716
+ }
717
+ catch {
718
+ return false;
719
+ }
720
+ })
721
+ .map((p) => p.url), {});
722
+ const combos = new Set(pages.map((p) => new URL(p.url, ctx.crawl.origin).search));
723
+ if (combos.size > 1000)
724
+ push(out, "PARAM.FACET_COMBO_COUNT", [ctx.crawl.origin], { count: combos.size });
725
+ push(out, "PARAM.FACET_ZERO_RESULTS", htmlPages(ctx)
726
+ .filter((p) => /no results|0 products|nothing found/i.test(p.extract?.mainText ?? "") &&
727
+ p.statusCode === 200)
728
+ .map((p) => p.url), {});
729
+ push(out, "PARAM.FACET_NOT_DISALLOWED", pages
730
+ .filter((p) => FACET_PARAMS.test(p.url) && p.robotsAllowed && selfCanonical(p))
731
+ .map((p) => p.url), {});
732
+ const sm = sitemapSet(ctx);
733
+ push(out, "PARAM.FACET_IN_SITEMAP", pages.filter((p) => FACET_PARAMS.test(p.url) && sm.has(p.url)).map((p) => p.url), {});
734
+ push(out, "PARAM.FRAGMENT_OK", pages
735
+ .filter((p) => p.url.includes("#") && FACET_PARAMS.test(p.url))
736
+ .map((p) => p.url), {});
737
+ return out;
738
+ }
739
+ export function detectDup(ctx) {
740
+ const out = [];
741
+ const pages = htmlPages(ctx).filter((p) => p.statusCode === 200);
742
+ const byHash = group(pages, (p) => p.contentHash ?? "");
743
+ push(out, "DUP.EXACT_HTML", [...byHash.values()]
744
+ .filter((g) => g.length > 1 && g[0]?.contentHash)
745
+ .flatMap((g) => g.map((p) => p.url)), {});
746
+ const byBody = group(pages, (p) => p.extract?.mainText ?? "");
747
+ push(out, "DUP.EXACT_BODY", [...byBody.values()]
748
+ .filter((g) => g.length > 1 && (g[0]?.extract?.mainText.length ?? 0) > 80)
749
+ .flatMap((g) => g.map((p) => p.url)), {});
750
+ const near = [];
751
+ for (let i = 0; i < pages.length; i++) {
752
+ const a = pages[i];
753
+ if (!a?.simhash)
754
+ continue;
755
+ for (let j = i + 1; j < Math.min(pages.length, i + 25); j++) {
756
+ const b = pages[j];
757
+ if (!b?.simhash)
758
+ continue;
759
+ if (nearDuplicate(a.simhash, b.simhash, 3)) {
760
+ near.push(a.url, b.url);
761
+ }
762
+ }
763
+ }
764
+ push(out, "DUP.NEAR", near, {}, 0.8);
765
+ push(out, "DUP.SEMANTIC", [], {});
766
+ push(out, "DUP.TITLE", dupField(pages, (p) => p.extract?.title ?? ""), {});
767
+ push(out, "DUP.META_DESC", dupField(pages, (p) => p.extract?.metaDescription ?? ""), {});
768
+ push(out, "DUP.H1", dupField(pages, (p) => p.extract?.h1[0] ?? ""), {});
769
+ push(out, "DUP.CROSS_DOMAIN", [], {});
770
+ push(out, "DUP.BOILERPLATE_RATIO", pages.filter((p) => (p.extract?.boilerplateRatio ?? 0) > 0.75).map((p) => p.url), {});
771
+ push(out, "DUP.PAGINATED_DUP", [], {});
772
+ push(out, "DUP.PRINT_VERSION", pages.filter((p) => /[?&]print=|\/print\//i.test(p.url)).map((p) => p.url), {});
773
+ if (ctx.crawl.originProbe.https &&
774
+ ctx.crawl.originProbe.httpRedirectsToHttps === false) {
775
+ push(out, "DUP.HTTP_AND_HTTPS", [ctx.crawl.origin], {});
776
+ }
777
+ return out;
778
+ }
779
+ function group(items, key) {
780
+ const m = new Map();
781
+ for (const i of items) {
782
+ const k = key(i);
783
+ if (!k)
784
+ continue;
785
+ const arr = m.get(k) ?? [];
786
+ arr.push(i);
787
+ m.set(k, arr);
788
+ }
789
+ return m;
790
+ }
791
+ function dupField(pages, key) {
792
+ const m = group(pages, key);
793
+ return [...m.values()]
794
+ .filter((g) => g.length > 1)
795
+ .flatMap((g) => g.map((p) => p.url));
796
+ }
797
+ export function detectThin(ctx) {
798
+ const out = [];
799
+ const pages = htmlPages(ctx).filter((p) => p.statusCode === 200);
800
+ const words = pages
801
+ .map((p) => p.extract?.mainWordCount ?? 0)
802
+ .toSorted((a, b) => a - b);
803
+ const median = words[Math.floor(words.length / 2)] ?? 0;
804
+ const floor = Math.max(200, 0.4 * median);
805
+ push(out, "THIN.LOW_WORDCOUNT", pages.filter((p) => (p.extract?.mainWordCount ?? 0) < floor).map((p) => p.url), { floor, median });
806
+ push(out, "THIN.NO_MAIN_CONTENT", pages.filter((p) => (p.extract?.mainWordCount ?? 0) < 50).map((p) => p.url), {});
807
+ push(out, "THIN.TEMPLATE_ONLY", pages.filter((p) => (p.extract?.boilerplateRatio ?? 0) > 0.9).map((p) => p.url), {});
808
+ push(out, "THIN.LOREM_IPSUM", pages.filter((p) => p.extract?.loremIpsum).map((p) => p.url), {});
809
+ if (hasReq(ctx, "gsc")) {
810
+ push(out, "THIN.ZERO_IMPRESSIONS", pages
811
+ .filter((p) => (ctx.gsc?.impressionsByUrl?.[p.url] ?? 0) === 0)
812
+ .map((p) => p.url), {});
813
+ push(out, "THIN.NO_INTERNAL_VALUE", pages
814
+ .filter((p) => (p.extract?.mainWordCount ?? 0) < 50 && p.inlinkCount === 0)
815
+ .map((p) => p.url), {});
816
+ }
817
+ push(out, "THIN.AUTOGEN_PATTERN", [], {});
818
+ return out;
819
+ }
820
+ export function detectJs(ctx) {
821
+ const out = [];
822
+ const pages = htmlPages(ctx);
823
+ const rendered = pages.filter((p) => p.renderedExtract);
824
+ push(out, "JS.CONTENT_ONLY_RENDERED", rendered
825
+ .filter((p) => {
826
+ const raw = p.extract?.mainWordCount ?? 0;
827
+ const rend = p.renderedExtract?.mainWordCount ?? 0;
828
+ return rend > 0 && (rend - raw) / rend > 0.5;
829
+ })
830
+ .map((p) => p.url), {});
831
+ push(out, "JS.NO_RAW_CONTENT", rendered
832
+ .filter((p) => (p.extract?.mainWordCount ?? 0) < 100 &&
833
+ (p.renderedExtract?.mainWordCount ?? 0) > 500)
834
+ .map((p) => p.url), {});
835
+ push(out, "JS.TITLE_RENDER_ONLY", rendered
836
+ .filter((p) => !p.extract?.title && p.renderedExtract?.title)
837
+ .map((p) => p.url), {});
838
+ push(out, "JS.TITLE_UPDATED_BY_JS", rendered
839
+ .filter((p) => p.extract?.title &&
840
+ p.renderedExtract?.title &&
841
+ p.extract.title !== p.renderedExtract.title)
842
+ .map((p) => p.url), {});
843
+ push(out, "JS.META_DESC_RENDER_ONLY", rendered
844
+ .filter((p) => !p.extract?.metaDescription && p.renderedExtract?.metaDescription)
845
+ .map((p) => p.url), {});
846
+ push(out, "JS.META_DESC_UPDATED", rendered
847
+ .filter((p) => p.extract?.metaDescription &&
848
+ p.renderedExtract?.metaDescription &&
849
+ p.extract.metaDescription !== p.renderedExtract.metaDescription)
850
+ .map((p) => p.url), {});
851
+ push(out, "JS.H1_RENDER_ONLY", rendered
852
+ .filter((p) => (p.extract?.h1.length ?? 0) === 0 && (p.renderedExtract?.h1.length ?? 0) > 0)
853
+ .map((p) => p.url), {});
854
+ push(out, "JS.H1_UPDATED", rendered
855
+ .filter((p) => (p.extract?.h1[0] ?? "") !== (p.renderedExtract?.h1[0] ?? "") &&
856
+ (p.renderedExtract?.h1.length ?? 0) > 0)
857
+ .map((p) => p.url), {});
858
+ push(out, "JS.CANONICAL_RENDER_ONLY", rendered
859
+ .filter((p) => (p.extract?.canonicalHtml.length ?? 0) === 0 &&
860
+ (p.renderedExtract?.canonicalHtml.length ?? 0) > 0)
861
+ .map((p) => p.url), {});
862
+ push(out, "JS.CANONICAL_MISMATCH", rendered
863
+ .filter((p) => (p.extract?.canonicalHtml[0] ?? "") !==
864
+ (p.renderedExtract?.canonicalHtml[0] ?? "") &&
865
+ (p.renderedExtract?.canonicalHtml.length ?? 0) > 0)
866
+ .map((p) => p.url), {});
867
+ push(out, "JS.NOINDEX_RAW_ONLY", rendered
868
+ .filter((p) => noindexOf(p) &&
869
+ !/\bnoindex\b/i.test(p.renderedExtract?.robotsMeta.join(",") ?? ""))
870
+ .map((p) => p.url), {});
871
+ push(out, "JS.NOFOLLOW_RAW_ONLY", rendered
872
+ .filter((p) => nofollowOf(p) &&
873
+ !/\bnofollow\b/i.test(p.renderedExtract?.robotsMeta.join(",") ?? ""))
874
+ .map((p) => p.url), {});
875
+ push(out, "JS.LINKS_RENDER_ONLY", rendered
876
+ .filter((p) => {
877
+ const raw = p.extract?.links.filter((l) => l.isInternal).length ?? 0;
878
+ const rend = p.renderedExtract?.links.filter((l) => l.isInternal).length ?? 0;
879
+ return rend > 0 && (rend - raw) / rend > 0.3;
880
+ })
881
+ .map((p) => p.url), {});
882
+ push(out, "JS.NO_HREF_LINKS", pages
883
+ .filter((p) => (p.extract?.links.length ?? 0) === 0 && /onclick=/i.test(p.html ?? ""))
884
+ .map((p) => p.url), {});
885
+ push(out, "JS.HASH_ROUTING", pages.filter((p) => p.url.includes("#/")).map((p) => p.url), {});
886
+ push(out, "JS.OLD_AJAX_SCHEME", pages
887
+ .filter((p) => p.url.includes("#!") || p.extract?.hashFragmentMeta)
888
+ .map((p) => p.url), {});
889
+ push(out, "JS.BLOCKED_RESOURCES", pages
890
+ .filter((p) => p.extract?.scripts.some((s) => s.absUrl && !pageAllowed(ctx, s.absUrl)))
891
+ .map((p) => p.url), {});
892
+ push(out, "JS.CONSOLE_ERRORS", [], {});
893
+ push(out, "JS.FAILED_REQUESTS", [], {});
894
+ push(out, "JS.RENDER_TIMEOUT", pages.filter((p) => p.error === "render_timeout").map((p) => p.url), {});
895
+ push(out, "JS.JSONLD_RENDER_ONLY", rendered
896
+ .filter((p) => (p.extract?.jsonLd.length ?? 0) === 0 &&
897
+ (p.renderedExtract?.jsonLd.length ?? 0) > 0)
898
+ .map((p) => p.url), {});
899
+ push(out, "JS.HYDRATION_MISMATCH", rendered
900
+ .filter((p) => {
901
+ const a = p.extract?.mainText ?? "";
902
+ const b = p.renderedExtract?.mainText ?? "";
903
+ return a.length > 50 && b.length > 50 && jaccard(a, b) < 0.5;
904
+ })
905
+ .map((p) => p.url), {});
906
+ push(out, "JS.COOKIE_DEPENDENT", [], {});
907
+ push(out, "JS.LOCALSTORAGE_DEPENDENT", [], {});
908
+ push(out, "JS.CONSENT_WALL", pages
909
+ .filter((p) => (p.extract?.mainWordCount ?? 0) < 100 &&
910
+ /onetrust|usercentrics|cookiebot|didomi|cky-consent/i.test(p.html ?? ""))
911
+ .map((p) => p.url), {});
912
+ push(out, "JS.GEO_REDIRECT", [], {});
913
+ push(out, "JS.CLOAKING_RISK", [], {});
914
+ push(out, "JS.INFINITE_SCROLL_NO_SSR", [], {});
915
+ return out;
916
+ }
917
+ function jaccard(a, b) {
918
+ const A = new Set(a.split(/\s+/));
919
+ const B = new Set(b.split(/\s+/));
920
+ let inter = 0;
921
+ for (const x of A)
922
+ if (B.has(x))
923
+ inter++;
924
+ const union = A.size + B.size - inter;
925
+ return union === 0 ? 0 : inter / union;
926
+ }
927
+ export function detectCwv(ctx) {
928
+ const out = [];
929
+ if (!hasReq(ctx, "crux") || !ctx.crux)
930
+ return out;
931
+ const origin = ctx.crawl.origin;
932
+ if ((ctx.crux.lcpP75Ms ?? 0) > 2500)
933
+ push(out, "CWV.LCP_FAIL", [origin], { p75: ctx.crux.lcpP75Ms });
934
+ if ((ctx.crux.inpP75Ms ?? 0) > 200)
935
+ push(out, "CWV.INP_FAIL", [origin], { p75: ctx.crux.inpP75Ms });
936
+ if ((ctx.crux.clsP75 ?? 0) > 0.1)
937
+ push(out, "CWV.CLS_FAIL", [origin], { p75: ctx.crux.clsP75 });
938
+ if (ctx.crux.urlLevel === false)
939
+ push(out, "CWV.NO_FIELD_DATA", [origin], {});
940
+ if (ctx.crux.phoneLcpP75Ms &&
941
+ ctx.crux.desktopLcpP75Ms &&
942
+ ctx.crux.phoneLcpP75Ms > 1.5 * ctx.crux.desktopLcpP75Ms) {
943
+ push(out, "CWV.MOBILE_DESKTOP_GAP", [origin], {});
944
+ }
945
+ push(out, "CWV.REGRESSION", [], {});
946
+ return out;
947
+ }
948
+ export function detectPerf(ctx) {
949
+ const out = [];
950
+ const pages = ctx.crawl.pages;
951
+ if (hasReq(ctx, "crux") && ctx.crux?.ttfbP75Ms && ctx.crux.ttfbP75Ms > 800) {
952
+ push(out, "PERF.TTFB", [ctx.crawl.origin], { p75: ctx.crux.ttfbP75Ms });
953
+ }
954
+ else {
955
+ push(out, "PERF.TTFB", pages.filter((p) => p.ttfbMs > 800).map((p) => p.url), {});
956
+ }
957
+ const lh = ctx.lighthouse;
958
+ const failAudit = (id) => (lh?.audits[id]?.score ?? 1) < 1;
959
+ if (hasReq(ctx, "lighthouse") && lh) {
960
+ if (failAudit("lcp-discovery-insight"))
961
+ push(out, "PERF.LCP_IS_IMAGE_NO_PRELOAD", [ctx.crawl.origin], {});
962
+ if (failAudit("render-blocking-insight"))
963
+ push(out, "PERF.RENDER_BLOCKING", [ctx.crawl.origin], {});
964
+ if (failAudit("legacy-javascript-insight"))
965
+ push(out, "PERF.LEGACY_JS", [ctx.crawl.origin], {});
966
+ if (failAudit("duplicated-javascript-insight"))
967
+ push(out, "PERF.DUPLICATE_JS", [ctx.crawl.origin], {});
968
+ if (failAudit("dom-size-insight"))
969
+ push(out, "PERF.DOM_SIZE", [ctx.crawl.origin], {});
970
+ if (failAudit("third-parties-insight"))
971
+ push(out, "PERF.THIRD_PARTY_BLOCKING", [ctx.crawl.origin], {});
972
+ }
973
+ push(out, "PERF.LCP_LAZY_LOADED", htmlPages(ctx)
974
+ .filter((p) => p.extract?.images.some((i) => i.loading === "lazy"))
975
+ .map((p) => p.url), {}, 0.4);
976
+ push(out, "PERF.NO_CACHE_HEADERS", pages
977
+ .filter((p) => /\.(js|css|woff2|png|jpe?g|webp|avif)(\?|$)/i.test(p.url) &&
978
+ !/(max-age|immutable)/i.test(p.headers["cache-control"] ?? ""))
979
+ .map((p) => p.url), {});
980
+ push(out, "PERF.NO_COMPRESSION", pages
981
+ .filter((p) => /html|javascript|json|css|text\//i.test(p.contentType) &&
982
+ !p.headers["content-encoding"])
983
+ .map((p) => p.url), {});
984
+ push(out, "PERF.NO_CDN", pages
985
+ .filter((p) => p.depth === 0 &&
986
+ !/(cf-ray|x-amz-cf-id|x-vercel-cache|x-cache|fastly)/i.test(Object.keys(p.headers).join(" ")))
987
+ .map((p) => p.url), {});
988
+ push(out, "PERF.CACHE_MISS_RATE", [], {});
989
+ if (ctx.crawl.originProbe.alpn &&
990
+ !/h2|h3|http\/2|http\/3/i.test(ctx.crawl.originProbe.alpn)) {
991
+ push(out, "PERF.HTTP1_ONLY", [ctx.crawl.origin], {
992
+ alpn: ctx.crawl.originProbe.alpn,
993
+ });
994
+ }
995
+ push(out, "PERF.NO_UNSIZED_IMG", htmlPages(ctx)
996
+ .filter((p) => p.extract?.images.some((i) => !i.width && !i.height))
997
+ .map((p) => p.url), {});
998
+ push(out, "PERF.FONT_NO_DISPLAY_SWAP", htmlPages(ctx)
999
+ .filter((p) => /@font-face/i.test(p.html ?? "") && !/font-display/i.test(p.html ?? ""))
1000
+ .map((p) => p.url), {});
1001
+ push(out, "PERF.PAGE_WEIGHT", pages.filter((p) => p.wireBytes > 3 * 1024 * 1024).map((p) => p.url), {});
1002
+ return out;
1003
+ }
1004
+ export function detectSec(ctx) {
1005
+ const out = [];
1006
+ const pages = ctx.crawl.pages;
1007
+ const origin = ctx.crawl.origin;
1008
+ push(out, "SEC.HTTP_URLS", pages.filter((p) => p.isInternal && p.url.startsWith("http:")).map((p) => p.url), {});
1009
+ if (ctx.crawl.originProbe.https &&
1010
+ ctx.crawl.originProbe.httpRedirectsToHttps === false) {
1011
+ push(out, "SEC.NO_HTTPS_REDIRECT", [origin], {});
1012
+ }
1013
+ push(out, "SEC.MIXED_ACTIVE", htmlPages(ctx)
1014
+ .filter((p) => (p.extract?.mixedActive.length ?? 0) > 0)
1015
+ .map((p) => p.url), {});
1016
+ push(out, "SEC.MIXED_PASSIVE", htmlPages(ctx)
1017
+ .filter((p) => (p.extract?.mixedPassive.length ?? 0) > 0)
1018
+ .map((p) => p.url), {});
1019
+ push(out, "SEC.PROTOCOL_RELATIVE", htmlPages(ctx)
1020
+ .filter((p) => p.extract?.links.some((l) => l.href.startsWith("//")))
1021
+ .map((p) => p.url), {});
1022
+ if (ctx.crawl.originProbe.certDaysRemaining !== null &&
1023
+ ctx.crawl.originProbe.certDaysRemaining < 30) {
1024
+ push(out, "SEC.CERT_EXPIRING", [origin], {
1025
+ days: ctx.crawl.originProbe.certDaysRemaining,
1026
+ });
1027
+ }
1028
+ if (ctx.crawl.originProbe.certError)
1029
+ push(out, "SEC.CERT_INVALID", [origin], { error: ctx.crawl.originProbe.certError });
1030
+ push(out, "SEC.TLS_OLD", [], {});
1031
+ const home = pages.find((p) => p.depth === 0) ?? pages[0];
1032
+ if (home &&
1033
+ !home.headers["strict-transport-security"] &&
1034
+ !ctx.crawl.originProbe.hsts) {
1035
+ push(out, "SEC.NO_HSTS", [origin], {});
1036
+ }
1037
+ const hsts = home?.headers["strict-transport-security"] ?? ctx.crawl.originProbe.hsts ?? "";
1038
+ const maxAge = Number(/max-age=(\d+)/i.exec(hsts)?.[1] ?? 0);
1039
+ if (hsts && maxAge < 31536000)
1040
+ push(out, "SEC.HSTS_SHORT", [origin], { maxAge });
1041
+ push(out, "SEC.FORM_INSECURE", htmlPages(ctx)
1042
+ .filter((p) => p.extract?.forms.some((f) => f.absAction.startsWith("http:")))
1043
+ .map((p) => p.url), {});
1044
+ push(out, "SEC.FORM_ON_HTTP", htmlPages(ctx)
1045
+ .filter((p) => p.url.startsWith("http:") && (p.extract?.forms.length ?? 0) > 0)
1046
+ .map((p) => p.url), {});
1047
+ if (home && !home.headers["content-security-policy"])
1048
+ push(out, "SEC.NO_CSP", [origin], {});
1049
+ if (home && home.headers["x-content-type-options"]?.toLowerCase() !== "nosniff")
1050
+ push(out, "SEC.NO_XCTO", [origin], {});
1051
+ if (home &&
1052
+ !home.headers["x-frame-options"] &&
1053
+ !/frame-ancestors/.test(home.headers["content-security-policy"] ?? "")) {
1054
+ push(out, "SEC.NO_XFO", [origin], {});
1055
+ }
1056
+ if (home && !home.headers["referrer-policy"])
1057
+ push(out, "SEC.NO_REFERRER_POLICY", [origin], {});
1058
+ push(out, "SEC.UNSAFE_TARGET_BLANK", htmlPages(ctx)
1059
+ .filter((p) => (p.extract?.unsafeTargetBlanks ?? 0) > 0)
1060
+ .map((p) => p.url), {});
1061
+ push(out, "SEC.CANONICAL_HTTP", htmlPages(ctx)
1062
+ .filter((p) => p.url.startsWith("https:") && (absCanonical(p) ?? "").startsWith("http:"))
1063
+ .map((p) => p.url), {});
1064
+ push(out, "SEC.HREFLANG_HTTP", htmlPages(ctx)
1065
+ .filter((p) => p.extract?.hreflang.some((h) => h.absUrl.startsWith("http:")))
1066
+ .map((p) => p.url), {});
1067
+ if (ctx.crawl.sitemaps.some((s) => s.urls.some((u) => u.loc.startsWith("http:")))) {
1068
+ push(out, "SEC.SITEMAP_HTTP", [origin], {});
1069
+ }
1070
+ return out;
1071
+ }
1072
+ export function detectHref(ctx) {
1073
+ const out = [];
1074
+ const pages = htmlPages(ctx).filter((p) => (p.extract?.hreflang.length ?? 0) > 0);
1075
+ const map = byUrl(ctx);
1076
+ const iso639 = /^(?:[a-z]{2,3})(?:-[A-Z][a-z]{3})?(?:-[A-Z]{2}|-[0-9]{3})?$|^x-default$/;
1077
+ push(out, "HREF.MISSING_SELF", pages
1078
+ .filter((p) => !p.extract?.hreflang.some((h) => stripSlash(h.absUrl) === stripSlash(p.finalUrl)))
1079
+ .map((p) => p.url), {});
1080
+ const missingReturn = [];
1081
+ for (const p of pages) {
1082
+ for (const h of p.extract?.hreflang ?? []) {
1083
+ const t = map.get(h.absUrl);
1084
+ if (t?.extract &&
1085
+ !t.extract.hreflang.some((x) => stripSlash(x.absUrl) === stripSlash(p.finalUrl))) {
1086
+ missingReturn.push(p.url);
1087
+ }
1088
+ }
1089
+ }
1090
+ push(out, "HREF.MISSING_RETURN", missingReturn, {});
1091
+ push(out, "HREF.NON_RECIPROCAL_CODE", [], {});
1092
+ push(out, "HREF.INVALID_LANG_CODE", pages
1093
+ .filter((p) => p.extract?.hreflang.some((h) => h.lang !== "x-default" && !/^[a-z]{2,3}/i.test(h.lang)))
1094
+ .map((p) => p.url), {});
1095
+ push(out, "HREF.INVALID_REGION_CODE", pages
1096
+ .filter((p) => p.extract?.hreflang.some((h) => /-(UK|EU|UN|EN)$/i.test(h.lang)))
1097
+ .map((p) => p.url), {});
1098
+ push(out, "HREF.REGION_ONLY", pages
1099
+ .filter((p) => p.extract?.hreflang.some((h) => /^[A-Z]{2}$/.test(h.lang)))
1100
+ .map((p) => p.url), {});
1101
+ push(out, "HREF.MALFORMED", pages
1102
+ .filter((p) => p.extract?.hreflang.some((h) => !iso639.test(h.lang) && h.lang !== "x-default"))
1103
+ .map((p) => p.url), {});
1104
+ push(out, "HREF.RELATIVE_URL", pages
1105
+ .filter((p) => p.extract?.hreflang.some((h) => !/^https?:/i.test(h.href)))
1106
+ .map((p) => p.url), {});
1107
+ push(out, "HREF.NON_200", pages
1108
+ .filter((p) => p.extract?.hreflang.some((h) => {
1109
+ const t = map.get(h.absUrl);
1110
+ return t && t.statusCode !== 200 && t.statusCode !== null;
1111
+ }))
1112
+ .map((p) => p.url), {});
1113
+ push(out, "HREF.TO_REDIRECT", pages
1114
+ .filter((p) => p.extract?.hreflang.some((h) => {
1115
+ const t = map.get(h.absUrl);
1116
+ return t && hops(t) >= 1;
1117
+ }))
1118
+ .map((p) => p.url), {});
1119
+ push(out, "HREF.TO_NOINDEX", pages
1120
+ .filter((p) => p.extract?.hreflang.some((h) => {
1121
+ const t = map.get(h.absUrl);
1122
+ return t && noindexOf(t);
1123
+ }))
1124
+ .map((p) => p.url), {});
1125
+ push(out, "HREF.TO_CANONICALISED", pages
1126
+ .filter((p) => p.extract?.hreflang.some((h) => {
1127
+ const t = map.get(h.absUrl);
1128
+ return t && absCanonical(t) && !selfCanonical(t);
1129
+ }))
1130
+ .map((p) => p.url), {});
1131
+ push(out, "HREF.CANON_CONFLICT", pages
1132
+ .filter((p) => {
1133
+ const c = absCanonical(p);
1134
+ if (!c)
1135
+ return false;
1136
+ return !p.extract?.hreflang.some((h) => stripSlash(h.absUrl) === stripSlash(c));
1137
+ })
1138
+ .map((p) => p.url), {});
1139
+ push(out, "HREF.MULTIPLE_SAME_CODE", pages
1140
+ .filter((p) => {
1141
+ const codes = p.extract?.hreflang.map((h) => h.lang) ?? [];
1142
+ return codes.length !== new Set(codes).size;
1143
+ })
1144
+ .map((p) => p.url), {});
1145
+ push(out, "HREF.DUPLICATE_ENTRIES", pages
1146
+ .filter((p) => {
1147
+ const keys = p.extract?.hreflang.map((h) => `${h.lang}|${h.absUrl}`) ?? [];
1148
+ return keys.length !== new Set(keys).size;
1149
+ })
1150
+ .map((p) => p.url), {});
1151
+ push(out, "HREF.OUTSIDE_HEAD", pages.filter((p) => p.extract?.hreflang.some((h) => !h.inHead)).map((p) => p.url), {});
1152
+ push(out, "HREF.MISSING_X_DEFAULT", pages
1153
+ .filter((p) => !p.extract?.hreflang.some((h) => h.lang === "x-default"))
1154
+ .map((p) => p.url), {});
1155
+ push(out, "HREF.MULTIPLE_X_DEFAULT", pages
1156
+ .filter((p) => (p.extract?.hreflang.filter((h) => h.lang === "x-default").length ?? 0) > 1)
1157
+ .map((p) => p.url), {});
1158
+ push(out, "HREF.UNLINKED_ALTERNATE", pages
1159
+ .filter((p) => p.extract?.hreflang.some((h) => (map.get(h.absUrl)?.inlinkCount ?? 0) === 0))
1160
+ .map((p) => p.url), {});
1161
+ push(out, "HREF.MIXED_IMPLEMENTATION", [], {});
1162
+ push(out, "HREF.HTTP_ALTERNATE", pages
1163
+ .filter((p) => p.extract?.hreflang.some((h) => h.absUrl.startsWith("http:")))
1164
+ .map((p) => p.url), {});
1165
+ push(out, "HREF.CLUSTER_ASYMMETRY", [], {});
1166
+ push(out, "HREF.LANG_MISMATCH", [], {});
1167
+ push(out, "HREF.HTML_LANG_MISMATCH", pages
1168
+ .filter((p) => {
1169
+ const self = p.extract?.hreflang.find((h) => stripSlash(h.absUrl) === stripSlash(p.finalUrl));
1170
+ return Boolean(self &&
1171
+ p.extract?.htmlLang &&
1172
+ !p.extract.htmlLang.toLowerCase().startsWith(self.lang.split("-")[0] ?? ""));
1173
+ })
1174
+ .map((p) => p.url), {});
1175
+ push(out, "HREF.CURRENCY_MISMATCH", [], {});
1176
+ return out;
1177
+ }
1178
+ export function detectSd(ctx) {
1179
+ const out = [];
1180
+ const pages = htmlPages(ctx);
1181
+ const gallery = googleSupportedTypes();
1182
+ for (const p of pages) {
1183
+ const issues = validateJsonLdBlocks(p.extract?.jsonLd ?? []);
1184
+ const codes = new Set(issues.map((i) => i.code));
1185
+ if (codes.has("PARSE_ERROR"))
1186
+ push(out, "SD.PARSE_ERROR", [p.url], { issues });
1187
+ if (codes.has("MISSING_CONTEXT"))
1188
+ push(out, "SD.MISSING_CONTEXT", [p.url], { issues });
1189
+ if (codes.has("MISSING_TYPE"))
1190
+ push(out, "SD.MISSING_TYPE", [p.url], { issues });
1191
+ if (codes.has("UNKNOWN_TYPE"))
1192
+ push(out, "SD.UNKNOWN_TYPE", [p.url], { issues });
1193
+ if (codes.has("TYPE_DEPRECATED"))
1194
+ push(out, "SD.TYPE_DEPRECATED", [p.url], { issues });
1195
+ if (codes.has("MISSING_REQUIRED_PROP"))
1196
+ push(out, "SD.MISSING_REQUIRED_PROP", [p.url], { issues });
1197
+ if (codes.has("MISSING_RECOMMENDED_PROP"))
1198
+ push(out, "SD.MISSING_RECOMMENDED_PROP", [p.url], { issues });
1199
+ if (codes.has("INVALID_ENUM"))
1200
+ push(out, "SD.INVALID_ENUM", [p.url], { issues });
1201
+ if (codes.has("INVALID_DATE"))
1202
+ push(out, "SD.INVALID_DATE", [p.url], { issues });
1203
+ if (codes.has("INVALID_DURATION"))
1204
+ push(out, "SD.INVALID_DURATION", [p.url], { issues });
1205
+ const types = (p.extract?.jsonLd ?? []).flatMap((b) => flattenTypes(b.parsed));
1206
+ const text = p.extract?.allText ?? "";
1207
+ for (const b of p.extract?.jsonLd ?? []) {
1208
+ if (typeof b.parsed === "object" && b.parsed) {
1209
+ const rec = b.parsed;
1210
+ for (const key of ["name", "headline", "description"]) {
1211
+ const v = rec[key];
1212
+ if (typeof v === "string" &&
1213
+ v.length > 20 &&
1214
+ !text.includes(v.slice(0, 20))) {
1215
+ push(out, "SD.HIDDEN_CONTENT", [p.url], { property: key });
1216
+ }
1217
+ }
1218
+ }
1219
+ }
1220
+ if (types.includes("FAQPage") && !gallery.supported.includes("FAQPage")) {
1221
+ push(out, "SD.TYPE_DEPRECATED", [p.url], { type: "FAQPage" });
1222
+ }
1223
+ const urls = JSON.stringify(p.extract?.jsonLd ?? []);
1224
+ if (/https?:\/\/[^"'\s]+/.test(urls) === false) {
1225
+ /* skip */
1226
+ }
1227
+ push(out, "SD.INVALID_URL_PROP", (p.extract?.jsonLd ?? []).some((b) => JSON.stringify(b.parsed).includes('"url":"/"'))
1228
+ ? [p.url]
1229
+ : [], {});
1230
+ if (p.depth >= 2 && !types.includes("BreadcrumbList"))
1231
+ push(out, "SD.NO_BREADCRUMB", [p.url], {});
1232
+ if (p.renderedExtract &&
1233
+ (p.extract?.jsonLd.length ?? 0) === 0 &&
1234
+ p.renderedExtract.jsonLd.length > 0) {
1235
+ push(out, "SD.JS_INJECTED", [p.url], {});
1236
+ }
1237
+ if ((p.extract?.jsonLd.length ?? 0) > 1 &&
1238
+ !p.extract?.jsonLd.some((b) => JSON.stringify(b.parsed).includes("@id"))) {
1239
+ push(out, "SD.MULTIPLE_ENTITIES_NO_GRAPH", [p.url], {});
1240
+ }
1241
+ if ((p.extract?.jsonLd.length ?? 0) > 0 &&
1242
+ p.extract?.jsonLd.every((b) => !b.inHead) === true) {
1243
+ /* still valid in body */
1244
+ }
1245
+ }
1246
+ const home = pages.find((p) => originPath(p.url) === "/") ?? pages[0];
1247
+ if (home &&
1248
+ !home.extract?.jsonLd.some((b) => flattenTypes(b.parsed).includes("Organization"))) {
1249
+ push(out, "SD.NO_ORGANIZATION", [home.url], {});
1250
+ }
1251
+ const ratings = pages
1252
+ .map((p) => JSON.stringify(p.extract?.jsonLd ?? ""))
1253
+ .filter((s) => s.includes("aggregateRating"));
1254
+ if (ratings.length > 5 && new Set(ratings).size === 1)
1255
+ push(out, "SD.SITEWIDE_AGG_RATING", [ctx.crawl.origin], {});
1256
+ push(out, "SD.IRRELEVANT", [], {});
1257
+ push(out, "SD.MISLEADING", pages
1258
+ .filter((p) => JSON.stringify(p.extract?.jsonLd ?? "").includes("aggregateRating") &&
1259
+ !/review/i.test(p.extract?.allText ?? ""))
1260
+ .map((p) => p.url), {});
1261
+ push(out, "SD.SELF_SERVING_REVIEW", [], {});
1262
+ push(out, "SD.IMAGE_TOO_SMALL", [], {});
1263
+ push(out, "SD.PRICE_MISMATCH", [], {});
1264
+ push(out, "SD.ORG_INCONSISTENT", [], {});
1265
+ push(out, "SD.MICRODATA_JSONLD_CONFLICT", [], {});
1266
+ push(out, "SD.NOT_IN_HEAD_OR_BODY", [], {});
1267
+ push(out, "SD.MISSING_ON_ELIGIBLE_TEMPLATE", pages
1268
+ .filter((p) => {
1269
+ const path = originPath(p.url);
1270
+ const types = (p.extract?.jsonLd ?? []).flatMap((b) => flattenTypes(b.parsed));
1271
+ return (/\/(blog|article|post)\//i.test(path) && !types.some((t) => /Article/.test(t)));
1272
+ })
1273
+ .map((p) => p.url), {});
1274
+ return out;
1275
+ }
1276
+ export function detectOnp(ctx) {
1277
+ const out = [];
1278
+ const pages = htmlPages(ctx).filter((p) => p.statusCode === 200);
1279
+ push(out, "ONP.TITLE_MISSING", pages.filter((p) => !p.extract?.title).map((p) => p.url), {});
1280
+ push(out, "ONP.TITLE_MULTIPLE", pages.filter((p) => (p.extract?.titles.length ?? 0) > 1).map((p) => p.url), {});
1281
+ push(out, "ONP.TITLE_OUTSIDE_HEAD", pages.filter((p) => p.extract?.title && !p.extract.titleInHead).map((p) => p.url), {});
1282
+ push(out, "ONP.TITLE_DUPLICATE", dupField(pages, (p) => p.extract?.title ?? ""), {});
1283
+ push(out, "ONP.TITLE_TOO_LONG", pages.filter((p) => (p.extract?.title?.length ?? 0) > 60).map((p) => p.url), {});
1284
+ push(out, "ONP.TITLE_TOO_SHORT", pages
1285
+ .filter((p) => {
1286
+ const n = p.extract?.title?.length ?? 0;
1287
+ return n > 0 && n < 30;
1288
+ })
1289
+ .map((p) => p.url), {});
1290
+ push(out, "ONP.TITLE_PIXEL_OVER", pages.filter((p) => (p.extract?.title?.length ?? 0) > 70).map((p) => p.url), {});
1291
+ push(out, "ONP.TITLE_SAME_AS_H1", pages
1292
+ .filter((p) => p.extract?.title && p.extract.title === p.extract.h1[0])
1293
+ .map((p) => p.url), {});
1294
+ if (hasReq(ctx, "gsc"))
1295
+ push(out, "ONP.TITLE_REWRITTEN_BY_GOOGLE", [], {});
1296
+ push(out, "ONP.META_DESC_MISSING", pages.filter((p) => !p.extract?.metaDescription).map((p) => p.url), {});
1297
+ push(out, "ONP.META_DESC_MULTIPLE", pages
1298
+ .filter((p) => (p.extract?.metaDescriptions.length ?? 0) > 1)
1299
+ .map((p) => p.url), {});
1300
+ push(out, "ONP.META_DESC_DUPLICATE", dupField(pages, (p) => p.extract?.metaDescription ?? ""), {});
1301
+ push(out, "ONP.META_DESC_TOO_LONG", pages
1302
+ .filter((p) => (p.extract?.metaDescription?.length ?? 0) > 155)
1303
+ .map((p) => p.url), {});
1304
+ push(out, "ONP.META_DESC_TOO_SHORT", pages
1305
+ .filter((p) => {
1306
+ const n = p.extract?.metaDescription?.length ?? 0;
1307
+ return n > 0 && n < 70;
1308
+ })
1309
+ .map((p) => p.url), {});
1310
+ push(out, "ONP.META_DESC_OUTSIDE_HEAD", pages
1311
+ .filter((p) => p.extract?.metaDescription && !p.extract.metaDescInHead)
1312
+ .map((p) => p.url), {});
1313
+ push(out, "ONP.H1_MISSING", pages.filter((p) => (p.extract?.h1.length ?? 0) === 0).map((p) => p.url), {});
1314
+ push(out, "ONP.H1_MULTIPLE", pages.filter((p) => (p.extract?.h1.length ?? 0) > 1).map((p) => p.url), {});
1315
+ push(out, "ONP.H1_DUPLICATE", dupField(pages, (p) => p.extract?.h1[0] ?? ""), {});
1316
+ push(out, "ONP.H1_TOO_LONG", pages.filter((p) => (p.extract?.h1[0]?.length ?? 0) > 70).map((p) => p.url), {});
1317
+ push(out, "ONP.H_NON_SEQUENTIAL", pages
1318
+ .filter((p) => {
1319
+ const levels = p.extract?.headings.map((h) => h.level) ?? [];
1320
+ for (let i = 1; i < levels.length; i++) {
1321
+ if ((levels[i] ?? 0) > (levels[i - 1] ?? 0) + 1)
1322
+ return true;
1323
+ }
1324
+ return false;
1325
+ })
1326
+ .map((p) => p.url), {});
1327
+ push(out, "ONP.ALT_TEXT_IN_H1", pages.filter((p) => /<h1[^>]*>\s*<img/i.test(p.html ?? "")).map((p) => p.url), {});
1328
+ push(out, "ONP.NO_VIEWPORT", pages.filter((p) => !p.extract?.viewport).map((p) => p.url), {});
1329
+ push(out, "ONP.VIEWPORT_FIXED_WIDTH", pages
1330
+ .filter((p) => /width\s*=\s*1024|user-scalable\s*=\s*no/i.test(p.extract?.viewport ?? ""))
1331
+ .map((p) => p.url), {});
1332
+ push(out, "ONP.NO_LANG", pages.filter((p) => !p.extract?.lang).map((p) => p.url), {});
1333
+ push(out, "ONP.NO_CHARSET", pages.filter((p) => !p.extract?.charsetInFirst1024).map((p) => p.url), {});
1334
+ push(out, "ONP.SPELLING", [], {});
1335
+ push(out, "ONP.GRAMMAR", [], {});
1336
+ push(out, "ONP.READABILITY", [], {});
1337
+ push(out, "ONP.URL_OVER_115_CHARS", pages.filter((p) => p.url.length > 115).map((p) => p.url), {});
1338
+ push(out, "ONP.URL_UPPERCASE", pages.filter((p) => /[A-Z]/.test(originPath(p.url))).map((p) => p.url), {});
1339
+ push(out, "ONP.URL_UNDERSCORES", pages.filter((p) => originPath(p.url).includes("_")).map((p) => p.url), {});
1340
+ push(out, "ONP.URL_SPACES", pages.filter((p) => p.url.includes("%20") || p.url.includes(" ")).map((p) => p.url), {});
1341
+ push(out, "ONP.URL_NON_ASCII", pages.filter((p) => /[^\u0020-\u007e]/.test(p.url)).map((p) => p.url), {});
1342
+ push(out, "ONP.URL_MULTIPLE_SLASHES", pages.filter((p) => originPath(p.url).includes("//")).map((p) => p.url), {});
1343
+ push(out, "ONP.URL_REPETITIVE_PATH", pages
1344
+ .filter((p) => {
1345
+ const segs = originPath(p.url).split("/").filter(Boolean);
1346
+ return new Set(segs).size < segs.length / 2 && segs.length > 4;
1347
+ })
1348
+ .map((p) => p.url), {});
1349
+ return out;
1350
+ }
1351
+ export function detectImg(ctx) {
1352
+ const out = [];
1353
+ const pages = htmlPages(ctx);
1354
+ push(out, "IMG.MISSING_ALT_ATTR", pages.filter((p) => p.extract?.images.some((i) => !i.hasAltAttr)).map((p) => p.url), {});
1355
+ push(out, "IMG.MISSING_ALT_TEXT", pages
1356
+ .filter((p) => p.extract?.images.some((i) => i.hasAltAttr && i.alt === ""))
1357
+ .map((p) => p.url), {});
1358
+ push(out, "IMG.ALT_TOO_LONG", pages
1359
+ .filter((p) => p.extract?.images.some((i) => (i.alt?.length ?? 0) > 100))
1360
+ .map((p) => p.url), {});
1361
+ push(out, "IMG.ALT_KEYWORD_STUFFED", pages
1362
+ .filter((p) => p.extract?.images.some((i) => {
1363
+ const words = (i.alt ?? "").toLowerCase().split(/\s+/);
1364
+ const freq = new Map();
1365
+ for (const w of words)
1366
+ freq.set(w, (freq.get(w) ?? 0) + 1);
1367
+ return [...freq.values()].some((n) => n >= 4);
1368
+ }))
1369
+ .map((p) => p.url), {});
1370
+ push(out, "IMG.NO_DIMENSIONS", pages
1371
+ .filter((p) => p.extract?.images.some((i) => !i.width && !i.height))
1372
+ .map((p) => p.url), {});
1373
+ push(out, "IMG.OVER_100KB", [], {});
1374
+ push(out, "IMG.INCORRECTLY_SIZED", [], {});
1375
+ push(out, "IMG.LEGACY_FORMAT", pages
1376
+ .filter((p) => p.extract?.images.some((i) => /\.(jpe?g|png)(\?|$)/i.test(i.src)))
1377
+ .map((p) => p.url), {}, 0.4);
1378
+ push(out, "IMG.NO_SRCSET", pages.filter((p) => p.extract?.images.some((i) => !i.srcset)).map((p) => p.url), {});
1379
+ push(out, "IMG.LCP_LAZY", pages.filter((p) => p.extract?.images[0]?.loading === "lazy").map((p) => p.url), {});
1380
+ push(out, "IMG.BELOW_FOLD_EAGER", pages
1381
+ .filter((p) => p.extract?.images.slice(2).some((i) => i.loading !== "lazy"))
1382
+ .map((p) => p.url), {}, 0.4);
1383
+ push(out, "IMG.BROKEN", [], {});
1384
+ push(out, "IMG.BACKGROUND_CONTENT", pages
1385
+ .filter((p) => /background-image:\s*url/i.test(p.html ?? ""))
1386
+ .map((p) => p.url), {}, 0.3);
1387
+ push(out, "IMG.NOT_IN_SITEMAP", [], {});
1388
+ return out;
1389
+ }
1390
+ export function detectMob(ctx) {
1391
+ const out = [];
1392
+ const pages = htmlPages(ctx);
1393
+ push(out, "MOB.NO_VIEWPORT", pages.filter((p) => !p.extract?.viewport).map((p) => p.url), {});
1394
+ push(out, "MOB.CONTENT_WIDER_THAN_SCREEN", [], {});
1395
+ push(out, "MOB.FONT_TOO_SMALL", [], {});
1396
+ push(out, "MOB.TAP_TARGETS", [], {});
1397
+ push(out, "MOB.UNSUPPORTED_PLUGIN", pages.filter((p) => p.extract?.plugins).map((p) => p.url), {});
1398
+ push(out, "MOB.HORIZONTAL_SCROLL", [], {});
1399
+ push(out, "MOB.MOBILE_DESKTOP_CONTENT_GAP", [], {});
1400
+ push(out, "MOB.MOBILE_MISSING_SD", [], {});
1401
+ push(out, "MOB.MOBILE_MISSING_HREFLANG", [], {});
1402
+ push(out, "MOB.SEPARATE_M_DOT", pages.filter((p) => /^m\./i.test(new URL(p.url).hostname)).map((p) => p.url), {});
1403
+ push(out, "MOB.INTERSTITIAL", pages
1404
+ .filter((p) => /interstitial|modal-overlay|cookie-banner/i.test(p.html ?? ""))
1405
+ .map((p) => p.url), {}, 0.4);
1406
+ return out;
1407
+ }
1408
+ export function detectLog(ctx) {
1409
+ const out = [];
1410
+ if (!hasReq(ctx, "logs") || !ctx.logs)
1411
+ return out;
1412
+ const origin = ctx.crawl.origin;
1413
+ const mix = ctx.logs.googlebotStatusMix ?? {};
1414
+ const total = Object.values(mix).reduce((s, n) => s + n, 0) || 1;
1415
+ const non2xx = Object.entries(mix)
1416
+ .filter(([k]) => !k.startsWith("2"))
1417
+ .reduce((s, [, n]) => s + n, 0);
1418
+ if (non2xx / total > 0.2)
1419
+ push(out, "LOG.STATUS_MIX", [origin], { mix });
1420
+ if ((mix["500"] ?? mix["5xx"] ?? 0) > 0)
1421
+ push(out, "LOG.5XX_TO_BOT", [origin], {});
1422
+ push(out, "LOG.CRAWL_WASTE", [], {});
1423
+ push(out, "LOG.UNCRAWLED_IMPORTANT", [], {});
1424
+ push(out, "LOG.ORPHAN_IN_LOGS", [], {});
1425
+ push(out, "LOG.CRAWL_FREQ_BY_TEMPLATE", [], {});
1426
+ push(out, "LOG.FRESHNESS_MISMATCH", [], {});
1427
+ push(out, "LOG.CRAWL_DEPTH_DECAY", [], {});
1428
+ push(out, "LOG.SPIKE", [], {});
1429
+ push(out, "LOG.DROP", [], {});
1430
+ push(out, "LOG.PARAM_WASTE", [], {});
1431
+ push(out, "LOG.SLOW_RESPONSES_TO_BOT", [], {});
1432
+ if ((ctx.logs.fakeBotHits ?? 0) > 0)
1433
+ push(out, "LOG.FAKE_BOT", [origin], { hits: ctx.logs.fakeBotHits });
1434
+ push(out, "LOG.AI_CRAWLER_VOLUME", [], {});
1435
+ push(out, "LOG.MOBILE_VS_DESKTOP_BOT", [], {});
1436
+ push(out, "LOG.LAST_CRAWLED_AGE", [], {});
1437
+ return out;
1438
+ }
1439
+ export function detectMig(ctx) {
1440
+ const out = [];
1441
+ if (!hasReq(ctx, "migration") || !ctx.migration)
1442
+ return out;
1443
+ const map = ctx.migration.urlMap ?? {};
1444
+ const origin = ctx.crawl.origin;
1445
+ push(out, "MIG.MAP_COVERAGE", Object.keys(map).length === 0 ? [origin] : [], {});
1446
+ push(out, "MIG.MAP_ONE_HOP", [], {});
1447
+ push(out, "MIG.MAP_RELEVANCE", [], {});
1448
+ const homes = Object.values(map).filter((v) => originPath(v) === "/").length;
1449
+ if (Object.keys(map).length && homes / Object.keys(map).length > 0.05) {
1450
+ push(out, "MIG.NO_MASS_HOMEPAGE", [origin], {
1451
+ ratio: homes / Object.keys(map).length,
1452
+ });
1453
+ }
1454
+ push(out, "MIG.OLD_SITEMAP_RETAINED", [], {});
1455
+ push(out, "MIG.CANONICAL_UPDATED", htmlPages(ctx)
1456
+ .filter((p) => absCanonical(p) && !selfCanonical(p))
1457
+ .map((p) => p.url), {});
1458
+ push(out, "MIG.HREFLANG_UPDATED", [], {});
1459
+ push(out, "MIG.INTERNAL_LINKS_UPDATED", [], {});
1460
+ if (ctx.crawl.robots?.disallowAll)
1461
+ push(out, "MIG.ROBOTS_MIGRATED", [origin], {});
1462
+ push(out, "MIG.NOINDEX_LEAK", htmlPages(ctx)
1463
+ .filter(noindexOf)
1464
+ .map((p) => p.url), {});
1465
+ if (hasReq(ctx, "gsc")) {
1466
+ push(out, "MIG.GSC_NEW_PROPERTY", [], {});
1467
+ push(out, "MIG.CHANGE_OF_ADDRESS", [], {});
1468
+ push(out, "MIG.TRAFFIC_DELTA", [], {});
1469
+ }
1470
+ push(out, "MIG.ANALYTICS_CONTINUITY", htmlPages(ctx)
1471
+ .filter((p) => !/gtag|googletagmanager|ga4/i.test(p.html ?? ""))
1472
+ .map((p) => p.url), {});
1473
+ push(out, "MIG.STRUCTURED_DATA_PARITY", [], {});
1474
+ if (hasReq(ctx, "crux"))
1475
+ push(out, "MIG.PERF_PARITY", [], {});
1476
+ push(out, "MIG.BACKLINK_TARGETS_ALIVE", [], {});
1477
+ push(out, "MIG.REDIRECT_EXPIRY", [], {});
1478
+ return out;
1479
+ }
1480
+ export function detectConf(ctx) {
1481
+ const out = [];
1482
+ const pages = htmlPages(ctx);
1483
+ const sm = sitemapSet(ctx);
1484
+ push(out, "CONF.NOINDEX_IN_SITEMAP", pages.filter((p) => noindexOf(p) && sm.has(p.url)).map((p) => p.url), {});
1485
+ push(out, "CONF.DISALLOW_PLUS_NOINDEX", pages.filter((p) => !p.robotsAllowed && noindexOf(p)).map((p) => p.url), {});
1486
+ push(out, "CONF.CANON_VS_HREFLANG", pages
1487
+ .filter((p) => {
1488
+ const c = absCanonical(p);
1489
+ if (!c || (p.extract?.hreflang.length ?? 0) === 0)
1490
+ return false;
1491
+ return !p.extract?.hreflang.some((h) => stripSlash(h.absUrl) === stripSlash(c));
1492
+ })
1493
+ .map((p) => p.url), {});
1494
+ push(out, "CONF.CANON_VS_REDIRECT", pages.filter((p) => hops(p) >= 1 && absCanonical(p)).map((p) => p.url), {});
1495
+ if (hasReq(ctx, "gsc") && ctx.gsc?.googleCanonicalByUrl) {
1496
+ push(out, "CONF.GOOGLE_CHOSE_OTHER", Object.entries(ctx.gsc.googleCanonicalByUrl)
1497
+ .filter(([u, g]) => {
1498
+ const p = pages.find((x) => x.url === u);
1499
+ if (!p)
1500
+ return false;
1501
+ const user = absCanonical(p);
1502
+ return Boolean(user && stripSlash(g) !== stripSlash(user));
1503
+ })
1504
+ .map(([u]) => u), {});
1505
+ }
1506
+ push(out, "CONF.SITEMAP_VS_NOINDEX_HEADER", pages
1507
+ .filter((p) => sm.has(p.url) &&
1508
+ (p.extract?.xRobotsTag ?? "").toLowerCase().includes("noindex"))
1509
+ .map((p) => p.url), {});
1510
+ push(out, "CONF.PAGINATED_CANON_TO_P1", pages
1511
+ .filter((p) => /[?&]page=\d+/i.test(p.url) && /page=1/i.test(absCanonical(p) ?? ""))
1512
+ .map((p) => p.url), {});
1513
+ return out;
1514
+ }
1515
+ export function detectGap(ctx) {
1516
+ const out = [];
1517
+ const pages = ctx.crawl.pages.filter((p) => p.isInternal);
1518
+ const sm = sitemapSet(ctx);
1519
+ push(out, "GAP.NOT_IN_SITEMAP", pages
1520
+ .filter((p) => isIndexable(p) && !sm.has(p.url) && !p.inSitemap)
1521
+ .map((p) => p.url), {});
1522
+ push(out, "GAP.ORPHAN_SITEMAP", pages.filter((p) => p.inSitemap && p.followInlinkCount === 0).map((p) => p.url), {});
1523
+ if (hasReq(ctx, "gsc")) {
1524
+ push(out, "GAP.ORPHAN_INDEXED", [], {});
1525
+ push(out, "GAP.INDEXED_NOT_SUBMITTED", [], {});
1526
+ push(out, "GAP.SUBMITTED_NOT_INDEXED", [], {});
1527
+ }
1528
+ if (hasReq(ctx, "logs"))
1529
+ push(out, "GAP.ORPHAN_LOGS", [], {});
1530
+ return out;
1531
+ }
1532
+ export function detectSoft404(ctx) {
1533
+ const out = [];
1534
+ const phrase = /\b(page not found|not found|404|no results|nothing found|this page (doesn't|does not) exist|error 404|no longer available|coming soon|under construction)\b/i;
1535
+ if (ctx.crawl.originProbe.randomSoft404) {
1536
+ push(out, "SOFT404.RANDOM_URL_200", [ctx.crawl.originProbe.randomSoft404Url ?? ctx.crawl.origin], {});
1537
+ }
1538
+ push(out, "SOFT404.PHRASE", htmlPages(ctx)
1539
+ .filter((p) => p.statusCode === 200 &&
1540
+ phrase.test((p.extract?.mainText ?? "").slice(0, 500)))
1541
+ .map((p) => p.url), {});
1542
+ push(out, "SOFT404.EMPTY_LISTING", htmlPages(ctx)
1543
+ .filter((p) => p.statusCode === 200 &&
1544
+ /0 products|no products|no results/i.test(p.extract?.mainText ?? ""))
1545
+ .map((p) => p.url), {});
1546
+ if (hasReq(ctx, "gsc")) {
1547
+ const confirmed = Object.entries(ctx.gsc?.coverageStateByUrl ?? {})
1548
+ .filter(([, s]) => /soft 404/i.test(s))
1549
+ .map(([u]) => u);
1550
+ push(out, "SOFT404.GSC_CONFIRMED", confirmed, {});
1551
+ }
1552
+ push(out, "SOFT404.SPA_ROUTE", htmlPages(ctx)
1553
+ .filter((p) => p.statusCode === 200 &&
1554
+ p.extract?.spaRootEmpty &&
1555
+ phrase.test(p.extract?.allText ?? ""))
1556
+ .map((p) => p.url), {});
1557
+ push(out, "SOFT404.REDIRECT_TO_HOME", ctx.crawl.pages
1558
+ .filter((p) => hops(p) >= 1 && originPath(p.finalUrl) === "/" && originPath(p.url) !== "/")
1559
+ .map((p) => p.url), {});
1560
+ push(out, "SOFT404.OOS_PRODUCT", htmlPages(ctx)
1561
+ .filter((p) => /out of stock|sold out/i.test(p.extract?.allText ?? "") &&
1562
+ /\/product\//i.test(p.url))
1563
+ .map((p) => p.url), {});
1564
+ return out;
1565
+ }
1566
+ export const FAMILY_DETECTORS = {
1567
+ RESP: detectResp,
1568
+ CANON: detectCanon,
1569
+ ROBOTS: detectRobots,
1570
+ DIRECT: detectDirect,
1571
+ SMAP: detectSmap,
1572
+ CRAWL: detectCrawl,
1573
+ LINK: detectLink,
1574
+ PAGE: detectPage,
1575
+ PARAM: detectParam,
1576
+ DUP: detectDup,
1577
+ THIN: detectThin,
1578
+ JS: detectJs,
1579
+ CWV: detectCwv,
1580
+ PERF: detectPerf,
1581
+ SEC: detectSec,
1582
+ HREF: detectHref,
1583
+ SD: detectSd,
1584
+ ONP: detectOnp,
1585
+ IMG: detectImg,
1586
+ MOB: detectMob,
1587
+ LOG: detectLog,
1588
+ MIG: detectMig,
1589
+ CONF: detectConf,
1590
+ GAP: detectGap,
1591
+ SOFT404: detectSoft404,
1592
+ };
1593
+ export function detectAll(ctx) {
1594
+ const out = [];
1595
+ for (const [cat, fn] of Object.entries(FAMILY_DETECTORS)) {
1596
+ const n = checksByCategory(cat).length;
1597
+ if (n === 0)
1598
+ continue;
1599
+ out.push(...fn(ctx));
1600
+ }
1601
+ const byRule = new Map();
1602
+ for (const f of out) {
1603
+ const prev = byRule.get(f.ruleId);
1604
+ if (!prev) {
1605
+ byRule.set(f.ruleId, f);
1606
+ continue;
1607
+ }
1608
+ prev.urls = [...new Set([...prev.urls, ...f.urls])];
1609
+ }
1610
+ return [...byRule.values()].filter((f) => f.urls.length > 0);
1611
+ }
1612
+ export function detectorFor(id) {
1613
+ const cat = id.split(".")[0] ?? "";
1614
+ const fn = FAMILY_DETECTORS[cat];
1615
+ if (!fn)
1616
+ return () => [];
1617
+ return (ctx) => fn(ctx).filter((f) => f.ruleId === id);
1618
+ }
1619
+ //# sourceMappingURL=all.js.map