search-auditor 0.1.0 → 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 +16 -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,22 @@
1
+ /** LNK-5: Internal links do not carry nofollow. */
2
+ import { finder, isSameSite } from "auditor-engine";
3
+ import { absolute, rel } from "../util.js";
4
+ import { anchors } from "./shared.js";
5
+ export const lnk5 = {
6
+ id: "LNK-5",
7
+ group: "LNK",
8
+ level: "warn",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ const o = { target: site.target, base: site.base };
12
+ for (const p of site.pages) {
13
+ const own = anchors(p.fetched.body)
14
+ .filter((a) => a.href && /\bnofollow\b/i.test(a.rel))
15
+ .map((a) => absolute(a.href, p.fetched.publicUrl))
16
+ .filter((u) => u && isSameSite(u, o));
17
+ if (own.length)
18
+ add(p.fetched.publicUrl, t.nofollowInternal(own.length, rel(own[0], site.base)));
19
+ }
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const lnk6: SeoRule;
@@ -0,0 +1,24 @@
1
+ /** LNK-6: Link text says where the link goes. */
2
+ import { finder } from "auditor-engine";
3
+ import { short } from "../util.js";
4
+ import { anchors, VAGUE } from "./shared.js";
5
+ export const lnk6 = {
6
+ id: "LNK-6",
7
+ group: "LNK",
8
+ level: "info",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const p of site.pages) {
12
+ const links = anchors(p.fetched.body).filter((a) => a.href && !a.href.startsWith("#"));
13
+ // «Read more» and friends: the text a search engine uses to learn what the target page is about.
14
+ const vague = links.filter((a) => !a.label && VAGUE.test(a.text.replace(/[\s→›»….:!>]+$/u, "")));
15
+ // No text, no label, no alt: an icon or an image link that names nothing.
16
+ const nameless = links.filter((a) => !a.text && !a.label && !a.alt);
17
+ if (vague.length)
18
+ add(p.fetched.publicUrl, t.vagueLinks(vague.length, short(vague[0].text, 30)));
19
+ if (nameless.length)
20
+ add(p.fetched.publicUrl, t.namelessLinks(nameless.length, nameless[0].href));
21
+ }
22
+ return out;
23
+ },
24
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const lnk7: SeoRule;
@@ -0,0 +1,26 @@
1
+ /** LNK-7: Links to other sites still work. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { statusOf } from "../util.js";
5
+ import { GONE } from "./shared.js";
6
+ export const lnk7 = {
7
+ id: "LNK-7",
8
+ group: "LNK",
9
+ level: "warn",
10
+ applies(site, t) {
11
+ const external = [...seoOf(site).external.values()];
12
+ if (!external.length)
13
+ return t.skipNoExternal;
14
+ // Not one outside site answered at all: the audit runs without internet access, so nothing can be told dead.
15
+ return external.some((p) => p.status > 0) ? null : t.skipNoNetwork;
16
+ },
17
+ run(site, _c, t) {
18
+ const { out, add } = finder(this);
19
+ // Only answers that mean «gone»: many sites turn bots away with 403, 429 or 999, and that is not a dead link.
20
+ for (const [url, p] of seoOf(site).external)
21
+ if (p.status === 404 || p.status === 410 || (p.status === 0 && GONE.test(p.error ?? "")))
22
+ for (const page of p.from ?? [])
23
+ add(page, t.deadExternal(url, statusOf(p)));
24
+ return out;
25
+ },
26
+ };
@@ -0,0 +1,20 @@
1
+ /** One <a> of the raw HTML: its href as written, rel, the text a person and a crawler read, and its attributes. */
2
+ export interface Anchor {
3
+ href: string | null;
4
+ rel: string;
5
+ text: string;
6
+ /** aria-label or title: a name for links whose text is an icon. */
7
+ label: string;
8
+ /** The alt of images inside the link, which names an image link. */
9
+ alt: string;
10
+ named: boolean;
11
+ }
12
+ /** Every <a>…</a> in the HTML. A regex is enough here: links do not nest. */
13
+ export declare function anchors(html: string): Anchor[];
14
+ /** Link texts that say nothing about where the link goes. */
15
+ export declare const VAGUE: RegExp;
16
+ /**
17
+ * Error codes that mean the domain is gone: no DNS record. Timeouts, refused connections and EAI_AGAIN («try again»)
18
+ * say more about the network the audit runs in than about the site, so they never count as dead.
19
+ */
20
+ export declare const GONE: RegExp;
@@ -0,0 +1,29 @@
1
+ /** Constants and helpers shared by the LNK checks. */
2
+ import { textContent } from "auditor-engine";
3
+ const attr = (tag, name) => {
4
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
5
+ return m ? (m[1] ?? m[2] ?? m[3] ?? "") : null;
6
+ };
7
+ /** Every <a>…</a> in the HTML. A regex is enough here: links do not nest. */
8
+ export function anchors(html) {
9
+ return [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)].map((m) => {
10
+ const open = `<a${m[1]}>`;
11
+ const inner = m[2] ?? "";
12
+ const alts = [...inner.matchAll(/<img\b[^>]*>/gi)].map((i) => attr(i[0], "alt") ?? "").join(" ");
13
+ return {
14
+ href: attr(open, "href"),
15
+ rel: attr(open, "rel") ?? "",
16
+ text: textContent(inner).replace(/\s+/g, " ").trim(),
17
+ label: (attr(open, "aria-label") ?? attr(open, "title") ?? "").trim(),
18
+ alt: alts.trim(),
19
+ named: attr(open, "name") !== null || attr(open, "id") !== null,
20
+ };
21
+ });
22
+ }
23
+ /** Link texts that say nothing about where the link goes. */
24
+ export const VAGUE = /^(click here|here|read more|more|learn more|see more|link|this link|continue|go|details|здесь|тут|сюда|подробнее|читать далее|читать|далее|ещё|еще|ссылка|по ссылке|перейти|узнать больше|нажмите здесь|смотреть)$/i;
25
+ /**
26
+ * Error codes that mean the domain is gone: no DNS record. Timeouts, refused connections and EAI_AGAIN («try again»)
27
+ * say more about the network the audit runs in than about the site, so they never count as dead.
28
+ */
29
+ export const GONE = /^(ENOTFOUND|ERR_NAME_NOT_RESOLVED)$/;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Sitemap (MAP): the map of the site for search engines, and whether it lists the right pages the right way.
3
+ * A valid sitemap file declared in robots.txt; full addresses on this site that answer 200 without redirects;
4
+ * only pages that are their own canonical; the pages the site links to; and lastmod dates search engines can trust.
5
+ */
6
+ import type { SeoRule } from "../util.ts";
7
+ export declare const mapRules: SeoRule[];
@@ -0,0 +1,9 @@
1
+ import { map1 } from "./map-1.js";
2
+ import { map2 } from "./map-2.js";
3
+ import { map3 } from "./map-3.js";
4
+ import { map4 } from "./map-4.js";
5
+ import { map5 } from "./map-5.js";
6
+ import { map6 } from "./map-6.js";
7
+ import { map7 } from "./map-7.js";
8
+ import { map8 } from "./map-8.js";
9
+ export const mapRules = [map1, map2, map3, map4, map5, map6, map7, map8];
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const map1: SeoRule;
@@ -0,0 +1,15 @@
1
+ /** MAP-1: There is a sitemap, and robots.txt points to it. */
2
+ import { finder } from "auditor-engine";
3
+ export const map1 = {
4
+ id: "MAP-1",
5
+ group: "MAP",
6
+ level: "warn",
7
+ run(site, _c, t) {
8
+ const { out, add } = finder(this);
9
+ if (!site.sitemap)
10
+ add("site", t.noSitemap);
11
+ else if (site.robots && !/^\s*sitemap\s*:/im.test(site.robots.fetched.body))
12
+ add("site", t.sitemapNotInRobots, "info");
13
+ return out;
14
+ },
15
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const map2: SeoRule;
@@ -0,0 +1,26 @@
1
+ /** MAP-2: Sitemap addresses do not redirect. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { norm, rel } from "../util.js";
5
+ import { inSitemap } from "./shared.js";
6
+ export const map2 = {
7
+ id: "MAP-2",
8
+ group: "MAP",
9
+ level: "warn",
10
+ applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
11
+ run(site, _c, t) {
12
+ const { out, add } = finder(this);
13
+ const listed = inSitemap(site);
14
+ for (const f of [...site.pages.map((p) => p.fetched), ...site.failed]) {
15
+ if (!listed.has(norm(f.publicUrl)) || !f.redirected || !f.finalUrl)
16
+ continue;
17
+ if (norm(f.finalUrl) !== norm(f.publicUrl))
18
+ add(f.publicUrl, t.sitemapRedirects(rel(f.finalUrl, site.base)));
19
+ }
20
+ // And the sitemap addresses beyond the sample, requested in collect().
21
+ for (const [url, p] of seoOf(site).sitemapExtra)
22
+ if (p.status > 0 && p.status < 400 && p.redirected && norm(p.finalUrl) !== url)
23
+ add(url, t.sitemapRedirects(rel(p.finalUrl, site.base)));
24
+ return out;
25
+ },
26
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const map3: SeoRule;
@@ -0,0 +1,24 @@
1
+ /** MAP-3: Sitemap pages are their own canonical. */
2
+ import { finder } from "auditor-engine";
3
+ import { absolute, norm, rel } from "../util.js";
4
+ import { inSitemap } from "./shared.js";
5
+ export const map3 = {
6
+ id: "MAP-3",
7
+ group: "MAP",
8
+ level: "warn",
9
+ applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const listed = inSitemap(site);
13
+ for (const p of site.pages) {
14
+ const f = p.fetched;
15
+ // A listed address that redirects is MAP-2's finding; its target's canonical says nothing new.
16
+ if (!listed.has(norm(f.publicUrl)) || (f.finalUrl && norm(f.finalUrl) !== norm(f.publicUrl)))
17
+ continue;
18
+ const href = p.canonical ? absolute(p.canonical, f.publicUrl) : "";
19
+ if (href && norm(href) !== norm(f.publicUrl))
20
+ add(f.publicUrl, t.sitemapCanonicalElsewhere(rel(href, site.base)));
21
+ }
22
+ return out;
23
+ },
24
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const map4: SeoRule;
@@ -0,0 +1,34 @@
1
+ /** MAP-4: The sitemap file is valid. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { statusOf } from "../util.js";
5
+ import { MAX_BYTES, MAX_URLS } from "./shared.js";
6
+ export const map4 = {
7
+ id: "MAP-4",
8
+ group: "MAP",
9
+ level: "error",
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ for (const f of seoOf(site).sitemapFiles) {
13
+ // No /sitemap.xml at the default address is MAP-1's finding; a declared or listed file that fails is this one.
14
+ if (f.status !== 200) {
15
+ if (f.source !== "default")
16
+ add(f.url, t.sitemapStatus(statusOf(f)));
17
+ continue;
18
+ }
19
+ if (f.kind === "gzip")
20
+ continue;
21
+ if (f.kind === "html")
22
+ add(f.url, t.sitemapHtml);
23
+ else if (f.kind === "other")
24
+ add(f.url, t.sitemapNotXml);
25
+ if (f.count > MAX_URLS)
26
+ add(f.url, t.sitemapTooMany(f.count));
27
+ if (f.bytes > MAX_BYTES)
28
+ add(f.url, t.sitemapTooBig(Math.round(f.bytes / 1024 / 1024)));
29
+ if (f.badAmpersands)
30
+ add(f.url, t.sitemapAmpersand(f.badAmpersands));
31
+ }
32
+ return out;
33
+ },
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const map5: SeoRule;
@@ -0,0 +1,28 @@
1
+ /** MAP-5: Sitemap addresses are absolute and on this site. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { sitemapUrl } from "./shared.js";
5
+ export const map5 = {
6
+ id: "MAP-5",
7
+ group: "MAP",
8
+ level: "warn",
9
+ applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ // The protocol takes full addresses on the sitemap's own host; others are dropped. http:// on this host is SEC-6.
13
+ const host = new URL(site.base).host;
14
+ const wrong = site.sitemap.urls
15
+ .map((u) => u.loc)
16
+ .filter((loc) => {
17
+ try {
18
+ return !/^https?:\/\//i.test(loc) || new URL(loc).host !== host;
19
+ }
20
+ catch {
21
+ return true;
22
+ }
23
+ });
24
+ if (wrong.length)
25
+ add(sitemapUrl(site, seoOf(site).sitemapFiles), t.sitemapForeign(wrong.length, wrong[0]));
26
+ return out;
27
+ },
28
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const map6: SeoRule;
@@ -0,0 +1,22 @@
1
+ /** MAP-6: Pages the site links to are in the sitemap. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { inSitemap } from "./shared.js";
5
+ export const map6 = {
6
+ id: "MAP-6",
7
+ group: "MAP",
8
+ level: "info",
9
+ applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ const listed = inSitemap(site);
13
+ // Working HTML pages open to indexing only: a link that redirects or fails is LNK's finding; feeds, PDFs,
14
+ // pages with noindex and addresses with a query are not meant for the sitemap.
15
+ for (const [url, p] of seoOf(site).links) {
16
+ if (p.status !== 200 || p.redirected || !p.html || p.noindex || url.includes("?") || listed.has(url))
17
+ continue;
18
+ add(url, t.notInSitemap(p.from?.length ?? 0));
19
+ }
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const map7: SeoRule;
@@ -0,0 +1,18 @@
1
+ /** MAP-7: Every sitemap address answers. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { statusOf } from "../util.js";
5
+ export const map7 = {
6
+ id: "MAP-7",
7
+ group: "MAP",
8
+ level: "error",
9
+ applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
10
+ run(site, _c, t) {
11
+ const { out, add } = finder(this);
12
+ // The sampled pages are IDX-1's: these are the sitemap addresses beyond the sample.
13
+ for (const [url, p] of seoOf(site).sitemapExtra)
14
+ if (p.status === 0 || p.status >= 400)
15
+ add(url, t.sitemapDead(statusOf(p)));
16
+ return out;
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const map8: SeoRule;
@@ -0,0 +1,31 @@
1
+ /** MAP-8: lastmod in the sitemap is honest. */
2
+ import { finder } from "auditor-engine";
3
+ import { seoOf } from "../../collect.js";
4
+ import { short } from "../util.js";
5
+ import { SAME_LASTMOD_MIN, W3C_DATE, sitemapUrl } from "./shared.js";
6
+ export const map8 = {
7
+ id: "MAP-8",
8
+ group: "MAP",
9
+ level: "warn",
10
+ applies: (site, t) => (site.sitemap?.urls.some((u) => u.lastmod) ? null : t.skipNoLastmod),
11
+ run(site, _c, t) {
12
+ const { out, add } = finder(this);
13
+ const where = sitemapUrl(site, seoOf(site).sitemapFiles);
14
+ const dates = site.sitemap.urls.map((u) => u.lastmod).filter((d) => Boolean(d));
15
+ const bad = dates.filter((d) => !W3C_DATE.test(d) || Number.isNaN(Date.parse(d)));
16
+ // A day of slack for time zones: a date tomorrow in UTC can be today on the server.
17
+ const future = dates.filter((d) => W3C_DATE.test(d) && Date.parse(d) > Date.now() + 86_400_000);
18
+ if (bad.length)
19
+ add(where, t.lastmodBad(bad.length, short(bad[0], 40)));
20
+ if (future.length)
21
+ add(where, t.lastmodFuture(future.length, future[0]));
22
+ // The same lastmod everywhere is the build time: Google learns to ignore it, and real edits go unnoticed.
23
+ const counts = new Map();
24
+ for (const d of dates)
25
+ counts.set(d, (counts.get(d) ?? 0) + 1);
26
+ const [top, n] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
27
+ if (n >= SAME_LASTMOD_MIN && n === site.sitemap.urls.length)
28
+ add(where, t.lastmodSame(n, top));
29
+ return out;
30
+ },
31
+ };
@@ -0,0 +1,14 @@
1
+ /** Constants and helpers shared by the MAP checks. */
2
+ import { type SeoRule } from "../util.ts";
3
+ export declare const inSitemap: (site: Parameters<SeoRule["run"]>[0]) => Set<string>;
4
+ /** Where findings about the sitemap as a whole go: its first file, or the default address. */
5
+ export declare const sitemapUrl: (site: Parameters<SeoRule["run"]>[0], files: {
6
+ url: string;
7
+ }[]) => string;
8
+ /** The sitemap protocol's limits per file. */
9
+ export declare const MAX_URLS = 50000;
10
+ export declare const MAX_BYTES: number;
11
+ /** W3C Datetime as the sitemap protocol takes it: a date, or a date and time with a time zone. */
12
+ export declare const W3C_DATE: RegExp;
13
+ /** A sitemap where at least this many addresses share one lastmod is stamping the build time, not the edits. */
14
+ export declare const SAME_LASTMOD_MIN = 10;
@@ -0,0 +1,12 @@
1
+ /** Constants and helpers shared by the MAP checks. */
2
+ import { norm } from "../util.js";
3
+ export const inSitemap = (site) => new Set((site.sitemap?.urls ?? []).map((u) => norm(u.loc)));
4
+ /** Where findings about the sitemap as a whole go: its first file, or the default address. */
5
+ export const sitemapUrl = (site, files) => files[0]?.url ?? `${site.base}/sitemap.xml`;
6
+ /** The sitemap protocol's limits per file. */
7
+ export const MAX_URLS = 50_000;
8
+ export const MAX_BYTES = 50 * 1024 * 1024;
9
+ /** W3C Datetime as the sitemap protocol takes it: a date, or a date and time with a time zone. */
10
+ export const W3C_DATE = /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$/;
11
+ /** A sitemap where at least this many addresses share one lastmod is stamping the build time, not the edits. */
12
+ export const SAME_LASTMOD_MIN = 10;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Meta tags (META): how a page looks in search results, its title, snippet and icon.
3
+ * One title and one description per page, of a length that fits the result, written for the page and not for a
4
+ * template, not repeated across pages or between title and description; a declared encoding, a snippet that is
5
+ * not forbidden, and a favicon.
6
+ */
7
+ import type { SeoRule } from "../util.ts";
8
+ export declare const metaRules: SeoRule[];
@@ -0,0 +1,10 @@
1
+ import { meta1 } from "./meta-1.js";
2
+ import { meta2 } from "./meta-2.js";
3
+ import { meta3 } from "./meta-3.js";
4
+ import { meta4 } from "./meta-4.js";
5
+ import { meta5 } from "./meta-5.js";
6
+ import { meta6 } from "./meta-6.js";
7
+ import { meta7 } from "./meta-7.js";
8
+ import { meta8 } from "./meta-8.js";
9
+ import { meta9 } from "./meta-9.js";
10
+ export const metaRules = [meta1, meta2, meta3, meta4, meta5, meta6, meta7, meta8, meta9];
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const meta1: SeoRule;
@@ -0,0 +1,20 @@
1
+ /** META-1: Every page has a title of the right length. */
2
+ import { finder } from "auditor-engine";
3
+ import { short } from "../util.js";
4
+ import { TITLE, len } from "./shared.js";
5
+ export const meta1 = {
6
+ id: "META-1",
7
+ group: "META",
8
+ level: "error",
9
+ run(site, _c, t) {
10
+ const { out, add } = finder(this);
11
+ for (const p of site.pages) {
12
+ const n = len(p.title);
13
+ if (!n)
14
+ add(p.fetched.publicUrl, t.noTitle);
15
+ else if (n < TITLE.min || n > TITLE.max)
16
+ add(p.fetched.publicUrl, t.titleLength(n, short(p.title), TITLE.min, TITLE.max), "warn");
17
+ }
18
+ return out;
19
+ },
20
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta2: SeoRule;
@@ -0,0 +1,19 @@
1
+ /** META-2: Every page has a description. */
2
+ import { finder } from "auditor-engine";
3
+ import { DESCRIPTION, len } from "./shared.js";
4
+ export const meta2 = {
5
+ id: "META-2",
6
+ group: "META",
7
+ level: "warn",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ for (const p of site.pages) {
11
+ const n = len(p.description);
12
+ if (!n)
13
+ add(p.fetched.publicUrl, t.noDescription);
14
+ else if (n < DESCRIPTION.min || n > DESCRIPTION.max)
15
+ add(p.fetched.publicUrl, t.descriptionLength(n, DESCRIPTION.min, DESCRIPTION.max), "info");
16
+ }
17
+ return out;
18
+ },
19
+ };
@@ -0,0 +1,2 @@
1
+ import { type SeoRule } from "../util.ts";
2
+ export declare const meta3: SeoRule;
@@ -0,0 +1,26 @@
1
+ /** META-3: Titles and descriptions are not repeated across pages. */
2
+ import { finder } from "auditor-engine";
3
+ import { short } from "../util.js";
4
+ export const meta3 = {
5
+ id: "META-3",
6
+ group: "META",
7
+ level: "warn",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ const repeated = (pick, message) => {
11
+ const by = new Map();
12
+ for (const p of site.pages) {
13
+ const v = pick(p).trim();
14
+ if (v)
15
+ by.set(v, [...(by.get(v) ?? []), p.fetched.publicUrl]);
16
+ }
17
+ for (const [text, urls] of by)
18
+ if (urls.length > 1)
19
+ for (const u of urls)
20
+ add(u, message(urls.length, short(text)));
21
+ };
22
+ repeated((p) => p.title, t.sameTitle);
23
+ repeated((p) => p.description, t.sameDescription);
24
+ return out;
25
+ },
26
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta4: SeoRule;
@@ -0,0 +1,22 @@
1
+ /** META-4: One title and one description per page. */
2
+ import { finder } from "auditor-engine";
3
+ import { headOf } from "../../collect.js";
4
+ export const meta4 = {
5
+ id: "META-4",
6
+ group: "META",
7
+ level: "warn",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ for (const p of site.pages) {
11
+ // In <head> only: an inline SVG has <title> of its own, and that is fine.
12
+ const head = headOf(p.fetched.body);
13
+ const titles = (head.match(/<title\b/gi) ?? []).length;
14
+ const descriptions = (head.match(/<meta\b[^>]*\bname\s*=\s*["']?description\b/gi) ?? []).length;
15
+ if (titles > 1)
16
+ add(p.fetched.publicUrl, t.manyTitles(titles));
17
+ if (descriptions > 1)
18
+ add(p.fetched.publicUrl, t.manyDescriptions(descriptions));
19
+ }
20
+ return out;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta5: SeoRule;
@@ -0,0 +1,17 @@
1
+ /** META-5: The title names the page, not a template. */
2
+ import { finder } from "auditor-engine";
3
+ import { GENERIC_TITLE } from "./shared.js";
4
+ export const meta5 = {
5
+ id: "META-5",
6
+ group: "META",
7
+ level: "warn",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ for (const p of site.pages) {
11
+ const title = p.title.trim();
12
+ if (title && GENERIC_TITLE.test(title))
13
+ add(p.fetched.publicUrl, t.genericTitle(title));
14
+ }
15
+ return out;
16
+ },
17
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta6: SeoRule;
@@ -0,0 +1,18 @@
1
+ /** META-6: The description adds to the title, not repeats it. */
2
+ import { finder } from "auditor-engine";
3
+ import { plain } from "./shared.js";
4
+ export const meta6 = {
5
+ id: "META-6",
6
+ group: "META",
7
+ level: "info",
8
+ run(site, _c, t) {
9
+ const { out, add } = finder(this);
10
+ for (const p of site.pages) {
11
+ const [title, description] = [plain(p.title), plain(p.description)];
12
+ // The same words, or the title with a few characters added: the snippet says nothing the title did not.
13
+ if (title && description && (description === title || (description.startsWith(title) && description.length - title.length < 15)))
14
+ add(p.fetched.publicUrl, t.descriptionRepeatsTitle);
15
+ }
16
+ return out;
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta7: SeoRule;
@@ -0,0 +1,18 @@
1
+ /** META-7: The page declares its encoding. */
2
+ import { finder } from "auditor-engine";
3
+ export const meta7 = {
4
+ id: "META-7",
5
+ group: "META",
6
+ level: "warn",
7
+ run(site, _c, t) {
8
+ const { out, add } = finder(this);
9
+ for (const p of site.pages) {
10
+ const header = /charset\s*=/i.test(p.fetched.headers.get("content-type") ?? "");
11
+ // Browsers look for <meta charset> in the first 1024 bytes only.
12
+ const meta = /<meta\b[^>]*charset\s*=/i.test(p.fetched.body.slice(0, 1024));
13
+ if (!header && !meta)
14
+ add(p.fetched.publicUrl, t.noCharset);
15
+ }
16
+ return out;
17
+ },
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { SeoRule } from "../util.ts";
2
+ export declare const meta8: SeoRule;