search-auditor 0.1.1 → 0.2.1

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 +15 -0
  2. package/README.md +140 -43
  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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/a-bibikov/search-auditor/compare/v0.2.0...v0.2.1) (2026-11-09)
4
+
5
+
6
+ ### Documentation
7
+
8
+ * report screenshot, examples and the current roadmap ([70435c1](https://github.com/a-bibikov/search-auditor/commit/70435c155a16f8be5e6f744b871bfbc576414426))
9
+
10
+ ## [0.2.0](https://github.com/a-bibikov/search-auditor/compare/v0.1.1...v0.2.0) (2026-11-09)
11
+
12
+
13
+ ### New checks and features
14
+
15
+ * expand SEO checks and split rules into modules ([#10](https://github.com/a-bibikov/search-auditor/issues/10)) ([f79eddb](https://github.com/a-bibikov/search-auditor/commit/f79eddbd20487f50418ac20c84a1964820877c8c))
16
+ * expand SEO checks and split rules into modules ([#10](https://github.com/a-bibikov/search-auditor/issues/10)) ([41f4cab](https://github.com/a-bibikov/search-auditor/commit/41f4cab66cefe1f06402b40bf58ff8cf9acf7caf))
17
+
3
18
  ## [0.1.1](https://github.com/a-bibikov/search-auditor/compare/v0.1.0...v0.1.1) (2026-11-04)
4
19
 
5
20
 
package/README.md CHANGED
@@ -1,72 +1,160 @@
1
1
  # search-auditor
2
2
 
3
- **Can Google, Bing and AI search find, read and cite your site?** One crawl, one report: classic SEO checks — status codes, canonical, sitemap, titles, headings, broken links, hreflang, HTTPS — and the AI search checks of [ai-search-auditor](https://github.com/a-bibikov/ai-search-audit), with a score for each and one overall.
3
+ **Can Google, Bing and Yandex reach, index and show your site?** A technical SEO audit in one command: status codes, robots.txt, redirects, canonical, sitemap, titles and descriptions, headings, broken links, images, structured data, hreflang, HTTPS. 80 checks, each with what it costs and how to fix it.
4
4
 
5
5
  ```bash
6
6
  npx search-auditor https://example.com
7
7
  ```
8
8
 
9
- No sign-up, no API keys. Node.js 22.18+.
9
+ ![The report for albibikov.com: the score, what to do first and the first group of the checklist](docs/report.png)
10
10
 
11
- ## Why both in one report
11
+ No sign-up, no API keys. Node.js 22.18+.
12
12
 
13
- People find sites in two ways now: a list of links in Google, Bing or Yandex, and an answer from ChatGPT, Claude, Perplexity or Google AI Overviews that cites a few sources. Both start from the same thing — a crawler that can reach the page, read it and tell what it is about — and then they diverge. A broken canonical drops a page from Google; a heading that glues words together makes AI search misquote it.
13
+ ## AI search
14
14
 
15
- Running two tools means two crawls, two reports and two scores that never meet. search-auditor crawls once, runs both rule sets on the same pages and puts the results side by side, so the first three things to fix are the three that cost the most, whichever kind of search they hurt.
15
+ Whether ChatGPT, Claude and Perplexity can read and cite the site is checked by [ai-search-auditor](https://github.com/a-bibikov/ai-search-audit), with a report of its own: `npx ai-search-auditor https://example.com`. Both run on [auditor-engine](https://github.com/a-bibikov/auditor-engine), so the reports look the same.
16
16
 
17
17
  ## What it checks
18
18
 
19
- 24 classic search checks in ten groups, plus the 20 AI search checks of ai-search-auditor (turn them off with `--no-ai`). Nothing is checked twice: robots.txt for search crawlers, `noindex` and link cards are part of the AI search set.
19
+ 80 checks in ten groups.
20
20
 
21
21
  **IDX. Indexing**
22
22
  - **IDX-1** Audited pages answer 200
23
23
  - **IDX-2** A page that does not exist answers 404, and no «not found» page answers 200
24
-
25
- **SEC. HTTPS** (live sites only)
24
+ - **IDX-3** robots.txt answers without errors: no 5xx or timeout (Google then treats the site as closed), plain-text rules, under 500 KB
25
+ - **IDX-4** robots.txt does not block the pages of the site — those in the sitemap and the audited ones — for Googlebot, Bingbot or Yandex
26
+ - **IDX-5** The site has one version: the other host (with or without www.) and the other trailing slash redirect to the main address
27
+ - **IDX-6** Redirects take one step: no chains, no loops
28
+ - **IDX-7** `noindex` does not close the home page and does not contradict the sitemap or the canonical
29
+ - **IDX-8** robots.txt does not block the CSS and JavaScript the page needs to render
30
+ - **IDX-9** The server answers quickly: first byte in under 0.8 s (live sites only)
31
+
32
+ **SEC. Security and HTTPS** (the connection checks need the live site)
26
33
  - **SEC-1** The site is on https, and http:// redirects to it
27
34
  - **SEC-2** Pages do not load anything over http://
35
+ - **SEC-3** The redirect from http:// is permanent (301 or 308), not a temporary 302
36
+ - **SEC-4** The certificate is trusted and does not expire within two weeks
37
+ - **SEC-5** HSTS tells browsers to use only https, for half a year at least
38
+ - **SEC-6** The site's own addresses use https://: links, canonical, hreflang, og tags, sitemap
39
+ - **SEC-7** Basic security headers: nosniff, frame protection, Referrer-Policy
28
40
 
29
- **CAN. Canonical**
41
+ **CAN. Canonical: the main address of each page**
30
42
  - **CAN-1** Every page has a canonical
31
43
  - **CAN-2** The canonical is an absolute address on this site
32
44
  - **CAN-3** The canonical leads to a page that answers 200, not to an error or a redirect
33
-
34
- **MAP. Sitemap**
45
+ - **CAN-4** One canonical per page: two different ones make Google ignore both
46
+ - **CAN-5** The canonical sits in `<head>`; one in `<body>` is ignored
47
+ - **CAN-6** The canonical in the HTML and in the `Link` header agree
48
+ - **CAN-7** Canonicals lead straight to the main page, not through another canonical
49
+ - **CAN-8** An address with UTM tags names the clean address as canonical
50
+ - **CAN-9** Language versions do not name each other as canonical
51
+
52
+ **MAP. Sitemap: the map of the site for search engines**
35
53
  - **MAP-1** There is a sitemap, and robots.txt points to it
36
54
  - **MAP-2** Sitemap addresses do not redirect
37
55
  - **MAP-3** Sitemap pages are their own canonical
56
+ - **MAP-4** The sitemap file is valid: XML that answers 200, no bare &, within 50,000 addresses and 50 MB, every file of an index answers
57
+ - **MAP-5** Sitemap addresses are full and on this site
58
+ - **MAP-6** Pages the site links to are in the sitemap
59
+ - **MAP-7** Every sitemap address answers, including up to 50 beyond the audited sample
60
+ - **MAP-8** lastmod is honest: a W3C date, not in the future, not the same build time on every address
38
61
 
39
- **META. Titles and descriptions**
62
+ **META. Meta tags: how a page looks in search results**
40
63
  - **META-1** Every page has a title of about 10–60 characters
41
64
  - **META-2** Every page has a description of about 50–160 characters
42
65
  - **META-3** Titles and descriptions are not repeated across pages
43
-
44
- **STR. Page structure**
66
+ - **META-4** One title and one description per page
67
+ - **META-5** The title names the page, not a template: no «Home», «Untitled» or «React App»
68
+ - **META-6** The description adds to the title instead of repeating it
69
+ - **META-7** The page declares its encoding
70
+ - **META-8** The snippet is not forbidden with `nosnippet` or `max-snippet:0`
71
+ - **META-9** The site has a favicon
72
+
73
+ **STR. Page structure: headings, language, mobile**
45
74
  - **STR-1** One H1 per page
46
75
  - **STR-2** Heading levels do not skip
47
76
  - **STR-3** The page declares its language
48
77
  - **STR-4** Pages are set up for mobile
49
-
50
- **LNK. Links**
78
+ - **STR-5** Headings are not empty: an H1 that is only a logo reads as no H1
79
+ - **STR-6** The title and the H1 are about the same thing, or Google may rewrite the title
80
+ - **STR-7** The page can be zoomed on a phone
81
+ - **STR-8** `<html lang>` matches the language of the text
82
+ - **STR-9** The main content sits in `<main>`
83
+ - **STR-10** The page starts with `<!doctype html>`
84
+
85
+ **LNK. Links: how pages link to each other**
51
86
  - **LNK-1** Internal links work
52
87
  - **LNK-2** Internal links do not go through redirects
88
+ - **LNK-3** Every page is linked from another page: no orphans only the sitemap knows (with `--pages all`)
89
+ - **LNK-4** Links can be followed by a crawler: no `<a>` without href, `#` or `javascript:` for navigation
90
+ - **LNK-5** Internal links do not carry `nofollow`
91
+ - **LNK-6** Link text says where the link goes: no «read more», no image links without alt
92
+ - **LNK-7** Links to other sites still work: up to 30 checked, only 404, 410 and missing domains count as dead
53
93
 
54
- **IMG. Images**
94
+ **IMG. Images: image search and loading speed**
55
95
  - **IMG-1** Images have alt text
56
-
57
- **DATA. Structured data**
96
+ - **IMG-2** Images have width and height, so the layout does not jump (CLS)
97
+ - **IMG-3** Lazy loading where it helps: not on the first image, yes on long pages
98
+ - **IMG-4** Photos are served in modern formats: WebP or AVIF, not JPEG and PNG only
99
+ - **IMG-5** Images open
100
+ - **IMG-6** Images are not heavier than 500 KB
101
+ - **IMG-7** Alt text describes the image: not a file name, «photo» or a paragraph
102
+
103
+ **DATA. Structured data: rich results**
58
104
  - **DATA-1** JSON-LD has no syntax errors
105
+ - **DATA-2** Every block has a schema.org `@context`, every entity a `@type`
106
+ - **DATA-3** Rich-result types have the properties Google requires: Product, Event, BreadcrumbList, Recipe, VideoObject, LocalBusiness, JobPosting, Course, ratings and reviews
107
+ - **DATA-4** Nested pages have breadcrumbs (BreadcrumbList)
108
+ - **DATA-5** Addresses in the markup are full, dates are ISO 8601
109
+ - **DATA-6** The home page names the site with WebSite
59
110
 
60
- **LANG. Language versions** (sites with hreflang)
111
+ **LANG. Language versions: hreflang for multilingual sites** (sites with hreflang)
61
112
  - **LANG-1** hreflang codes are valid
62
113
  - **LANG-2** Language versions point at each other
63
114
  - **LANG-3** There is an x-default
115
+ - **LANG-4** Every version lists itself in hreflang
116
+ - **LANG-5** hreflang leads to working canonical pages: no redirects, errors or copies
117
+ - **LANG-6** The hreflang language matches the `<html lang>` of the page it points to
118
+ - **LANG-7** Each language is listed once
119
+ - **LANG-8** hreflang addresses are full
64
120
 
65
121
  Levels: `error` — search engines cannot index the page or index the wrong one; `warn` — lowers rankings or clicks; `info` — a recommendation.
66
122
 
67
- The report opens with the overall score and one per part («Classic search», «AI search»), then what to do first — the three changes worth the most, in plain words and with how many pages they touch — then the findings with fixes, and ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note, – not checked. A check that cannot run on a site — hreflang on a site in one language, HTTPS on a local build — is marked «not checked» with the reason, never as passed.
123
+ The report opens with the score and the number of errors, warnings and notes. Then what to do first: the three changes worth the most, each with what to do, on which pages it was found and why it matters. Then a table per group: every check with ✓ OK, ✖ error, ⚠ warning, ℹ note or – not checked, and what was found. It ends with one table of recommendations in checklist order, group by group: the check, its severity, what to do, and a comment on what was found where. A check that cannot run on a site — hreflang on a site in one language, HTTPS on a local build — is marked «not checked» with the reason, never as passed.
124
+
125
+ ```
126
+ ### META. Meta tags: how a page looks in search results
127
+
128
+ 7 of 9 OK
129
+
130
+ | | Check | Result |
131
+ |-----|--------------------------------------------------------------|----------------------------|
132
+ | ✓ | META-1 Every page has a title of the right length | OK |
133
+ | ℹ | META-2 Every page has a description of the right length | 1 note · 1 of 20 pages |
134
+ | ⚠ | META-3 Titles and descriptions are not repeated across pages | 4 warnings · 4 of 20 pages |
135
+ | ✓ | META-4 One title and one description per page | OK |
136
+ | ✓ | META-5 The title names the page, not a template | OK |
137
+ | ✓ | META-6 The description adds to the title | OK |
138
+ | ✓ | META-7 The page declares its encoding | OK |
139
+ | ✓ | META-8 The snippet is not forbidden | OK |
140
+ | ✓ | META-9 The site has a favicon | OK |
141
+ ```
68
142
 
69
- **Score:** each part is scored like ai-search-auditor: 100 minus 15 for each check with an error and 5 for each check with a warning; a warning that covers at least half of the audited pages costs 10. The overall score is the mean of the parts. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below; if fewer than two thirds of the checks could run, the grade is capped at B.
143
+ With `--json`, every finding reads on its own: what the check is, what was found against which norm, why it is a problem and how to fix it.
144
+
145
+ ```json
146
+ {
147
+ "rule": "META-2",
148
+ "level": "info",
149
+ "title": "Every page has a description of the right length",
150
+ "where": "https://albibikov.com/ru/privacy",
151
+ "message": "description of 165 characters, should be 50–160",
152
+ "why": "Without a description search engines pick a random snippet, a long one is cut off mid-sentence, and fewer people click.",
153
+ "fix": "Add `<meta name=\"description\">` of about 50–160 characters: what the page offers and why to open it."
154
+ }
155
+ ```
156
+
157
+ **Score:** 100 minus 15 for each check with an error and 5 for each check with a warning; a warning that covers at least half of the audited pages costs 10. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below; if fewer than two thirds of the checks could run, the grade is capped at B.
70
158
 
71
159
  ## Usage
72
160
 
@@ -80,8 +168,8 @@ npx search-auditor http://127.0.0.1:3000 --base https://example.com
80
168
  # Every page in the sitemap instead of a sample of 20
81
169
  npx search-auditor https://example.com --pages all
82
170
 
83
- # Classic search only, report in Russian
84
- npx search-auditor https://example.com --no-ai --lang ru
171
+ # Report in Russian
172
+ npx search-auditor https://example.com --lang ru
85
173
 
86
174
  # JSON for scripts and agents
87
175
  npx search-auditor https://example.com --json --out report.json
@@ -92,23 +180,21 @@ npx search-auditor https://example.com --json --out report.json
92
180
  | `--base <url>` | Public origin when auditing a local build |
93
181
  | `--pages <n\|all>` | Pages to audit: a number sampled across templates (default 20), or `all` for every page in the sitemap |
94
182
  | `--max-links <n>` | Internal links to request (default 100) |
95
- | `--no-ai` | Classic search checks only |
96
183
  | `--fail-on <level>` | Exit 1 on `error` (default), `warn` or `never` |
97
184
  | `--lang <lang>` | Report language: `en` (default), `ru` |
98
- | `--json` | JSON instead of Markdown |
185
+ | `--width <n>` | Fit tables into n characters; long cells wrap inside their column. Default: the terminal width in a terminal, plain Markdown in a file or a pipe; `0` turns wrapping off |
186
+ | `--json` | JSON instead of Markdown: each finding has its check `title`, the `message`, `why` it is a problem and how to `fix` it |
99
187
  | `--out <file>` | Also write the report to a file |
100
188
  | `--config <file>` | Config file (default `search-auditor.config.json`) |
101
189
  | `-v`, `--verbose` | Show progress |
102
190
 
103
- Config — the options of ai-search-auditor plus two of its own:
191
+ Config:
104
192
 
105
193
  ```json
106
194
  {
107
- "disable": ["STR-2", "C-7"],
195
+ "disable": ["STR-2", "IMG-4"],
108
196
  "maxPages": 20,
109
197
  "maxLinks": 100,
110
- "ai": true,
111
- "trainingBots": "any",
112
198
  "lang": "en"
113
199
  }
114
200
  ```
@@ -122,25 +208,36 @@ In CI the Markdown report also goes to the job summary (`GITHUB_STEP_SUMMARY`).
122
208
  ```js
123
209
  import { auditSearch, toMarkdown } from "search-auditor";
124
210
 
125
- const report = await auditSearch("https://example.com", { config: { lang: "en", ai: true } });
126
- console.log(report.score, report.sections);
211
+ const report = await auditSearch("https://example.com", { config: { lang: "en" } });
212
+ console.log(report.score, report.grade);
127
213
  console.log(toMarkdown(report));
128
214
  ```
129
215
 
130
- `ai-search-auditor` is a peer dependency: the crawl, the scoring and the report come from it, so both parts always run on the same engine version. The SEO checks are also exported as `seoPlugin` — rules, texts, groups and the extra requests they need — for runners that bring their own crawl.
216
+ The crawl, the scoring and the report come from [auditor-engine](https://github.com/a-bibikov/auditor-engine). The checks are exported as `seoRules`, with `collect` for the extra requests they need, for your own runner.
217
+
218
+ ## Example
219
+
220
+ [albibikov.com](https://albibikov.com), a Next.js site in English and Russian (20 pages, local production build): **95/100 (A)**, 70 of 80 checks run. The connection checks — HTTPS, the certificate, HSTS, the server speed — need the live site and are marked «not checked» on a local build; so are external links when the audit has no internet access.
221
+
222
+ | | Found |
223
+ |---|---|
224
+ | META-3 ⚠ | The same title on `/` and `/cv`, and on `/ru` and `/ru/cv` |
225
+ | META-2 ℹ | A description of 165 characters on `/ru/privacy`, should be 50–160 |
226
+ | DATA-6 ℹ | No WebSite with the site name on the home page |
131
227
 
132
228
  ## Roadmap
133
229
 
134
- - **IDX-3** `noindex` and canonical do not contradict each other
135
- - **IDX-4** robots.txt does not block CSS and JavaScript the page needs to render
136
- - **CAN-4** Canonical chains: a canonical target whose own canonical points further
137
- - **MAP-4** Every audited page is in the sitemap; the sitemap has no pages blocked by robots.txt
138
- - **LNK-3** Orphan pages: in the sitemap, but no internal link leads to them
139
- - **LNK-4** Redirect chains of more than one hop
140
- - **IMG-2** Images are sized and lazy-loaded, so they do not slow the page down
141
- - **PERF-1** Server response time and page weight
142
- - **SOC-1** Twitter / X cards in addition to Open Graph
230
+ **PERF. Speed** — page weight, render-blocking scripts and styles, Core Web Vitals from the Chrome UX Report
231
+
232
+ **SOC. Link cards** — Open Graph and Twitter / X cards for every page
233
+
234
+ - **GitHub Action**, like the one of [ai-search-auditor](https://github.com/a-bibikov/ai-search-audit#github-action), with the report in the job summary
235
+ - **CI that reports only new problems,** with a file of accepted exceptions
236
+ - **SARIF output,** so findings show up in GitHub code scanning
237
+ - **HTML report** to share with people who do not read Markdown
238
+
239
+ Ideas are welcome in [issues](https://github.com/a-bibikov/search-auditor/issues); for pull requests see [CONTRIBUTING.md](CONTRIBUTING.md).
143
240
 
144
241
  ## License
145
242
 
146
- MIT
243
+ MIT © [Aleksandr Bibikov](https://albibikov.com)
package/dist/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { parseArgs } from "node:util";
4
- import { DEFAULT_LANG, LANGS, isLang, parsePages, toMarkdown } from "ai-search-auditor";
4
+ import { DEFAULT_LANG, LANGS, isLang, parsePages, toJSON, toMarkdown } from "auditor-engine";
5
5
  import { auditSearch } from "./index.js";
6
- const HELP = `search-auditor — one crawl, one report: can Google, Bing and AI search (ChatGPT, Claude, Perplexity) find, read and cite your site?
6
+ const HELP = `search-auditor — technical SEO audit: can Google, Bing and Yandex reach, index and show your site?
7
+
8
+ AI search (ChatGPT, Claude, Perplexity) is checked by ai-search-auditor: npx ai-search-auditor <url>
7
9
 
8
10
  Usage:
9
11
  search-auditor <url> [options]
@@ -12,9 +14,9 @@ Options:
12
14
  --base <url> public origin when auditing a local build (links in sitemap point there)
13
15
  --pages <n|all> pages to audit: a number sampled across templates (default 20), or all pages in the sitemap
14
16
  --max-links <n> internal links to request (default 100)
15
- --no-ai classic search checks only
16
17
  --fail-on <level> exit 1 on: error (default), warn, never
17
18
  --lang <lang> report language: ${Object.keys(LANGS).join(", ")} (default ${DEFAULT_LANG})
19
+ --width <n> fit tables into n characters (default: the terminal width; 0 — no wrapping)
18
20
  --json print the JSON report instead of Markdown
19
21
  --out <file> also write the report to a file (.json or .md)
20
22
  --config <file> config file (default: search-auditor.config.json if present)
@@ -24,23 +26,24 @@ Options:
24
26
  Examples:
25
27
  npx search-auditor https://example.com
26
28
  npx search-auditor http://127.0.0.1:3000 --base https://example.com --fail-on warn
27
- npx search-auditor https://example.com --lang ru --no-ai
29
+ npx search-auditor https://example.com --lang ru
28
30
  npx search-auditor https://example.com --pages all
29
31
  `;
30
32
  // An unknown or malformed flag gets one line with the help hint, not a stack trace.
31
33
  let parsed;
32
34
  try {
33
35
  parsed = parseArgs({
36
+ // --no-ai turned the AI search checks off; they are no longer here, so old scripts keep working without it.
37
+ args: process.argv.slice(2).filter((a) => a !== "--no-ai"),
34
38
  allowPositionals: true,
35
- allowNegative: true,
36
39
  options: {
37
40
  base: { type: "string" },
38
41
  pages: { type: "string" },
39
42
  "max-links": { type: "string" },
40
- ai: { type: "boolean" },
41
43
  "fail-on": { type: "string", default: "error" },
42
44
  lang: { type: "string" },
43
45
  json: { type: "boolean", default: false },
46
+ width: { type: "string" },
44
47
  out: { type: "string" },
45
48
  config: { type: "string" },
46
49
  verbose: { type: "boolean", short: "v", default: false },
@@ -90,7 +93,6 @@ const config = {
90
93
  lang,
91
94
  ...(maxPages ? { maxPages } : {}),
92
95
  ...(maxLinks ? { maxLinks } : {}),
93
- ...(values.ai === false ? { ai: false } : {}),
94
96
  };
95
97
  // Progress goes to stderr, so `--json > report.json` stays clean.
96
98
  const log = values.verbose ? (line) => process.stderr.write(`· ${line}\n`) : undefined;
@@ -102,9 +104,15 @@ catch (e) {
102
104
  fail(e.message);
103
105
  }
104
106
  const markdown = toMarkdown(report);
105
- process.stdout.write(values.json ? JSON.stringify(report, null, 2) + "\n" : markdown);
107
+ // In a terminal, tables fit the window and long cells wrap; a file and a CI summary keep plain Markdown.
108
+ if (values.width !== undefined && !/^\d+$/.test(values.width))
109
+ fail(`--width must be a whole number, got «${values.width}»`);
110
+ const columns = values.width !== undefined ? Number(values.width) : process.stdout.isTTY ? process.stdout.columns : 0;
111
+ const screen = columns > 0 ? toMarkdown(report, { width: columns }) : markdown;
112
+ const json = JSON.stringify(toJSON(report), null, 2) + "\n";
113
+ process.stdout.write(values.json ? json : screen);
106
114
  if (values.out)
107
- writeFileSync(values.out, values.out.endsWith(".json") ? JSON.stringify(report, null, 2) : markdown);
115
+ writeFileSync(values.out, values.out.endsWith(".json") ? json : markdown);
108
116
  if (process.env.GITHUB_STEP_SUMMARY)
109
117
  appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
110
118
  const failOn = values["fail-on"];
package/dist/collect.d.ts CHANGED
@@ -1,5 +1,9 @@
1
- import { type Site } from "ai-search-auditor";
1
+ import { type Site } from "auditor-engine";
2
2
  import type { SeoExtras, SeoSite } from "./types.ts";
3
+ /** Parameters every campaign link adds: the page must still name its clean address as canonical. */
4
+ export declare const UTM = "utm_source=search-auditor&utm_medium=audit";
5
+ /** The <head> of a page: up to </head>, or up to <body> when the closing tag is left out. */
6
+ export declare const headOf: (html: string) => string;
3
7
  export interface CollectOptions {
4
8
  maxLinks: number;
5
9
  concurrency?: number;
package/dist/collect.js CHANGED
@@ -1,10 +1,74 @@
1
- import { fetchUrl, isSameSite, pool } from "ai-search-auditor";
1
+ import { fetchUrl, isSameSite, parsePage, parseSitemap, pool } from "auditor-engine";
2
2
  import { norm } from "./rules/util.js";
3
+ import { certificateOf, firstHop, timeToFirstByte, trace } from "./trace.js";
4
+ /** Redirecting addresses followed hop by hop per run: enough to show a pattern without a request storm. */
5
+ const MAX_CHAINS = 20;
6
+ /** Requests to time the home page: the median of three smooths out one slow answer. */
7
+ const TTFB_SAMPLES = 3;
8
+ /** The same host with www. added or removed. */
9
+ const otherHostOf = (base) => {
10
+ const u = new URL(base);
11
+ u.hostname = u.hostname.startsWith("www.") ? u.hostname.slice(4) : `www.${u.hostname}`;
12
+ return `${u.origin}/`;
13
+ };
14
+ /** Parameters every campaign link adds: the page must still name its clean address as canonical. */
15
+ export const UTM = "utm_source=search-auditor&utm_medium=audit";
16
+ /** Absolute canonical of a fetched page, or "" when it has none or it does not parse. */
17
+ const canonicalOf = (f) => {
18
+ if (f.status !== 200)
19
+ return "";
20
+ const href = parsePage(f).canonical;
21
+ try {
22
+ return href ? new URL(href, f.publicUrl).href : "";
23
+ }
24
+ catch {
25
+ return "";
26
+ }
27
+ };
28
+ /** The <head> of a page: up to </head>, or up to <body> when the closing tag is left out. */
29
+ export const headOf = (html) => {
30
+ const end = html.search(/<\/head\s*>|<body[\s>]/i);
31
+ return end < 0 ? html : html.slice(0, end);
32
+ };
33
+ /** Sitemap addresses outside the sample requested per run: enough to catch a pattern of dead pages. */
34
+ const MAX_SITEMAP_EXTRA = 50;
35
+ /** Images of the site requested per run: enough to catch broken and heavy ones. */
36
+ const MAX_IMAGES = 30;
37
+ /** Links to other sites requested per run: enough to catch dead ones without crawling the web. */
38
+ const MAX_EXTERNAL = 30;
39
+ /** Child sitemaps of an index requested per run. */
40
+ const MAX_SITEMAP_CHILDREN = 10;
41
+ /** The sitemap protocol's limits per file: 50,000 addresses and 50 MB uncompressed. Read a little past them. */
42
+ const SITEMAP_MAX_BYTES = 52 * 1024 * 1024;
43
+ /** What a sitemap file is and how it breaks the format, from its raw body. */
44
+ function sitemapFileOf(url, source, f) {
45
+ const body = f.body;
46
+ const kind = /\.gz(\?|$)/i.test(url) && !/<(urlset|sitemapindex)\b/i.test(body)
47
+ ? "gzip"
48
+ : /<sitemapindex\b/i.test(body)
49
+ ? "index"
50
+ : /<urlset\b/i.test(body)
51
+ ? "urlset"
52
+ : /^\s*<(!doctype html|html)/i.test(body)
53
+ ? "html"
54
+ : "other";
55
+ const count = (body.match(kind === "index" ? /<sitemap\b/gi : /<url\b/gi) ?? []).length;
56
+ const badAmpersands = [...body.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)].filter((m) => /&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/i.test(m[1])).length;
57
+ return { url: f.publicUrl, source, status: f.status, ...(f.error ? { error: f.error } : {}), kind, bytes: Buffer.byteLength(body), count, badAmpersands };
58
+ }
59
+ /** A sampled page whose address can be asked with the trailing slash toggled: not the home page, not a file. */
60
+ const slashCandidate = (site) => site.pages.find((p) => {
61
+ const path = new URL(p.fetched.publicUrl).pathname;
62
+ return path !== "/" && !/\.[a-z0-9]{2,5}$/i.test(path) && !p.fetched.redirected;
63
+ });
3
64
  const probeOf = (f, from) => ({
4
65
  status: f.status,
5
66
  redirected: Boolean(f.redirected),
6
67
  finalUrl: f.finalUrl ?? f.publicUrl,
68
+ html: /text\/html|application\/xhtml/i.test(f.headers.get("content-type") ?? ""),
69
+ noindex: f.status === 200 && /\b(noindex|none)\b/i.test(`${f.headers.get("x-robots-tag") ?? ""} ${f.body.match(/<meta[^>]+name=["']?robots["']?[^>]*>/i)?.[0] ?? ""}`),
7
70
  ...(f.error ? { error: f.error } : {}),
71
+ ...(Number(f.headers.get("content-length")) > 0 ? { bytes: Number(f.headers.get("content-length")) } : {}),
8
72
  ...(from ? { from } : {}),
9
73
  });
10
74
  /** Links worth requesting: pages, not files a crawler would not index anyway. */
@@ -38,36 +102,147 @@ export async function collect(site, opts) {
38
102
  })
39
103
  .filter((u) => u && isSameSite(u, o) && !known.has(norm(u)))),
40
104
  ].slice(0, 20);
41
- const firstSeen = new Map();
105
+ // Every audited page that links to each address: a broken link is fixed on the pages that have it.
106
+ const linkedFrom = new Map();
42
107
  for (const p of site.pages)
43
108
  for (const href of p.links)
44
- if (isSameSite(href, o) && isPageLike(href) && !firstSeen.has(norm(href)))
45
- firstSeen.set(norm(href), p.fetched.publicUrl);
46
- const linkTargets = [...firstSeen.keys()].filter((u) => !known.has(u));
109
+ if (isSameSite(href, o) && isPageLike(href)) {
110
+ const list = linkedFrom.get(norm(href)) ?? [];
111
+ if (!list.includes(p.fetched.publicUrl))
112
+ linkedFrom.set(norm(href), [...list, p.fetched.publicUrl]);
113
+ }
114
+ const linkTargets = [...linkedFrom.keys()].filter((u) => !known.has(u));
47
115
  const toCheck = linkTargets.slice(0, opts.maxLinks);
48
- log(`${canonicalTargets.length} canonical targets, ${toCheck.length} of ${firstSeen.size} internal links`);
116
+ log(`${canonicalTargets.length} canonical targets, ${toCheck.length} of ${linkedFrom.size} internal links`);
49
117
  const light = { maxBytes: 65_536, timeoutMs: 10_000 };
50
- const [probe404, httpHome, canonicals, linked] = await Promise.all([
118
+ const slashPage = slashCandidate(site);
119
+ const slashUrl = slashPage ? slashPage.fetched.publicUrl.replace(/\/?$/, (m) => (m ? "" : "/")) : null;
120
+ const liveHttps = !local && site.base.startsWith("https://");
121
+ const utmPage = site.pages.find((p) => new URL(p.fetched.publicUrl).pathname === "/" && !p.fetched.publicUrl.includes("?")) ?? site.pages[0];
122
+ const utmUrl = utmPage ? `${utmPage.fetched.publicUrl}${utmPage.fetched.publicUrl.includes("?") ? "&" : "?"}${UTM}` : null;
123
+ const host = new URL(site.base).hostname;
124
+ // The icon search results show next to the address: declared in <head>, or /favicon.ico by convention.
125
+ const declaresIcon = utmPage ? /<link\b[^>]*\brel\s*=\s*["']?[^"'>]*\bicon\b/i.test(headOf(utmPage.fetched.body)) : true;
126
+ const [probe404, httpHome, canonicals, linked, robotsRaw, otherHost, slashFetched, ttfb, httpHop, cert, utmFetched, favicon] = await Promise.all([
51
127
  fetchUrl(`${site.target}/__search-auditor-${Math.random().toString(36).slice(2, 10)}`, o, light),
52
128
  !local && site.base.startsWith("https://") ? fetchUrl(`http://${new URL(site.base).host}/`, o, light) : Promise.resolve(null),
53
129
  pool(canonicalTargets, conc, (u) => fetchUrl(u, o, light)),
54
130
  pool(toCheck, conc, (u) => fetchUrl(u, o, light)),
131
+ // Up to 600 KB: enough to tell a robots.txt over Google's 500 KB limit.
132
+ fetchUrl(`${site.base}/robots.txt`, o, { accept: "text/plain", maxBytes: 614_400, timeoutMs: 10_000 }),
133
+ local ? Promise.resolve(null) : fetchUrl(otherHostOf(site.base), { target: otherHostOf(site.base).slice(0, -1), base: otherHostOf(site.base).slice(0, -1) }, light),
134
+ slashUrl ? fetchUrl(slashUrl, o, light) : Promise.resolve(null),
135
+ local ? Promise.resolve(null) : (async () => {
136
+ const times = [];
137
+ for (let i = 0; i < TTFB_SAMPLES; i++) {
138
+ const ms = await timeToFirstByte(`${site.base}/`, o);
139
+ if (ms !== null)
140
+ times.push(ms);
141
+ }
142
+ return times.length ? times : null;
143
+ })(),
144
+ liveHttps ? firstHop(`http://${new URL(site.base).host}/`) : Promise.resolve(null),
145
+ liveHttps ? certificateOf(host) : Promise.resolve(null),
146
+ utmUrl ? fetchUrl(utmUrl, o, light) : Promise.resolve(null),
147
+ declaresIcon ? Promise.resolve(null) : fetchUrl(`${site.base}/favicon.ico`, o, { ...light, accept: "image/*" }),
55
148
  ]);
56
149
  const links = new Map();
57
- for (const [url, from] of firstSeen) {
150
+ for (const [url, from] of linkedFrom) {
58
151
  const done = known.get(url);
59
152
  if (done)
60
153
  links.set(url, { ...done, from });
61
154
  }
62
- toCheck.forEach((url, i) => links.set(url, probeOf(linked[i], firstSeen.get(url))));
155
+ toCheck.forEach((url, i) => links.set(url, probeOf(linked[i], linkedFrom.get(url))));
156
+ // Every address known to redirect, with the pages that point at it: sampled pages, links, canonicals, sitemap.
157
+ const redirecting = new Map();
158
+ const note = (url, from) => redirecting.set(url, [...new Set([...(redirecting.get(url) ?? []), ...from])]);
159
+ const listed = new Set((site.sitemap?.urls ?? []).map((u) => norm(u.loc)));
160
+ for (const f of [...site.pages.map((p) => p.fetched), ...site.failed])
161
+ if (f.redirected && f.finalUrl && norm(f.finalUrl) !== norm(f.publicUrl))
162
+ note(f.publicUrl, listed.has(norm(f.publicUrl)) ? ["sitemap.xml"] : []);
163
+ // A link that failed without an answer may be a redirect loop: fetch gives up on those without saying so.
164
+ for (const [url, p] of links)
165
+ if ((p.redirected && norm(p.finalUrl) !== norm(url)) || p.status === 0)
166
+ note(url, p.from ?? []);
167
+ if (httpHome?.redirected)
168
+ note(httpHome.publicUrl, []);
169
+ const toTrace = [...redirecting.keys()].slice(0, MAX_CHAINS);
170
+ const traced = await pool(toTrace, conc, (u) => trace(u, o));
171
+ const chains = toTrace.map((url, i) => ({ url, ...traced[i], from: redirecting.get(url) ?? [] }));
172
+ // The sitemap files as robots.txt declares them (or the default address), then the children of an index.
173
+ const declared = robotsRaw.status === 200 ? [...robotsRaw.body.matchAll(/^\s*sitemap\s*:\s*(\S+)/gim)].map((m) => m[1]) : [];
174
+ const roots = declared.length
175
+ ? [...new Set(declared)].slice(0, 5).map((url) => ({ url, source: "robots" }))
176
+ : [{ url: `${site.base}/sitemap.xml`, source: "default" }];
177
+ const xml = { accept: "application/xml,text/xml", maxBytes: SITEMAP_MAX_BYTES, timeoutMs: 20_000 };
178
+ const rootFetched = await pool(roots, conc, (r) => fetchUrl(r.url, o, xml));
179
+ const sitemapFiles = roots.map((r, i) => sitemapFileOf(r.url, r.source, rootFetched[i]));
180
+ const childUrls = rootFetched.flatMap((f) => (f.status === 200 ? parseSitemap(f.body).children : [])).slice(0, MAX_SITEMAP_CHILDREN);
181
+ const childFetched = await pool(childUrls, conc, (u) => fetchUrl(u, o, xml));
182
+ sitemapFiles.push(...childUrls.map((u, i) => sitemapFileOf(u, "index", childFetched[i])));
183
+ // Sitemap addresses the crawl did not sample: do they answer at all?
184
+ const extraUrls = (site.sitemap?.urls ?? [])
185
+ .map((u) => u.loc)
186
+ .filter((u) => isSameSite(u, o) && !known.has(norm(u)))
187
+ .slice(0, MAX_SITEMAP_EXTRA);
188
+ const extraFetched = await pool(extraUrls, conc, (u) => fetchUrl(u, o, light));
189
+ const sitemapExtra = new Map(extraUrls.map((u, i) => [norm(u), probeOf(extraFetched[i])]));
190
+ // Links to other sites, with the pages that have them: do they still exist?
191
+ const externalFrom = new Map();
192
+ for (const p of site.pages)
193
+ for (const href of p.links)
194
+ if (/^https?:\/\//i.test(href) && !isSameSite(href, o))
195
+ externalFrom.set(href, [...new Set([...(externalFrom.get(href) ?? []), p.fetched.publicUrl])]);
196
+ const externalUrls = [...externalFrom.keys()].slice(0, MAX_EXTERNAL);
197
+ const externalFetched = await pool(externalUrls, conc, (u) => fetchUrl(u, o, { maxBytes: 16_384, timeoutMs: 8_000 }));
198
+ const external = new Map(externalUrls.map((u, i) => [u, probeOf(externalFetched[i], externalFrom.get(u))]));
199
+ // Images of this site, with the pages that use them. Only the headers matter: status and Content-Length.
200
+ const imageFrom = new Map();
201
+ for (const p of site.pages)
202
+ for (const img of p.images)
203
+ if (img.src && isSameSite(img.src, o) && !img.src.startsWith("data:"))
204
+ imageFrom.set(img.src, [...new Set([...(imageFrom.get(img.src) ?? []), p.fetched.publicUrl])]);
205
+ const imageUrls = [...imageFrom.keys()].slice(0, MAX_IMAGES);
206
+ const imageFetched = await pool(imageUrls, conc, (u) => fetchUrl(u, o, { maxBytes: 1024, timeoutMs: 10_000, accept: "image/avif,image/webp,image/*" }));
207
+ const images = new Map(imageUrls.map((u, i) => [u, probeOf(imageFetched[i], imageFrom.get(u))]));
208
+ const slashParsed = slashFetched && slashFetched.status === 200 ? parsePage(slashFetched) : null;
209
+ const abs = (href, base) => {
210
+ try {
211
+ return href ? new URL(href, base).href : "";
212
+ }
213
+ catch {
214
+ return "";
215
+ }
216
+ };
63
217
  const seo = {
64
218
  local,
65
219
  probe404,
66
220
  httpHome,
67
- canonicals: new Map(canonicalTargets.map((u, i) => [norm(u), probeOf(canonicals[i])])),
221
+ canonicals: new Map(canonicalTargets.map((u, i) => [norm(u), { ...probeOf(canonicals[i]), canonical: canonicalOf(canonicals[i]) }])),
68
222
  links,
69
- linksFound: firstSeen.size,
223
+ linksFound: linkedFrom.size,
70
224
  linksChecked: links.size,
225
+ robotsRaw,
226
+ otherHost,
227
+ slashVariant: slashPage && slashUrl && slashFetched
228
+ ? {
229
+ main: slashPage.fetched.publicUrl,
230
+ mainCanonical: abs(slashPage.canonical, slashPage.fetched.publicUrl),
231
+ variant: slashUrl,
232
+ fetched: slashFetched,
233
+ canonical: slashParsed ? abs(slashParsed.canonical, slashUrl) : "",
234
+ }
235
+ : null,
236
+ chains,
237
+ ttfb,
238
+ httpHop,
239
+ cert,
240
+ sitemapFiles,
241
+ sitemapExtra,
242
+ favicon,
243
+ external,
244
+ images,
245
+ utm: utmPage && utmUrl && utmFetched ? { page: utmPage.fetched.publicUrl, url: utmUrl, status: utmFetched.status, canonical: canonicalOf(utmFetched) } : null,
71
246
  };
72
247
  return Object.assign(site, { seo });
73
248
  }