search-auditor 0.1.1 → 0.2.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 (225) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +87 -38
  3. package/dist/cli.js +17 -9
  4. package/dist/collect.d.ts +5 -1
  5. package/dist/collect.js +186 -11
  6. package/dist/i18n/en.d.ts +462 -7
  7. package/dist/i18n/en.js +480 -25
  8. package/dist/i18n/index.d.ts +924 -14
  9. package/dist/i18n/index.js +1 -1
  10. package/dist/i18n/ru.js +484 -24
  11. package/dist/index.d.ts +3 -145
  12. package/dist/index.js +14 -53
  13. package/dist/rules/can/can-1.d.ts +2 -0
  14. package/dist/rules/can/can-1.js +14 -0
  15. package/dist/rules/can/can-2.d.ts +2 -0
  16. package/dist/rules/can/can-2.js +20 -0
  17. package/dist/rules/can/can-3.d.ts +2 -0
  18. package/dist/rules/can/can-3.js +32 -0
  19. package/dist/rules/can/can-4.d.ts +2 -0
  20. package/dist/rules/can/can-4.js +18 -0
  21. package/dist/rules/can/can-5.d.ts +2 -0
  22. package/dist/rules/can/can-5.js +17 -0
  23. package/dist/rules/can/can-6.d.ts +2 -0
  24. package/dist/rules/can/can-6.js +22 -0
  25. package/dist/rules/can/can-7.d.ts +2 -0
  26. package/dist/rules/can/can-7.js +32 -0
  27. package/dist/rules/can/can-8.d.ts +2 -0
  28. package/dist/rules/can/can-8.js +23 -0
  29. package/dist/rules/can/can-9.d.ts +2 -0
  30. package/dist/rules/can/can-9.js +24 -0
  31. package/dist/rules/can/index.d.ts +7 -0
  32. package/dist/rules/can/index.js +10 -0
  33. package/dist/rules/can/shared.d.ts +11 -0
  34. package/dist/rules/can/shared.js +26 -0
  35. package/dist/rules/data/data-1.d.ts +2 -0
  36. package/dist/rules/data/data-1.js +16 -0
  37. package/dist/rules/data/data-2.d.ts +2 -0
  38. package/dist/rules/data/data-2.js +25 -0
  39. package/dist/rules/data/data-3.d.ts +2 -0
  40. package/dist/rules/data/data-3.js +29 -0
  41. package/dist/rules/data/data-4.d.ts +2 -0
  42. package/dist/rules/data/data-4.js +17 -0
  43. package/dist/rules/data/data-5.d.ts +2 -0
  44. package/dist/rules/data/data-5.js +28 -0
  45. package/dist/rules/data/data-6.d.ts +2 -0
  46. package/dist/rules/data/data-6.js +16 -0
  47. package/dist/rules/data/index.d.ts +8 -0
  48. package/dist/rules/data/index.js +7 -0
  49. package/dist/rules/data/shared.d.ts +22 -0
  50. package/dist/rules/data/shared.js +59 -0
  51. package/dist/rules/idx/idx-1.d.ts +2 -0
  52. package/dist/rules/idx/idx-1.js +15 -0
  53. package/dist/rules/idx/idx-2.d.ts +2 -0
  54. package/dist/rules/idx/idx-2.js +22 -0
  55. package/dist/rules/idx/idx-3.d.ts +2 -0
  56. package/dist/rules/idx/idx-3.js +35 -0
  57. package/dist/rules/idx/idx-4.d.ts +2 -0
  58. package/dist/rules/idx/idx-4.js +40 -0
  59. package/dist/rules/idx/idx-5.d.ts +2 -0
  60. package/dist/rules/idx/idx-5.js +24 -0
  61. package/dist/rules/idx/idx-6.d.ts +2 -0
  62. package/dist/rules/idx/idx-6.js +25 -0
  63. package/dist/rules/idx/idx-7.d.ts +2 -0
  64. package/dist/rules/idx/idx-7.js +29 -0
  65. package/dist/rules/idx/idx-8.d.ts +2 -0
  66. package/dist/rules/idx/idx-8.js +25 -0
  67. package/dist/rules/idx/idx-9.d.ts +2 -0
  68. package/dist/rules/idx/idx-9.js +22 -0
  69. package/dist/rules/idx/index.d.ts +7 -0
  70. package/dist/rules/idx/index.js +10 -0
  71. package/dist/rules/idx/shared.d.ts +18 -0
  72. package/dist/rules/idx/shared.js +18 -0
  73. package/dist/rules/img/img-1.d.ts +2 -0
  74. package/dist/rules/img/img-1.js +18 -0
  75. package/dist/rules/img/img-2.d.ts +2 -0
  76. package/dist/rules/img/img-2.js +20 -0
  77. package/dist/rules/img/img-3.d.ts +2 -0
  78. package/dist/rules/img/img-3.js +24 -0
  79. package/dist/rules/img/img-4.d.ts +2 -0
  80. package/dist/rules/img/img-4.js +19 -0
  81. package/dist/rules/img/img-5.d.ts +2 -0
  82. package/dist/rules/img/img-5.js +18 -0
  83. package/dist/rules/img/img-6.d.ts +2 -0
  84. package/dist/rules/img/img-6.js +20 -0
  85. package/dist/rules/img/img-7.d.ts +2 -0
  86. package/dist/rules/img/img-7.js +22 -0
  87. package/dist/rules/img/index.d.ts +7 -0
  88. package/dist/rules/img/index.js +8 -0
  89. package/dist/rules/img/shared.d.ts +26 -0
  90. package/dist/rules/img/shared.js +38 -0
  91. package/dist/rules/index.d.ts +1 -1
  92. package/dist/rules/index.js +23 -6
  93. package/dist/rules/lang/index.d.ts +8 -0
  94. package/dist/rules/lang/index.js +9 -0
  95. package/dist/rules/lang/lang-1.d.ts +2 -0
  96. package/dist/rules/lang/lang-1.js +17 -0
  97. package/dist/rules/lang/lang-2.d.ts +2 -0
  98. package/dist/rules/lang/lang-2.js +30 -0
  99. package/dist/rules/lang/lang-3.d.ts +2 -0
  100. package/dist/rules/lang/lang-3.js +22 -0
  101. package/dist/rules/lang/lang-4.d.ts +2 -0
  102. package/dist/rules/lang/lang-4.js +23 -0
  103. package/dist/rules/lang/lang-5.d.ts +2 -0
  104. package/dist/rules/lang/lang-5.js +33 -0
  105. package/dist/rules/lang/lang-6.d.ts +2 -0
  106. package/dist/rules/lang/lang-6.js +25 -0
  107. package/dist/rules/lang/lang-7.d.ts +2 -0
  108. package/dist/rules/lang/lang-7.js +25 -0
  109. package/dist/rules/lang/lang-8.d.ts +2 -0
  110. package/dist/rules/lang/lang-8.js +19 -0
  111. package/dist/rules/lang/shared.d.ts +7 -0
  112. package/dist/rules/lang/shared.js +5 -0
  113. package/dist/rules/lnk/index.d.ts +8 -0
  114. package/dist/rules/lnk/index.js +8 -0
  115. package/dist/rules/lnk/lnk-1.d.ts +2 -0
  116. package/dist/rules/lnk/lnk-1.js +22 -0
  117. package/dist/rules/lnk/lnk-2.d.ts +2 -0
  118. package/dist/rules/lnk/lnk-2.js +18 -0
  119. package/dist/rules/lnk/lnk-3.d.ts +2 -0
  120. package/dist/rules/lnk/lnk-3.js +32 -0
  121. package/dist/rules/lnk/lnk-4.d.ts +2 -0
  122. package/dist/rules/lnk/lnk-4.js +18 -0
  123. package/dist/rules/lnk/lnk-5.d.ts +2 -0
  124. package/dist/rules/lnk/lnk-5.js +22 -0
  125. package/dist/rules/lnk/lnk-6.d.ts +2 -0
  126. package/dist/rules/lnk/lnk-6.js +24 -0
  127. package/dist/rules/lnk/lnk-7.d.ts +2 -0
  128. package/dist/rules/lnk/lnk-7.js +26 -0
  129. package/dist/rules/lnk/shared.d.ts +20 -0
  130. package/dist/rules/lnk/shared.js +29 -0
  131. package/dist/rules/map/index.d.ts +7 -0
  132. package/dist/rules/map/index.js +9 -0
  133. package/dist/rules/map/map-1.d.ts +2 -0
  134. package/dist/rules/map/map-1.js +15 -0
  135. package/dist/rules/map/map-2.d.ts +2 -0
  136. package/dist/rules/map/map-2.js +26 -0
  137. package/dist/rules/map/map-3.d.ts +2 -0
  138. package/dist/rules/map/map-3.js +24 -0
  139. package/dist/rules/map/map-4.d.ts +2 -0
  140. package/dist/rules/map/map-4.js +34 -0
  141. package/dist/rules/map/map-5.d.ts +2 -0
  142. package/dist/rules/map/map-5.js +28 -0
  143. package/dist/rules/map/map-6.d.ts +2 -0
  144. package/dist/rules/map/map-6.js +22 -0
  145. package/dist/rules/map/map-7.d.ts +2 -0
  146. package/dist/rules/map/map-7.js +18 -0
  147. package/dist/rules/map/map-8.d.ts +2 -0
  148. package/dist/rules/map/map-8.js +31 -0
  149. package/dist/rules/map/shared.d.ts +14 -0
  150. package/dist/rules/map/shared.js +12 -0
  151. package/dist/rules/meta/index.d.ts +8 -0
  152. package/dist/rules/meta/index.js +10 -0
  153. package/dist/rules/meta/meta-1.d.ts +2 -0
  154. package/dist/rules/meta/meta-1.js +20 -0
  155. package/dist/rules/meta/meta-2.d.ts +2 -0
  156. package/dist/rules/meta/meta-2.js +19 -0
  157. package/dist/rules/meta/meta-3.d.ts +2 -0
  158. package/dist/rules/meta/meta-3.js +26 -0
  159. package/dist/rules/meta/meta-4.d.ts +2 -0
  160. package/dist/rules/meta/meta-4.js +22 -0
  161. package/dist/rules/meta/meta-5.d.ts +2 -0
  162. package/dist/rules/meta/meta-5.js +17 -0
  163. package/dist/rules/meta/meta-6.d.ts +2 -0
  164. package/dist/rules/meta/meta-6.js +18 -0
  165. package/dist/rules/meta/meta-7.d.ts +2 -0
  166. package/dist/rules/meta/meta-7.js +18 -0
  167. package/dist/rules/meta/meta-8.d.ts +2 -0
  168. package/dist/rules/meta/meta-8.js +17 -0
  169. package/dist/rules/meta/meta-9.d.ts +2 -0
  170. package/dist/rules/meta/meta-9.js +16 -0
  171. package/dist/rules/meta/shared.d.ts +15 -0
  172. package/dist/rules/meta/shared.js +9 -0
  173. package/dist/rules/sec/index.d.ts +8 -0
  174. package/dist/rules/sec/index.js +8 -0
  175. package/dist/rules/sec/sec-1.d.ts +2 -0
  176. package/dist/rules/sec/sec-1.js +21 -0
  177. package/dist/rules/sec/sec-2.d.ts +2 -0
  178. package/dist/rules/sec/sec-2.js +17 -0
  179. package/dist/rules/sec/sec-3.d.ts +2 -0
  180. package/dist/rules/sec/sec-3.js +18 -0
  181. package/dist/rules/sec/sec-4.d.ts +2 -0
  182. package/dist/rules/sec/sec-4.js +23 -0
  183. package/dist/rules/sec/sec-5.d.ts +2 -0
  184. package/dist/rules/sec/sec-5.js +23 -0
  185. package/dist/rules/sec/sec-6.d.ts +2 -0
  186. package/dist/rules/sec/sec-6.js +32 -0
  187. package/dist/rules/sec/sec-7.d.ts +2 -0
  188. package/dist/rules/sec/sec-7.js +26 -0
  189. package/dist/rules/sec/shared.d.ts +10 -0
  190. package/dist/rules/sec/shared.js +8 -0
  191. package/dist/rules/str/index.d.ts +8 -0
  192. package/dist/rules/str/index.js +11 -0
  193. package/dist/rules/str/shared.d.ts +14 -0
  194. package/dist/rules/str/shared.js +16 -0
  195. package/dist/rules/str/str-1.d.ts +2 -0
  196. package/dist/rules/str/str-1.js +17 -0
  197. package/dist/rules/str/str-10.d.ts +2 -0
  198. package/dist/rules/str/str-10.js +15 -0
  199. package/dist/rules/str/str-2.d.ts +2 -0
  200. package/dist/rules/str/str-2.js +19 -0
  201. package/dist/rules/str/str-3.d.ts +2 -0
  202. package/dist/rules/str/str-3.js +14 -0
  203. package/dist/rules/str/str-4.d.ts +2 -0
  204. package/dist/rules/str/str-4.js +14 -0
  205. package/dist/rules/str/str-5.d.ts +2 -0
  206. package/dist/rules/str/str-5.js +19 -0
  207. package/dist/rules/str/str-6.d.ts +2 -0
  208. package/dist/rules/str/str-6.js +22 -0
  209. package/dist/rules/str/str-7.d.ts +2 -0
  210. package/dist/rules/str/str-7.js +18 -0
  211. package/dist/rules/str/str-8.d.ts +2 -0
  212. package/dist/rules/str/str-8.js +24 -0
  213. package/dist/rules/str/str-9.d.ts +2 -0
  214. package/dist/rules/str/str-9.js +14 -0
  215. package/dist/rules/util.d.ts +1 -1
  216. package/dist/trace.d.ts +20 -0
  217. package/dist/trace.js +77 -0
  218. package/dist/types.d.ts +86 -6
  219. package/package.json +4 -9
  220. package/dist/rules/links.d.ts +0 -2
  221. package/dist/rules/links.js +0 -95
  222. package/dist/rules/page.d.ts +0 -2
  223. package/dist/rules/page.js +0 -145
  224. package/dist/rules/site.d.ts +0 -2
  225. package/dist/rules/site.js +0 -177
@@ -0,0 +1,29 @@
1
+ /** DATA-3: Rich-result types have the properties Google requires. */
2
+ import { finder } from "auditor-engine";
3
+ import { REQUIRED, typesOf } from "./shared.js";
4
+ /** Present and not empty: "", [] and {} do not count. */
5
+ const has = (n, key) => {
6
+ const v = n[key];
7
+ return v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && !v.length);
8
+ };
9
+ export const data3 = {
10
+ id: "DATA-3",
11
+ group: "DATA",
12
+ level: "warn",
13
+ applies: (site, t) => (site.pages.some((p) => p.jsonLd.some((n) => typesOf(n).some((x) => REQUIRED[x]))) ? null : t.skipNoRichTypes),
14
+ run(site, _c, t) {
15
+ const { out, add } = finder(this);
16
+ for (const p of site.pages) {
17
+ const missing = new Map();
18
+ for (const n of p.jsonLd)
19
+ for (const type of typesOf(n)) {
20
+ const absent = (REQUIRED[type] ?? []).filter((req) => !req.split("|").some((k) => has(n, k)));
21
+ if (absent.length)
22
+ missing.set(type, new Set([...(missing.get(type) ?? []), ...absent.map((a) => a.replace(/\|/g, " / "))]));
23
+ }
24
+ for (const [type, keys] of missing)
25
+ add(p.fetched.publicUrl, t.missingProperties(type, [...keys].join(", ")));
26
+ }
27
+ return out;
28
+ },
29
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const data4: SeoRule;
@@ -0,0 +1,17 @@
1
+ /** DATA-4: Nested pages have breadcrumbs. */
2
+ import { finder } from "auditor-engine";
3
+ import { BREADCRUMB_DEPTH, depthOf, typesOf } from "./shared.js";
4
+ export const data4 = {
5
+ id: "DATA-4",
6
+ group: "DATA",
7
+ level: "info",
8
+ applies: (site, t) => (site.pages.some((p) => depthOf(p.fetched.publicUrl) >= BREADCRUMB_DEPTH) ? null : t.skipNoNestedPages),
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ // Google shows the trail instead of the bare address under the title: «Blog › AI agents».
12
+ for (const p of site.pages)
13
+ if (depthOf(p.fetched.publicUrl) >= BREADCRUMB_DEPTH && !p.jsonLd.some((n) => typesOf(n).includes("BreadcrumbList")))
14
+ add(p.fetched.publicUrl, t.noBreadcrumbs);
15
+ return out;
16
+ },
17
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const data5: SeoRule;
@@ -0,0 +1,28 @@
1
+ /** DATA-5: Addresses and dates in the markup are in a format search engines read. */
2
+ import { finder } from "auditor-engine";
3
+ import { short } from "../util.js";
4
+ import { DATE_KEYS, ISO_DATE, URL_KEYS } from "./shared.js";
5
+ /** String values of a property: a plain string or each string of an array. */
6
+ const strings = (n, key) => {
7
+ const v = n[key];
8
+ return (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
9
+ };
10
+ export const data5 = {
11
+ id: "DATA-5",
12
+ group: "DATA",
13
+ level: "warn",
14
+ applies: (site, t) => (site.pages.some((p) => p.jsonLd.length) ? null : t.skipNoJsonLd),
15
+ run(site, _c, t) {
16
+ const { out, add } = finder(this);
17
+ for (const p of site.pages) {
18
+ // Relative addresses are resolved against nothing in JSON-LD: «/logo.png» is not an address there.
19
+ const relative = p.jsonLd.flatMap((n) => URL_KEYS.flatMap((k) => strings(n, k).map((v) => `${k}: ${v}`))).filter((s) => !/: (https?:\/\/|#)/i.test(s));
20
+ const dates = p.jsonLd.flatMap((n) => DATE_KEYS.flatMap((k) => strings(n, k).map((v) => ({ k, v })))).filter(({ v }) => !ISO_DATE.test(v.trim()));
21
+ if (relative.length)
22
+ add(p.fetched.publicUrl, t.relativeInMarkup(relative.length, short(relative[0], 60)));
23
+ if (dates.length)
24
+ add(p.fetched.publicUrl, t.badDatesInMarkup(dates.length, `${dates[0].k}: ${short(dates[0].v, 30)}`));
25
+ }
26
+ return out;
27
+ },
28
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const data6: SeoRule;
@@ -0,0 +1,16 @@
1
+ /** DATA-6: The home page names the site with WebSite. */
2
+ import { finder } from "auditor-engine";
3
+ import { typesOf } from "./shared.js";
4
+ export const data6 = {
5
+ id: "DATA-6",
6
+ group: "DATA",
7
+ level: "info",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ // Google takes the site name shown above results from WebSite on the home page; without it, it guesses.
11
+ const home = site.pages.find((p) => new URL(p.fetched.publicUrl).pathname === "/");
12
+ if (home && !home.jsonLd.some((n) => typesOf(n).includes("WebSite") && typeof n.name === "string" && n.name.trim()))
13
+ add(home.fetched.publicUrl, t.noWebSite);
14
+ return out;
15
+ },
16
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Structured data (DATA): does the JSON-LD on the pages parse and say enough for rich results in search.
3
+ * No syntax errors, @context and @type on every entity, the properties Google requires for rich-result types,
4
+ * breadcrumbs on nested pages, full addresses and ISO dates, and the site name in WebSite on the home page.
5
+ * What the markup says about authorship, articles and FAQs is checked by the AI search rules (C group).
6
+ */
7
+ import type { SeoRule } from "../util.ts";
8
+ export declare const dataRules: SeoRule[];
@@ -0,0 +1,7 @@
1
+ import { data1 } from "./data-1.js";
2
+ import { data2 } from "./data-2.js";
3
+ import { data3 } from "./data-3.js";
4
+ import { data4 } from "./data-4.js";
5
+ import { data5 } from "./data-5.js";
6
+ import { data6 } from "./data-6.js";
7
+ export const dataRules = [data1, data2, data3, data4, data5, data6];
@@ -0,0 +1,22 @@
1
+ /** Constants and helpers shared by the DATA checks. */
2
+ import { type JsonLdNode } from "auditor-engine";
3
+ /** The JSON-LD blocks of the raw HTML that parse, as written: the top level keeps @context, flattening loses it. */
4
+ export declare function blocks(html: string): unknown[];
5
+ /** The entities a block describes at its top level: the block itself, the items of an array, or of @graph. */
6
+ export declare const topNodes: (block: unknown) => JsonLdNode[];
7
+ /** A node that only points at another one: {"@id": "…#person"}. It needs no @type of its own. */
8
+ export declare const isReference: (n: JsonLdNode) => boolean;
9
+ export declare const typesOf: (n: JsonLdNode) => string[];
10
+ /**
11
+ * What Google needs to show a rich result, per type: every name in a list is required; «a|b» means one of them.
12
+ * Article and FAQPage are checked by the AI search rules (C-4, C-7).
13
+ */
14
+ export declare const REQUIRED: Record<string, string[]>;
15
+ /** Properties that hold an address, and those that hold a date. */
16
+ export declare const URL_KEYS: string[];
17
+ export declare const DATE_KEYS: string[];
18
+ /** ISO 8601 as Google reads it: a date, or a date and time with an optional zone. */
19
+ export declare const ISO_DATE: RegExp;
20
+ /** Pages this deep or deeper get a breadcrumb trail; a language prefix (/ru) does not count as a level. */
21
+ export declare const BREADCRUMB_DEPTH = 2;
22
+ export declare const depthOf: (url: string) => number;
@@ -0,0 +1,59 @@
1
+ /** Constants and helpers shared by the DATA checks. */
2
+ import { jsonLdTypes } from "auditor-engine";
3
+ /** The JSON-LD blocks of the raw HTML that parse, as written: the top level keeps @context, flattening loses it. */
4
+ export function blocks(html) {
5
+ const out = [];
6
+ for (const m of html.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi)) {
7
+ try {
8
+ out.push(JSON.parse(m[1] ?? ""));
9
+ }
10
+ catch {
11
+ // A block that does not parse is DATA-1's finding.
12
+ }
13
+ }
14
+ return out;
15
+ }
16
+ /** The entities a block describes at its top level: the block itself, the items of an array, or of @graph. */
17
+ export const topNodes = (block) => {
18
+ const list = Array.isArray(block) ? block : [block];
19
+ return list.flatMap((b) => {
20
+ if (!b || typeof b !== "object")
21
+ return [];
22
+ const graph = b["@graph"];
23
+ return Array.isArray(graph) ? graph : [b];
24
+ });
25
+ };
26
+ /** A node that only points at another one: {"@id": "…#person"}. It needs no @type of its own. */
27
+ export const isReference = (n) => Object.keys(n).every((k) => k === "@id");
28
+ export const typesOf = (n) => jsonLdTypes(n);
29
+ /**
30
+ * What Google needs to show a rich result, per type: every name in a list is required; «a|b» means one of them.
31
+ * Article and FAQPage are checked by the AI search rules (C-4, C-7).
32
+ */
33
+ export const REQUIRED = {
34
+ Product: ["name", "offers|review|aggregateRating"],
35
+ Offer: ["price|priceSpecification"],
36
+ Event: ["name", "startDate", "location"],
37
+ Recipe: ["name", "image"],
38
+ VideoObject: ["name", "thumbnailUrl", "uploadDate"],
39
+ LocalBusiness: ["name", "address"],
40
+ Restaurant: ["name", "address"],
41
+ Store: ["name", "address"],
42
+ JobPosting: ["title", "datePosted", "description", "hiringOrganization"],
43
+ Course: ["name", "description"],
44
+ AggregateRating: ["ratingValue", "ratingCount|reviewCount"],
45
+ Review: ["reviewRating", "author"],
46
+ BreadcrumbList: ["itemListElement"],
47
+ ListItem: ["position"],
48
+ };
49
+ /** Properties that hold an address, and those that hold a date. */
50
+ export const URL_KEYS = ["url", "image", "logo", "thumbnailUrl", "contentUrl", "sameAs", "item"];
51
+ export const DATE_KEYS = ["datePublished", "dateModified", "dateCreated", "uploadDate", "startDate", "endDate", "datePosted", "validThrough", "validFrom", "priceValidUntil"];
52
+ /** ISO 8601 as Google reads it: a date, or a date and time with an optional zone. */
53
+ export const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
54
+ /** Pages this deep or deeper get a breadcrumb trail; a language prefix (/ru) does not count as a level. */
55
+ export const BREADCRUMB_DEPTH = 2;
56
+ export const depthOf = (url) => {
57
+ const parts = new URL(url).pathname.split("/").filter(Boolean);
58
+ return parts.length - (parts[0] && /^[a-z]{2}(-[a-z]{2})?$/i.test(parts[0]) ? 1 : 0);
59
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx1: SeoRule;
@@ -0,0 +1,15 @@
1
+ /** IDX-1: Pages answer 200. */
2
+ import { finder } from "auditor-engine";
3
+ import { statusOf } from "../util.js";
4
+ export const idx1 = {
5
+ id: "IDX-1",
6
+ group: "IDX",
7
+ level: "error",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ for (const f of site.failed)
11
+ if (f.status !== 200)
12
+ add(f.publicUrl, t.status(statusOf(f)));
13
+ return out;
14
+ },
15
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx2: SeoRule;
@@ -0,0 +1,22 @@
1
+ /** IDX-2: Missing pages answer 404. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { rel, short } from "../util.js";
5
+ import { NOT_FOUND } from "./shared.js";
6
+ export const idx2 = {
7
+ id: "IDX-2",
8
+ group: "IDX",
9
+ level: "warn",
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const probe = seoOf(site).probe404;
13
+ if (probe && probe.status === 200)
14
+ add("site", t.soft404Probe(rel(probe.publicUrl, site.base)));
15
+ for (const p of site.pages) {
16
+ const head = p.h1[0] ?? "";
17
+ if (NOT_FOUND.test(p.title) || NOT_FOUND.test(head))
18
+ add(p.fetched.publicUrl, t.soft404Page(short(p.title || head)));
19
+ }
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx3: SeoRule;
@@ -0,0 +1,35 @@
1
+ /** IDX-3: robots.txt answers without errors. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { short, statusOf } from "../util.js";
5
+ import { ROBOTS_KEYS, ROBOTS_LIMIT } from "./shared.js";
6
+ export const idx3 = {
7
+ id: "IDX-3",
8
+ group: "IDX",
9
+ level: "error",
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const r = seoOf(site).robotsRaw;
13
+ const where = `${site.base}/robots.txt`;
14
+ // 5xx, 429 and no answer: Google stops crawling. Other 4xx mean «no rules», which is fine.
15
+ if (r.status === 0 || r.status === 429 || r.status >= 500) {
16
+ add(where, t.robotsStatus(statusOf(r)));
17
+ return out;
18
+ }
19
+ if (r.status !== 200)
20
+ return out;
21
+ if (/^\s*<(!doctype|html)/i.test(r.body)) {
22
+ add(where, t.robotsHtml, "warn");
23
+ return out;
24
+ }
25
+ if (Buffer.byteLength(r.body) > ROBOTS_LIMIT)
26
+ add(where, t.robotsSize(Math.round(Buffer.byteLength(r.body) / 1024)), "warn");
27
+ const bad = r.body
28
+ .split(/\r?\n/)
29
+ .map((l) => l.replace(/#.*/, "").trim())
30
+ .filter((l) => l && !ROBOTS_KEYS.has((l.match(/^([a-z-]+)\s*:/i)?.[1] ?? "").toLowerCase()));
31
+ if (bad.length)
32
+ add(where, t.robotsBadLines(bad.length, short(bad[0], 50)), "warn");
33
+ return out;
34
+ },
35
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const idx4: SeoRule;
@@ -0,0 +1,40 @@
1
+ /** IDX-4: robots.txt does not block the pages of the site: those in the sitemap and the audited ones. */
2
+ import { finder } from "auditor-engine";
3
+ import { norm } from "../util.js";
4
+ import { SEARCH_BOTS, pathOf } from "./shared.js";
5
+ export const idx4 = {
6
+ id: "IDX-4",
7
+ group: "IDX",
8
+ level: "error",
9
+ applies: (site, t) => (site.robots ? null : t.skipNoRobots),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const robots = site.robots.parsed;
13
+ const blockedFor = (url) => {
14
+ try {
15
+ const path = pathOf(url);
16
+ return SEARCH_BOTS.filter((bot) => !robots.allows(bot, path));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ };
22
+ const listed = new Set();
23
+ for (const { loc } of site.sitemap?.urls ?? []) {
24
+ listed.add(norm(loc));
25
+ const blocked = blockedFor(loc);
26
+ if (blocked.length)
27
+ add(loc, t.blockedBySitemap(blocked.join(", ")));
28
+ }
29
+ // Without a sitemap, or for pages it does not list: the pages the site links to are meant to be found too.
30
+ for (const p of site.pages) {
31
+ const url = p.fetched.publicUrl;
32
+ if (listed.has(norm(url)))
33
+ continue;
34
+ const blocked = blockedFor(url);
35
+ if (blocked.length)
36
+ add(url, t.blockedPage(blocked.join(", ")));
37
+ }
38
+ return out;
39
+ },
40
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx5: SeoRule;
@@ -0,0 +1,24 @@
1
+ /** IDX-5: The site has one version. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { rel, statusOf } from "../util.js";
5
+ export const idx5 = {
6
+ id: "IDX-5",
7
+ group: "IDX",
8
+ level: "warn",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ const { otherHost, slashVariant } = seoOf(site);
12
+ const host = new URL(site.base).host;
13
+ // The other host answering 200 on its own is a second copy; no DNS record or an error is not.
14
+ if (otherHost && otherHost.status === 200 && new URL(otherHost.finalUrl ?? otherHost.publicUrl).host !== host)
15
+ add("site", t.otherHostAnswers(otherHost.publicUrl, statusOf(otherHost)));
16
+ const v = slashVariant;
17
+ if (v && v.fetched.status === 200 && !v.fetched.redirected) {
18
+ const points = v.canonical && (v.canonical === v.main || v.canonical === v.mainCanonical);
19
+ if (!points)
20
+ add(v.main, t.slashDuplicate(rel(v.variant, site.base)));
21
+ }
22
+ return out;
23
+ },
24
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx6: SeoRule;
@@ -0,0 +1,25 @@
1
+ /** IDX-6: Redirects take one step. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { rel } from "../util.js";
5
+ export const idx6 = {
6
+ id: "IDX-6",
7
+ group: "IDX",
8
+ level: "warn",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const c of seoOf(site).chains) {
12
+ const path = [c.url, ...c.hops].map((u) => rel(u, site.base)).join(" → ");
13
+ const url = rel(c.url, site.base);
14
+ // Reported where the address is used: the linking pages or the sitemap; else on the address itself.
15
+ const places = c.from.length ? c.from.map((f) => (f === "sitemap.xml" ? `${site.base}/sitemap.xml` : f)) : [c.url];
16
+ for (const place of places) {
17
+ if (c.loop)
18
+ add(place, t.redirectLoop(url, path), "error");
19
+ else if (c.hops.length > 1)
20
+ add(place, t.redirectChain(url, c.hops.length, path));
21
+ }
22
+ }
23
+ return out;
24
+ },
25
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx7: SeoRule;
@@ -0,0 +1,29 @@
1
+ /** IDX-7: noindex does not close the home page and does not contradict the sitemap and canonical. */
2
+ import { finder } from "auditor-engine";
3
+ import { absolute, norm, rel } from "../util.js";
4
+ import { isNoindex } from "./shared.js";
5
+ export const idx7 = {
6
+ id: "IDX-7",
7
+ group: "IDX",
8
+ level: "warn",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ const listed = new Set((site.sitemap?.urls ?? []).map((u) => norm(u.loc)));
12
+ const byUrl = new Map(site.pages.map((p) => [norm(p.fetched.publicUrl), p]));
13
+ for (const p of site.pages) {
14
+ const self = norm(p.fetched.publicUrl);
15
+ const href = p.canonical ? absolute(p.canonical, p.fetched.publicUrl) : "";
16
+ const elsewhere = href && norm(href) !== self ? href : "";
17
+ if (isNoindex(p) && self === norm(`${site.base}/`))
18
+ add(p.fetched.publicUrl, t.noindexHome, "error");
19
+ else if (isNoindex(p) && listed.has(self))
20
+ add(p.fetched.publicUrl, t.noindexInSitemap);
21
+ if (isNoindex(p) && elsewhere)
22
+ add(p.fetched.publicUrl, t.noindexWithCanonical(rel(elsewhere, site.base)));
23
+ const target = elsewhere ? byUrl.get(norm(elsewhere)) : undefined;
24
+ if (target && isNoindex(target) && !isNoindex(p))
25
+ add(p.fetched.publicUrl, t.canonicalToNoindex(rel(elsewhere, site.base)));
26
+ }
27
+ return out;
28
+ },
29
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const idx8: SeoRule;
@@ -0,0 +1,25 @@
1
+ /** IDX-8: robots.txt does not block CSS and JavaScript. */
2
+ import { finder, isSameSite } from "auditor-engine";
3
+ import { rel } from "../util.js";
4
+ import { pathOf } from "./shared.js";
5
+ export const idx8 = {
6
+ id: "IDX-8",
7
+ group: "IDX",
8
+ level: "warn",
9
+ applies: (site, t) => (site.robots ? null : t.skipNoRobots),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const o = { target: site.target, base: site.base };
13
+ const robots = site.robots.parsed;
14
+ const seen = new Set();
15
+ for (const p of site.pages)
16
+ for (const res of p.resources) {
17
+ if (seen.has(res) || !isSameSite(res, o) || !/\.(css|js|mjs)(\?|$)/i.test(new URL(res).pathname))
18
+ continue;
19
+ seen.add(res);
20
+ if (!robots.allows("Googlebot", pathOf(res)))
21
+ add(p.fetched.publicUrl, t.blockedResource(rel(res, site.base)));
22
+ }
23
+ return out;
24
+ },
25
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const idx9: SeoRule;
@@ -0,0 +1,22 @@
1
+ /** IDX-9: The server answers quickly. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { TTFB, median } from "./shared.js";
5
+ export const idx9 = {
6
+ id: "IDX-9",
7
+ group: "IDX",
8
+ level: "info",
9
+ applies: (site, t) => (seoOf(site).local ? t.skipLocal : null),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const times = seoOf(site).ttfb;
13
+ if (!times?.length)
14
+ return out;
15
+ const ms = median(times);
16
+ if (ms > TTFB.poor)
17
+ add(`${site.base}/`, t.slowServer(ms, TTFB.slow), "warn");
18
+ else if (ms > TTFB.slow)
19
+ add(`${site.base}/`, t.slowServer(ms, TTFB.slow));
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Indexing (IDX): can search engines reach the pages and are they allowed to keep them in the index.
3
+ * Status codes and soft 404s, robots.txt health and what it blocks, one version of the site, redirect chains,
4
+ * noindex that contradicts the sitemap or the canonical, CSS and JavaScript closed to Googlebot, server speed.
5
+ */
6
+ import type { SeoRule } from "../util.ts";
7
+ export declare const idxRules: SeoRule[];
@@ -0,0 +1,10 @@
1
+ import { idx1 } from "./idx-1.js";
2
+ import { idx2 } from "./idx-2.js";
3
+ import { idx3 } from "./idx-3.js";
4
+ import { idx4 } from "./idx-4.js";
5
+ import { idx5 } from "./idx-5.js";
6
+ import { idx6 } from "./idx-6.js";
7
+ import { idx7 } from "./idx-7.js";
8
+ import { idx8 } from "./idx-8.js";
9
+ import { idx9 } from "./idx-9.js";
10
+ export const idxRules = [idx1, idx2, idx3, idx4, idx5, idx6, idx7, idx8, idx9];
@@ -0,0 +1,18 @@
1
+ /** Constants and helpers shared by the IDX checks. */
2
+ import { type Page } from "auditor-engine";
3
+ /** Titles and H1s of «not found» pages that still answer 200. */
4
+ export declare const NOT_FOUND: RegExp;
5
+ /** The search crawlers whose robots.txt groups matter for indexing. */
6
+ export declare const SEARCH_BOTS: string[];
7
+ /** Directives search engines understand; anything else in robots.txt is skipped by them. */
8
+ export declare const ROBOTS_KEYS: Set<string>;
9
+ /** Google reads the first 500 KiB of robots.txt. */
10
+ export declare const ROBOTS_LIMIT: number;
11
+ /** web.dev: a time to first byte up to 0.8 s is good, over 1.8 s is poor. */
12
+ export declare const TTFB: {
13
+ slow: number;
14
+ poor: number;
15
+ };
16
+ export declare const pathOf: (url: string) => string;
17
+ export declare const isNoindex: (p: Page) => boolean;
18
+ export declare const median: (xs: number[]) => number;
@@ -0,0 +1,18 @@
1
+ /** Constants and helpers shared by the IDX checks. */
2
+ import {} from "auditor-engine";
3
+ /** Titles and H1s of «not found» pages that still answer 200. */
4
+ export const NOT_FOUND = /\b(404|not found|page not found|doesn.t exist)\b|не найден|страница не существует|ничего не найдено/i;
5
+ /** The search crawlers whose robots.txt groups matter for indexing. */
6
+ export const SEARCH_BOTS = ["Googlebot", "Bingbot", "Yandex"];
7
+ /** Directives search engines understand; anything else in robots.txt is skipped by them. */
8
+ export const ROBOTS_KEYS = new Set(["user-agent", "allow", "disallow", "sitemap", "crawl-delay", "host", "clean-param"]);
9
+ /** Google reads the first 500 KiB of robots.txt. */
10
+ export const ROBOTS_LIMIT = 500 * 1024;
11
+ /** web.dev: a time to first byte up to 0.8 s is good, over 1.8 s is poor. */
12
+ export const TTFB = { slow: 800, poor: 1800 };
13
+ export const pathOf = (url) => {
14
+ const u = new URL(url);
15
+ return u.pathname + u.search;
16
+ };
17
+ export const isNoindex = (p) => /\b(noindex|none)\b/i.test(`${p.metaRobots} ${p.fetched.headers.get("x-robots-tag") ?? ""}`);
18
+ export const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const img1: SeoRule;
@@ -0,0 +1,18 @@
1
+ /** IMG-1: Images have alt text. */
2
+ import { finder } from "auditor-engine";
3
+ import { rel } from "../util.js";
4
+ export const img1 = {
5
+ id: "IMG-1",
6
+ group: "IMG",
7
+ level: "warn",
8
+ applies: (site, t) => (site.pages.some((p) => p.images.length) ? null : t.skipNoImages),
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const p of site.pages) {
12
+ const missing = p.images.filter((i) => i.alt === null);
13
+ if (missing.length)
14
+ add(p.fetched.publicUrl, t.imagesNoAlt(missing.length, p.images.length, rel(missing[0].src, site.base)));
15
+ }
16
+ return out;
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const img2: SeoRule;
@@ -0,0 +1,20 @@
1
+ /** IMG-2: Images have width and height. */
2
+ import { finder } from "auditor-engine";
3
+ import { imgs } from "./shared.js";
4
+ export const img2 = {
5
+ id: "IMG-2",
6
+ group: "IMG",
7
+ level: "warn",
8
+ applies: (site, t) => (site.pages.some((p) => p.images.length) ? null : t.skipNoImages),
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const p of site.pages) {
12
+ // Without both, the browser cannot reserve space, and the layout jumps as images arrive (CLS).
13
+ const list = imgs(p.fetched.body);
14
+ const unsized = list.filter((i) => !i.width || !i.height);
15
+ if (unsized.length)
16
+ add(p.fetched.publicUrl, t.imagesNoSize(unsized.length, list.length, unsized[0].src));
17
+ }
18
+ return out;
19
+ },
20
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const img3: SeoRule;
@@ -0,0 +1,24 @@
1
+ /** IMG-3: Lazy loading is used where it helps. */
2
+ import { finder } from "auditor-engine";
3
+ import { FIRST_SCREEN_TEXT, imgs, LAZY_FROM } from "./shared.js";
4
+ export const img3 = {
5
+ id: "IMG-3",
6
+ group: "IMG",
7
+ level: "warn",
8
+ applies: (site, t) => (site.pages.some((p) => p.images.length) ? null : t.skipNoImages),
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const p of site.pages) {
12
+ const list = imgs(p.fetched.body);
13
+ // The first image on the first screen is often the largest one (LCP): loading it lazily delays it.
14
+ // One that comes after a screenful of text is further down, where lazy loading is right.
15
+ const first = list[0];
16
+ if (first?.loading === "lazy" && first.textBefore < FIRST_SCREEN_TEXT)
17
+ add(p.fetched.publicUrl, t.firstImageLazy(first.src));
18
+ // A long page that loads every image at once: the ones below the first screen can wait.
19
+ if (list.length >= LAZY_FROM && !list.some((i) => i.loading === "lazy"))
20
+ add(p.fetched.publicUrl, t.noLazyImages(list.length), "info");
21
+ }
22
+ return out;
23
+ },
24
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const img4: SeoRule;