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
package/dist/trace.js ADDED
@@ -0,0 +1,77 @@
1
+ import tls from "node:tls";
2
+ import { DEFAULT_UA, toPublic, toTarget } from "auditor-engine";
3
+ const REDIRECT = new Set([301, 302, 303, 307, 308]);
4
+ const MAX_HOPS = 10;
5
+ /**
6
+ * Follows redirects one at a time, the way a crawler counts them: fetch with redirect: "follow" hides the steps.
7
+ * Returns the public URL of every hop after the first, whether it looped, and the final status.
8
+ */
9
+ export async function trace(url, o, timeoutMs = 10_000) {
10
+ const seen = new Set([url]);
11
+ const hops = [];
12
+ let current = url;
13
+ for (let i = 0; i < MAX_HOPS; i++) {
14
+ let res;
15
+ try {
16
+ res = await fetch(toTarget(current, o), { redirect: "manual", headers: { "user-agent": DEFAULT_UA }, signal: AbortSignal.timeout(timeoutMs) });
17
+ await res.body?.cancel();
18
+ }
19
+ catch {
20
+ return { hops, loop: false, status: 0 };
21
+ }
22
+ const location = res.headers.get("location");
23
+ if (!REDIRECT.has(res.status) || !location)
24
+ return { hops, loop: false, status: res.status };
25
+ const next = toPublic(new URL(location, toTarget(current, o)).href, o);
26
+ hops.push(next);
27
+ if (seen.has(next))
28
+ return { hops, loop: true, status: res.status };
29
+ seen.add(next);
30
+ current = next;
31
+ }
32
+ return { hops, loop: false, status: 0 };
33
+ }
34
+ /** Time to the response headers, in ms: what a crawler waits before the page starts to arrive. */
35
+ export async function timeToFirstByte(url, o, timeoutMs = 10_000) {
36
+ const start = performance.now();
37
+ try {
38
+ const res = await fetch(toTarget(url, o), { headers: { "user-agent": DEFAULT_UA }, signal: AbortSignal.timeout(timeoutMs) });
39
+ const ms = performance.now() - start;
40
+ await res.body?.cancel();
41
+ return res.ok ? Math.round(ms) : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** The first answer to a URL without following redirects: its status and Location. */
48
+ export async function firstHop(url, timeoutMs = 10_000) {
49
+ try {
50
+ const res = await fetch(url, { redirect: "manual", headers: { "user-agent": DEFAULT_UA }, signal: AbortSignal.timeout(timeoutMs) });
51
+ await res.body?.cancel();
52
+ return { status: res.status, location: res.headers.get("location") ?? "" };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Opens a TLS connection to the host and reads its certificate: trusted or not, and how long it has left. */
59
+ export function certificateOf(host, timeoutMs = 10_000) {
60
+ return new Promise((resolve) => {
61
+ // rejectUnauthorized: false so an expired or self-signed certificate is reported, not thrown away.
62
+ const socket = tls.connect({ host, port: 443, servername: host, rejectUnauthorized: false, timeout: timeoutMs }, () => {
63
+ const cert = socket.getPeerCertificate();
64
+ socket.end();
65
+ if (!cert?.valid_to)
66
+ return resolve(null);
67
+ const daysLeft = Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86_400_000);
68
+ const error = socket.authorizationError ? String(socket.authorizationError) : undefined;
69
+ resolve({ valid: socket.authorized, ...(error ? { error } : {}), daysLeft, validTo: new Date(cert.valid_to).toISOString().slice(0, 10) });
70
+ });
71
+ socket.on("error", () => resolve(null));
72
+ socket.on("timeout", () => {
73
+ socket.destroy();
74
+ resolve(null);
75
+ });
76
+ });
77
+ }
package/dist/types.d.ts CHANGED
@@ -1,12 +1,20 @@
1
- import type { Config as AiConfig, Fetched, Lang, Site } from "ai-search-auditor";
1
+ import type { Config as EngineConfig, Fetched, Lang, Site } from "auditor-engine";
2
2
  /** One extra request made for the SEO checks: what it answered and where it ended. */
3
3
  export interface Probe {
4
4
  status: number;
5
5
  redirected: boolean;
6
6
  finalUrl: string;
7
7
  error?: string;
8
- /** The audited page where the URL was found (internal links). */
9
- from?: string;
8
+ /** The audited pages that link to the URL (internal links), in crawl order. */
9
+ from?: string[];
10
+ /** The canonical the page declares, absolute; for canonical targets that answered 200. */
11
+ canonical?: string;
12
+ /** An HTML page, as its Content-Type says: feeds, PDFs and other files are not pages to list. */
13
+ html?: boolean;
14
+ /** The page is closed with noindex (meta robots or X-Robots-Tag). */
15
+ noindex?: boolean;
16
+ /** Size from Content-Length, in bytes, when the server sends it. */
17
+ bytes?: number;
10
18
  }
11
19
  /** Data the SEO checks need beyond the shared crawl, collected in the same run (collect.ts). */
12
20
  export interface SeoExtras {
@@ -23,15 +31,87 @@ export interface SeoExtras {
23
31
  /** Unique internal links found, and how many were requested (capped by maxLinks). */
24
32
  linksFound: number;
25
33
  linksChecked: number;
34
+ /** robots.txt as the server answered it, whatever the status: the crawl keeps it only on 200. */
35
+ robotsRaw: Fetched;
36
+ /** The home page on the other host, www. added or removed; live sites only, null when there is no such host. */
37
+ otherHost: Fetched | null;
38
+ /** A sampled page requested with the trailing slash toggled, and the canonical it declares. */
39
+ slashVariant: {
40
+ main: string;
41
+ mainCanonical: string;
42
+ variant: string;
43
+ fetched: Fetched;
44
+ canonical: string;
45
+ } | null;
46
+ /** Addresses that redirect, followed hop by hop. */
47
+ chains: Chain[];
48
+ /** Time to the first byte of the home page, in ms, over a few requests; null on a local build. */
49
+ ttfb: number[] | null;
50
+ /** First answer of http://host/ without following it: the redirect code and where it points. Live https sites only. */
51
+ httpHop: {
52
+ status: number;
53
+ location: string;
54
+ } | null;
55
+ /** The TLS certificate of the live site; null on a local build or when the site is not on https. */
56
+ cert: Certificate | null;
57
+ /** A sampled page requested with UTM parameters, and the canonical it then declares. */
58
+ utm: {
59
+ page: string;
60
+ url: string;
61
+ status: number;
62
+ canonical: string;
63
+ } | null;
64
+ /** The sitemap files as served: those robots.txt declares (or /sitemap.xml), and the children of an index. */
65
+ sitemapFiles: SitemapFile[];
66
+ /** Sitemap addresses outside the sample, requested to see that they answer 200 (capped). */
67
+ sitemapExtra: Map<string, Probe>;
68
+ /** /favicon.ico, requested only when the home page declares no icon. */
69
+ favicon: Fetched | null;
70
+ /** Links to other sites found on the audited pages, requested to see that they still exist (capped). */
71
+ external: Map<string, Probe>;
72
+ /** Images of this site used on the audited pages: do they open, and how heavy are they (capped). */
73
+ images: Map<string, Probe>;
74
+ }
75
+ /** One sitemap file: what it answered, what it is, and what is wrong with its format. */
76
+ export interface SitemapFile {
77
+ url: string;
78
+ /** Where it came from: robots.txt, the default /sitemap.xml, or a sitemap index. */
79
+ source: "robots" | "default" | "index";
80
+ status: number;
81
+ error?: string;
82
+ kind: "urlset" | "index" | "html" | "other" | "gzip";
83
+ bytes: number;
84
+ /** <url> or <sitemap> entries. */
85
+ count: number;
86
+ /** <loc> values with a bare & that breaks the XML. */
87
+ badAmpersands: number;
88
+ }
89
+ /** What the TLS handshake said about the certificate. */
90
+ export interface Certificate {
91
+ /** The certificate is trusted and matches the host. */
92
+ valid: boolean;
93
+ /** Why it is not trusted: expired, self-signed, wrong host… */
94
+ error?: string;
95
+ /** Days until it expires; negative when it already has. */
96
+ daysLeft: number;
97
+ validTo: string;
98
+ }
99
+ /** A redirect followed one hop at a time: every address on the way, and whether it came back to one of them. */
100
+ export interface Chain {
101
+ url: string;
102
+ hops: string[];
103
+ loop: boolean;
104
+ /** Status at the end of the chain (0 when it did not end). */
105
+ status: number;
106
+ /** The audited pages where the address was found: links, sitemap, canonical. */
107
+ from: string[];
26
108
  }
27
109
  /** A crawled site with the SEO extras attached. */
28
110
  export type SeoSite = Site & {
29
111
  seo: SeoExtras;
30
112
  };
31
- export interface SearchConfig extends AiConfig {
113
+ export interface SearchConfig extends EngineConfig {
32
114
  /** Internal links to request per run (default 100). */
33
115
  maxLinks: number;
34
- /** Run the AI search checks too (default true). */
35
- ai: boolean;
36
116
  }
37
117
  export type { Lang };
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "search-auditor",
3
- "version": "0.1.0",
4
- "description": "SEO and AI search audit in one report: classic technical SEO (indexing, canonical, meta, links, sitemap, hreflang, HTTPS) plus whether ChatGPT, Claude and Perplexity can read and cite the site. One crawl, one score, CI-ready.",
3
+ "version": "0.2.0",
4
+ "description": "Technical SEO audit from the command line: indexing, robots.txt, redirects, canonical, sitemap, meta tags, headings, links, images, structured data, hreflang, HTTPS. One score, CI-ready.",
5
5
  "keywords": [
6
6
  "seo audit",
7
7
  "search audit",
8
8
  "technical seo",
9
9
  "seo checker",
10
- "geo",
11
- "ai search",
12
- "ai seo",
13
- "aeo",
14
10
  "canonical",
15
11
  "hreflang",
16
12
  "sitemap",
@@ -49,12 +45,11 @@
49
45
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
50
46
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
51
47
  },
52
- "peerDependencies": {
53
- "ai-search-auditor": "^0.3.0"
48
+ "dependencies": {
49
+ "auditor-engine": "^0.1.0"
54
50
  },
55
51
  "devDependencies": {
56
52
  "@types/node": "^22.18.0",
57
- "ai-search-auditor": "^0.3.0",
58
53
  "typescript": "^5.9.0"
59
54
  }
60
55
  }
@@ -1,2 +0,0 @@
1
- import { type SeoRule } from "./util.ts";
2
- export declare const linkRules: SeoRule[];
@@ -1,95 +0,0 @@
1
- import { finder, isSameSite } from "ai-search-auditor";
2
- import { seoOf } from "../collect.js";
3
- import { absolute, norm, rel, statusOf } from "./util.js";
4
- const lnk1 = {
5
- id: "LNK-1",
6
- group: "LNK",
7
- level: "error",
8
- applies: (site, t) => (seoOf(site).linksFound ? null : t.skipNoLinks),
9
- run(site, _c, t) {
10
- const { out, add } = finder(this);
11
- const { links, linksFound, linksChecked } = seoOf(site);
12
- for (const [url, p] of links)
13
- if (p.status === 0 || p.status >= 400)
14
- add(url, t.brokenLink(statusOf(p), rel(p.from ?? site.base, site.base)));
15
- if (linksChecked < linksFound)
16
- add("site", t.linksNotChecked(linksChecked, linksFound), "info");
17
- return out;
18
- },
19
- };
20
- const lnk2 = {
21
- id: "LNK-2",
22
- group: "LNK",
23
- level: "warn",
24
- applies: (site, t) => (seoOf(site).linksFound ? null : t.skipNoLinks),
25
- run(site, _c, t) {
26
- const { out, add } = finder(this);
27
- for (const [url, p] of seoOf(site).links)
28
- if (p.status > 0 && p.status < 400 && p.redirected && norm(p.finalUrl) !== norm(url))
29
- add(url, t.redirectLink(rel(p.finalUrl, site.base), rel(p.from ?? site.base, site.base)));
30
- return out;
31
- },
32
- };
33
- /** ISO 639-1 language, optional ISO 15924 script and region (ISO 3166-1 or UN M.49), or x-default. */
34
- const HREFLANG = /^(x-default|[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?)$/i;
35
- const hasHreflang = (site) => site.pages.some((p) => p.hreflang.length);
36
- const lang1 = {
37
- id: "LANG-1",
38
- group: "LANG",
39
- level: "warn",
40
- applies: (site, t) => (hasHreflang(site) ? null : t.skipNoHreflang),
41
- run(site, _c, t) {
42
- const { out, add } = finder(this);
43
- for (const p of site.pages)
44
- for (const h of p.hreflang)
45
- if (!HREFLANG.test(h.lang))
46
- add(p.fetched.publicUrl, t.badHreflang(h.lang));
47
- return out;
48
- },
49
- };
50
- const lang2 = {
51
- id: "LANG-2",
52
- group: "LANG",
53
- level: "warn",
54
- applies: (site, t) => (hasHreflang(site) ? null : t.skipNoHreflang),
55
- run(site, _c, t) {
56
- const { out, add } = finder(this);
57
- const o = { target: site.target, base: site.base };
58
- // Only pairs where both pages were audited: a translation outside the sample is not known to be one-way.
59
- const audited = new Map(site.pages.map((p) => [norm(p.fetched.publicUrl), p]));
60
- for (const p of site.pages) {
61
- const self = norm(p.fetched.publicUrl);
62
- for (const h of p.hreflang) {
63
- if (h.lang.toLowerCase() === "x-default")
64
- continue;
65
- const href = absolute(h.href, p.fetched.publicUrl);
66
- if (!href || !isSameSite(href, o) || norm(href) === self)
67
- continue;
68
- const other = audited.get(norm(href));
69
- if (other && !other.hreflang.some((b) => norm(absolute(b.href, other.fetched.publicUrl)) === self))
70
- add(p.fetched.publicUrl, t.oneWayHreflang(h.lang, rel(href, site.base)));
71
- }
72
- }
73
- return out;
74
- },
75
- };
76
- const lang3 = {
77
- id: "LANG-3",
78
- group: "LANG",
79
- level: "info",
80
- applies(site, t) {
81
- if (!hasHreflang(site))
82
- return t.skipNoHreflang;
83
- return site.pages.some((p) => new Set(p.hreflang.filter((h) => h.lang.toLowerCase() !== "x-default").map((h) => h.lang.toLowerCase())).size > 1) ? null : t.skipOneLanguage;
84
- },
85
- run(site, _c, t) {
86
- const { out, add } = finder(this);
87
- for (const p of site.pages) {
88
- const langs = [...new Set(p.hreflang.map((h) => h.lang.toLowerCase()))];
89
- if (langs.length > 1 && !langs.includes("x-default"))
90
- add(p.fetched.publicUrl, t.noXDefault(langs.join(", ")));
91
- }
92
- return out;
93
- },
94
- };
95
- export const linkRules = [lnk1, lnk2, lang1, lang2, lang3];
@@ -1,2 +0,0 @@
1
- import { type SeoRule } from "./util.ts";
2
- export declare const pageRules: SeoRule[];
@@ -1,145 +0,0 @@
1
- import { finder } from "ai-search-auditor";
2
- import { rel, short } from "./util.js";
3
- const TITLE = { min: 10, max: 60 };
4
- const DESCRIPTION = { min: 50, max: 160 };
5
- /** Characters as a person counts them: emoji and letters with marks are one each. */
6
- const len = (s) => [...s.trim()].length;
7
- const meta1 = {
8
- id: "META-1",
9
- group: "META",
10
- level: "error",
11
- run(site, _c, t) {
12
- const { out, add } = finder(this);
13
- for (const p of site.pages) {
14
- const n = len(p.title);
15
- if (!n)
16
- add(p.fetched.publicUrl, t.noTitle);
17
- else if (n < TITLE.min || n > TITLE.max)
18
- add(p.fetched.publicUrl, t.titleLength(n, short(p.title)), "warn");
19
- }
20
- return out;
21
- },
22
- };
23
- const meta2 = {
24
- id: "META-2",
25
- group: "META",
26
- level: "warn",
27
- run(site, _c, t) {
28
- const { out, add } = finder(this);
29
- for (const p of site.pages) {
30
- const n = len(p.description);
31
- if (!n)
32
- add(p.fetched.publicUrl, t.noDescription);
33
- else if (n < DESCRIPTION.min || n > DESCRIPTION.max)
34
- add(p.fetched.publicUrl, t.descriptionLength(n), "info");
35
- }
36
- return out;
37
- },
38
- };
39
- const meta3 = {
40
- id: "META-3",
41
- group: "META",
42
- level: "warn",
43
- run(site, _c, t) {
44
- const { out, add } = finder(this);
45
- const repeated = (pick, message) => {
46
- const by = new Map();
47
- for (const p of site.pages) {
48
- const v = pick(p).trim();
49
- if (v)
50
- by.set(v, [...(by.get(v) ?? []), p.fetched.publicUrl]);
51
- }
52
- for (const [text, urls] of by)
53
- if (urls.length > 1)
54
- for (const u of urls)
55
- add(u, message(urls.length, short(text)));
56
- };
57
- repeated((p) => p.title, t.sameTitle);
58
- repeated((p) => p.description, t.sameDescription);
59
- return out;
60
- },
61
- };
62
- const str1 = {
63
- id: "STR-1",
64
- group: "STR",
65
- level: "warn",
66
- run(site, _c, t) {
67
- const { out, add } = finder(this);
68
- for (const p of site.pages) {
69
- if (!p.h1.length)
70
- add(p.fetched.publicUrl, t.noH1);
71
- else if (p.h1.length > 1)
72
- add(p.fetched.publicUrl, t.manyH1(p.h1.length));
73
- }
74
- return out;
75
- },
76
- };
77
- const str2 = {
78
- id: "STR-2",
79
- group: "STR",
80
- level: "info",
81
- run(site, _c, t) {
82
- const { out, add } = finder(this);
83
- for (const p of site.pages) {
84
- // One finding per page: the first skip is enough to show the pattern.
85
- const hs = p.headingsAll;
86
- const i = hs.findIndex((h, k) => k > 0 && h.level > hs[k - 1].level + 1);
87
- if (i > 0)
88
- add(p.fetched.publicUrl, t.skippedLevel(hs[i - 1].level, hs[i].level, short(hs[i].text, 50)));
89
- }
90
- return out;
91
- },
92
- };
93
- const str3 = {
94
- id: "STR-3",
95
- group: "STR",
96
- level: "warn",
97
- run(site, _c, t) {
98
- const { out, add } = finder(this);
99
- for (const p of site.pages)
100
- if (!p.htmlLang.trim())
101
- add(p.fetched.publicUrl, t.noLang);
102
- return out;
103
- },
104
- };
105
- const str4 = {
106
- id: "STR-4",
107
- group: "STR",
108
- level: "warn",
109
- run(site, _c, t) {
110
- const { out, add } = finder(this);
111
- for (const p of site.pages)
112
- if (!/width\s*=\s*device-width/i.test(p.viewport))
113
- add(p.fetched.publicUrl, t.noViewport);
114
- return out;
115
- },
116
- };
117
- const img1 = {
118
- id: "IMG-1",
119
- group: "IMG",
120
- level: "warn",
121
- applies: (site, t) => (site.pages.some((p) => p.images.length) ? null : t.skipNoImages),
122
- run(site, _c, t) {
123
- const { out, add } = finder(this);
124
- for (const p of site.pages) {
125
- const missing = p.images.filter((i) => i.alt === null);
126
- if (missing.length)
127
- add(p.fetched.publicUrl, t.imagesNoAlt(missing.length, p.images.length, rel(missing[0].src, site.base)));
128
- }
129
- return out;
130
- },
131
- };
132
- const data1 = {
133
- id: "DATA-1",
134
- group: "DATA",
135
- level: "error",
136
- applies: (site, t) => (site.pages.some((p) => p.jsonLd.length || p.jsonLdErrors.length) ? null : t.skipNoJsonLd),
137
- run(site, _c, t) {
138
- const { out, add } = finder(this);
139
- for (const p of site.pages)
140
- for (const e of p.jsonLdErrors)
141
- add(p.fetched.publicUrl, t.jsonLdError(short(e, 100)));
142
- return out;
143
- },
144
- };
145
- export const pageRules = [meta1, meta2, meta3, str1, str2, str3, str4, img1, data1];
@@ -1,2 +0,0 @@
1
- import { type SeoRule } from "./util.ts";
2
- export declare const siteRules: SeoRule[];
@@ -1,177 +0,0 @@
1
- import { finder, isSameSite } from "ai-search-auditor";
2
- import { seoOf } from "../collect.js";
3
- import { absolute, norm, rel, short, statusOf } from "./util.js";
4
- /** Titles and H1s of «not found» pages that still answer 200. */
5
- const NOT_FOUND = /\b(404|not found|page not found|doesn.t exist)\b|не найден|страница не существует|ничего не найдено/i;
6
- const idx1 = {
7
- id: "IDX-1",
8
- group: "IDX",
9
- level: "error",
10
- run(site, _c, t) {
11
- const { out, add } = finder(this);
12
- for (const f of site.failed)
13
- if (f.status !== 200)
14
- add(f.publicUrl, t.status(statusOf(f)));
15
- return out;
16
- },
17
- };
18
- const idx2 = {
19
- id: "IDX-2",
20
- group: "IDX",
21
- level: "warn",
22
- run(site, _c, t) {
23
- const { out, add } = finder(this);
24
- const probe = seoOf(site).probe404;
25
- if (probe && probe.status === 200)
26
- add("site", t.soft404Probe(rel(probe.publicUrl, site.base)));
27
- for (const p of site.pages) {
28
- const head = p.h1[0] ?? "";
29
- if (NOT_FOUND.test(p.title) || NOT_FOUND.test(head))
30
- add(p.fetched.publicUrl, t.soft404Page(short(p.title || head)));
31
- }
32
- return out;
33
- },
34
- };
35
- const sec1 = {
36
- id: "SEC-1",
37
- group: "SEC",
38
- level: "error",
39
- applies: (site, t) => (seoOf(site).local ? t.skipLocal : null),
40
- run(site, _c, t) {
41
- const { out, add } = finder(this);
42
- if (!site.base.startsWith("https://")) {
43
- add("site", t.notHttps);
44
- return out;
45
- }
46
- // Only an http:// answer that stays on http:// is a problem; a closed port 80 is not.
47
- const home = seoOf(site).httpHome;
48
- if (home && home.status > 0 && !(home.finalUrl ?? "").startsWith("https://"))
49
- add(home.publicUrl, t.httpNoRedirect(home.finalUrl ?? home.publicUrl));
50
- return out;
51
- },
52
- };
53
- const sec2 = {
54
- id: "SEC-2",
55
- group: "SEC",
56
- level: "warn",
57
- applies: (site, t) => (site.base.startsWith("https://") ? null : t.skipNotHttps),
58
- run(site, _c, t) {
59
- const { out, add } = finder(this);
60
- for (const p of site.pages) {
61
- const insecure = p.resources.filter((r) => r.startsWith("http://"));
62
- if (insecure.length)
63
- add(p.fetched.publicUrl, t.mixedContent(insecure.length, insecure[0]));
64
- }
65
- return out;
66
- },
67
- };
68
- const can1 = {
69
- id: "CAN-1",
70
- group: "CAN",
71
- level: "warn",
72
- run(site, _c, t) {
73
- const { out, add } = finder(this);
74
- for (const p of site.pages)
75
- if (!p.canonical)
76
- add(p.fetched.publicUrl, t.noCanonical);
77
- return out;
78
- },
79
- };
80
- const can2 = {
81
- id: "CAN-2",
82
- group: "CAN",
83
- level: "warn",
84
- run(site, _c, t) {
85
- const { out, add } = finder(this);
86
- const o = { target: site.target, base: site.base };
87
- for (const p of site.pages) {
88
- if (!p.canonical)
89
- continue;
90
- if (!/^https?:\/\//i.test(p.canonical))
91
- add(p.fetched.publicUrl, t.relativeCanonical(p.canonical));
92
- else if (!isSameSite(p.canonical, o))
93
- add(p.fetched.publicUrl, t.foreignCanonical(p.canonical));
94
- }
95
- return out;
96
- },
97
- };
98
- const can3 = {
99
- id: "CAN-3",
100
- group: "CAN",
101
- level: "error",
102
- run(site, _c, t) {
103
- const { out, add } = finder(this);
104
- const { canonicals } = seoOf(site);
105
- const o = { target: site.target, base: site.base };
106
- // A canonical pointing at a sampled page is known from the crawl; others were requested in collect().
107
- const sampled = new Map(site.pages.map((p) => [norm(p.fetched.publicUrl), p.fetched]));
108
- const failed = new Map(site.failed.map((f) => [norm(f.publicUrl), f]));
109
- for (const p of site.pages) {
110
- const href = p.canonical ? absolute(p.canonical, p.fetched.publicUrl) : "";
111
- if (!href || !isSameSite(href, o))
112
- continue;
113
- const key = norm(href);
114
- const known = sampled.get(key) ?? failed.get(key);
115
- const probe = known ? { status: known.status, error: known.error, redirected: Boolean(known.redirected), finalUrl: known.finalUrl ?? known.publicUrl } : canonicals.get(key);
116
- if (!probe)
117
- continue;
118
- if (probe.status !== 200)
119
- add(p.fetched.publicUrl, t.canonicalStatus(rel(href, site.base), statusOf(probe)));
120
- else if (probe.redirected && norm(probe.finalUrl) !== key)
121
- add(p.fetched.publicUrl, t.canonicalRedirects(rel(href, site.base), rel(probe.finalUrl, site.base)), "warn");
122
- }
123
- return out;
124
- },
125
- };
126
- const map1 = {
127
- id: "MAP-1",
128
- group: "MAP",
129
- level: "warn",
130
- run(site, _c, t) {
131
- const { out, add } = finder(this);
132
- if (!site.sitemap)
133
- add("site", t.noSitemap);
134
- else if (site.robots && !/^\s*sitemap\s*:/im.test(site.robots.fetched.body))
135
- add("site", t.sitemapNotInRobots, "info");
136
- return out;
137
- },
138
- };
139
- const inSitemap = (site) => new Set((site.sitemap?.urls ?? []).map((u) => norm(u.loc)));
140
- const map2 = {
141
- id: "MAP-2",
142
- group: "MAP",
143
- level: "warn",
144
- applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
145
- run(site, _c, t) {
146
- const { out, add } = finder(this);
147
- const listed = inSitemap(site);
148
- for (const f of [...site.pages.map((p) => p.fetched), ...site.failed]) {
149
- if (!listed.has(norm(f.publicUrl)) || !f.redirected || !f.finalUrl)
150
- continue;
151
- if (norm(f.finalUrl) !== norm(f.publicUrl))
152
- add(f.publicUrl, t.sitemapRedirects(rel(f.finalUrl, site.base)));
153
- }
154
- return out;
155
- },
156
- };
157
- const map3 = {
158
- id: "MAP-3",
159
- group: "MAP",
160
- level: "warn",
161
- applies: (site, t) => (site.sitemap ? null : t.skipNoSitemap),
162
- run(site, _c, t) {
163
- const { out, add } = finder(this);
164
- const listed = inSitemap(site);
165
- for (const p of site.pages) {
166
- const f = p.fetched;
167
- // A listed address that redirects is MAP-2's finding; its target's canonical says nothing new.
168
- if (!listed.has(norm(f.publicUrl)) || (f.finalUrl && norm(f.finalUrl) !== norm(f.publicUrl)))
169
- continue;
170
- const href = p.canonical ? absolute(p.canonical, f.publicUrl) : "";
171
- if (href && norm(href) !== norm(f.publicUrl))
172
- add(f.publicUrl, t.sitemapCanonicalElsewhere(rel(href, site.base)));
173
- }
174
- return out;
175
- },
176
- };
177
- export const siteRules = [idx1, idx2, sec1, sec2, can1, can2, can3, map1, map2, map3];