search-auditor 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +87 -38
  3. package/dist/cli.js +17 -9
  4. package/dist/collect.d.ts +5 -1
  5. package/dist/collect.js +186 -11
  6. package/dist/i18n/en.d.ts +462 -7
  7. package/dist/i18n/en.js +480 -25
  8. package/dist/i18n/index.d.ts +924 -14
  9. package/dist/i18n/index.js +1 -1
  10. package/dist/i18n/ru.js +484 -24
  11. package/dist/index.d.ts +3 -145
  12. package/dist/index.js +14 -53
  13. package/dist/rules/can/can-1.d.ts +2 -0
  14. package/dist/rules/can/can-1.js +14 -0
  15. package/dist/rules/can/can-2.d.ts +2 -0
  16. package/dist/rules/can/can-2.js +20 -0
  17. package/dist/rules/can/can-3.d.ts +2 -0
  18. package/dist/rules/can/can-3.js +32 -0
  19. package/dist/rules/can/can-4.d.ts +2 -0
  20. package/dist/rules/can/can-4.js +18 -0
  21. package/dist/rules/can/can-5.d.ts +2 -0
  22. package/dist/rules/can/can-5.js +17 -0
  23. package/dist/rules/can/can-6.d.ts +2 -0
  24. package/dist/rules/can/can-6.js +22 -0
  25. package/dist/rules/can/can-7.d.ts +2 -0
  26. package/dist/rules/can/can-7.js +32 -0
  27. package/dist/rules/can/can-8.d.ts +2 -0
  28. package/dist/rules/can/can-8.js +23 -0
  29. package/dist/rules/can/can-9.d.ts +2 -0
  30. package/dist/rules/can/can-9.js +24 -0
  31. package/dist/rules/can/index.d.ts +7 -0
  32. package/dist/rules/can/index.js +10 -0
  33. package/dist/rules/can/shared.d.ts +11 -0
  34. package/dist/rules/can/shared.js +26 -0
  35. package/dist/rules/data/data-1.d.ts +2 -0
  36. package/dist/rules/data/data-1.js +16 -0
  37. package/dist/rules/data/data-2.d.ts +2 -0
  38. package/dist/rules/data/data-2.js +25 -0
  39. package/dist/rules/data/data-3.d.ts +2 -0
  40. package/dist/rules/data/data-3.js +29 -0
  41. package/dist/rules/data/data-4.d.ts +2 -0
  42. package/dist/rules/data/data-4.js +17 -0
  43. package/dist/rules/data/data-5.d.ts +2 -0
  44. package/dist/rules/data/data-5.js +28 -0
  45. package/dist/rules/data/data-6.d.ts +2 -0
  46. package/dist/rules/data/data-6.js +16 -0
  47. package/dist/rules/data/index.d.ts +8 -0
  48. package/dist/rules/data/index.js +7 -0
  49. package/dist/rules/data/shared.d.ts +22 -0
  50. package/dist/rules/data/shared.js +59 -0
  51. package/dist/rules/idx/idx-1.d.ts +2 -0
  52. package/dist/rules/idx/idx-1.js +15 -0
  53. package/dist/rules/idx/idx-2.d.ts +2 -0
  54. package/dist/rules/idx/idx-2.js +22 -0
  55. package/dist/rules/idx/idx-3.d.ts +2 -0
  56. package/dist/rules/idx/idx-3.js +35 -0
  57. package/dist/rules/idx/idx-4.d.ts +2 -0
  58. package/dist/rules/idx/idx-4.js +40 -0
  59. package/dist/rules/idx/idx-5.d.ts +2 -0
  60. package/dist/rules/idx/idx-5.js +24 -0
  61. package/dist/rules/idx/idx-6.d.ts +2 -0
  62. package/dist/rules/idx/idx-6.js +25 -0
  63. package/dist/rules/idx/idx-7.d.ts +2 -0
  64. package/dist/rules/idx/idx-7.js +29 -0
  65. package/dist/rules/idx/idx-8.d.ts +2 -0
  66. package/dist/rules/idx/idx-8.js +25 -0
  67. package/dist/rules/idx/idx-9.d.ts +2 -0
  68. package/dist/rules/idx/idx-9.js +22 -0
  69. package/dist/rules/idx/index.d.ts +7 -0
  70. package/dist/rules/idx/index.js +10 -0
  71. package/dist/rules/idx/shared.d.ts +18 -0
  72. package/dist/rules/idx/shared.js +18 -0
  73. package/dist/rules/img/img-1.d.ts +2 -0
  74. package/dist/rules/img/img-1.js +18 -0
  75. package/dist/rules/img/img-2.d.ts +2 -0
  76. package/dist/rules/img/img-2.js +20 -0
  77. package/dist/rules/img/img-3.d.ts +2 -0
  78. package/dist/rules/img/img-3.js +24 -0
  79. package/dist/rules/img/img-4.d.ts +2 -0
  80. package/dist/rules/img/img-4.js +19 -0
  81. package/dist/rules/img/img-5.d.ts +2 -0
  82. package/dist/rules/img/img-5.js +18 -0
  83. package/dist/rules/img/img-6.d.ts +2 -0
  84. package/dist/rules/img/img-6.js +20 -0
  85. package/dist/rules/img/img-7.d.ts +2 -0
  86. package/dist/rules/img/img-7.js +22 -0
  87. package/dist/rules/img/index.d.ts +7 -0
  88. package/dist/rules/img/index.js +8 -0
  89. package/dist/rules/img/shared.d.ts +26 -0
  90. package/dist/rules/img/shared.js +38 -0
  91. package/dist/rules/index.d.ts +1 -1
  92. package/dist/rules/index.js +23 -6
  93. package/dist/rules/lang/index.d.ts +8 -0
  94. package/dist/rules/lang/index.js +9 -0
  95. package/dist/rules/lang/lang-1.d.ts +2 -0
  96. package/dist/rules/lang/lang-1.js +17 -0
  97. package/dist/rules/lang/lang-2.d.ts +2 -0
  98. package/dist/rules/lang/lang-2.js +30 -0
  99. package/dist/rules/lang/lang-3.d.ts +2 -0
  100. package/dist/rules/lang/lang-3.js +22 -0
  101. package/dist/rules/lang/lang-4.d.ts +2 -0
  102. package/dist/rules/lang/lang-4.js +23 -0
  103. package/dist/rules/lang/lang-5.d.ts +2 -0
  104. package/dist/rules/lang/lang-5.js +33 -0
  105. package/dist/rules/lang/lang-6.d.ts +2 -0
  106. package/dist/rules/lang/lang-6.js +25 -0
  107. package/dist/rules/lang/lang-7.d.ts +2 -0
  108. package/dist/rules/lang/lang-7.js +25 -0
  109. package/dist/rules/lang/lang-8.d.ts +2 -0
  110. package/dist/rules/lang/lang-8.js +19 -0
  111. package/dist/rules/lang/shared.d.ts +7 -0
  112. package/dist/rules/lang/shared.js +5 -0
  113. package/dist/rules/lnk/index.d.ts +8 -0
  114. package/dist/rules/lnk/index.js +8 -0
  115. package/dist/rules/lnk/lnk-1.d.ts +2 -0
  116. package/dist/rules/lnk/lnk-1.js +22 -0
  117. package/dist/rules/lnk/lnk-2.d.ts +2 -0
  118. package/dist/rules/lnk/lnk-2.js +18 -0
  119. package/dist/rules/lnk/lnk-3.d.ts +2 -0
  120. package/dist/rules/lnk/lnk-3.js +32 -0
  121. package/dist/rules/lnk/lnk-4.d.ts +2 -0
  122. package/dist/rules/lnk/lnk-4.js +18 -0
  123. package/dist/rules/lnk/lnk-5.d.ts +2 -0
  124. package/dist/rules/lnk/lnk-5.js +22 -0
  125. package/dist/rules/lnk/lnk-6.d.ts +2 -0
  126. package/dist/rules/lnk/lnk-6.js +24 -0
  127. package/dist/rules/lnk/lnk-7.d.ts +2 -0
  128. package/dist/rules/lnk/lnk-7.js +26 -0
  129. package/dist/rules/lnk/shared.d.ts +20 -0
  130. package/dist/rules/lnk/shared.js +29 -0
  131. package/dist/rules/map/index.d.ts +7 -0
  132. package/dist/rules/map/index.js +9 -0
  133. package/dist/rules/map/map-1.d.ts +2 -0
  134. package/dist/rules/map/map-1.js +15 -0
  135. package/dist/rules/map/map-2.d.ts +2 -0
  136. package/dist/rules/map/map-2.js +26 -0
  137. package/dist/rules/map/map-3.d.ts +2 -0
  138. package/dist/rules/map/map-3.js +24 -0
  139. package/dist/rules/map/map-4.d.ts +2 -0
  140. package/dist/rules/map/map-4.js +34 -0
  141. package/dist/rules/map/map-5.d.ts +2 -0
  142. package/dist/rules/map/map-5.js +28 -0
  143. package/dist/rules/map/map-6.d.ts +2 -0
  144. package/dist/rules/map/map-6.js +22 -0
  145. package/dist/rules/map/map-7.d.ts +2 -0
  146. package/dist/rules/map/map-7.js +18 -0
  147. package/dist/rules/map/map-8.d.ts +2 -0
  148. package/dist/rules/map/map-8.js +31 -0
  149. package/dist/rules/map/shared.d.ts +14 -0
  150. package/dist/rules/map/shared.js +12 -0
  151. package/dist/rules/meta/index.d.ts +8 -0
  152. package/dist/rules/meta/index.js +10 -0
  153. package/dist/rules/meta/meta-1.d.ts +2 -0
  154. package/dist/rules/meta/meta-1.js +20 -0
  155. package/dist/rules/meta/meta-2.d.ts +2 -0
  156. package/dist/rules/meta/meta-2.js +19 -0
  157. package/dist/rules/meta/meta-3.d.ts +2 -0
  158. package/dist/rules/meta/meta-3.js +26 -0
  159. package/dist/rules/meta/meta-4.d.ts +2 -0
  160. package/dist/rules/meta/meta-4.js +22 -0
  161. package/dist/rules/meta/meta-5.d.ts +2 -0
  162. package/dist/rules/meta/meta-5.js +17 -0
  163. package/dist/rules/meta/meta-6.d.ts +2 -0
  164. package/dist/rules/meta/meta-6.js +18 -0
  165. package/dist/rules/meta/meta-7.d.ts +2 -0
  166. package/dist/rules/meta/meta-7.js +18 -0
  167. package/dist/rules/meta/meta-8.d.ts +2 -0
  168. package/dist/rules/meta/meta-8.js +17 -0
  169. package/dist/rules/meta/meta-9.d.ts +2 -0
  170. package/dist/rules/meta/meta-9.js +16 -0
  171. package/dist/rules/meta/shared.d.ts +15 -0
  172. package/dist/rules/meta/shared.js +9 -0
  173. package/dist/rules/sec/index.d.ts +8 -0
  174. package/dist/rules/sec/index.js +8 -0
  175. package/dist/rules/sec/sec-1.d.ts +2 -0
  176. package/dist/rules/sec/sec-1.js +21 -0
  177. package/dist/rules/sec/sec-2.d.ts +2 -0
  178. package/dist/rules/sec/sec-2.js +17 -0
  179. package/dist/rules/sec/sec-3.d.ts +2 -0
  180. package/dist/rules/sec/sec-3.js +18 -0
  181. package/dist/rules/sec/sec-4.d.ts +2 -0
  182. package/dist/rules/sec/sec-4.js +23 -0
  183. package/dist/rules/sec/sec-5.d.ts +2 -0
  184. package/dist/rules/sec/sec-5.js +23 -0
  185. package/dist/rules/sec/sec-6.d.ts +2 -0
  186. package/dist/rules/sec/sec-6.js +32 -0
  187. package/dist/rules/sec/sec-7.d.ts +2 -0
  188. package/dist/rules/sec/sec-7.js +26 -0
  189. package/dist/rules/sec/shared.d.ts +10 -0
  190. package/dist/rules/sec/shared.js +8 -0
  191. package/dist/rules/str/index.d.ts +8 -0
  192. package/dist/rules/str/index.js +11 -0
  193. package/dist/rules/str/shared.d.ts +14 -0
  194. package/dist/rules/str/shared.js +16 -0
  195. package/dist/rules/str/str-1.d.ts +2 -0
  196. package/dist/rules/str/str-1.js +17 -0
  197. package/dist/rules/str/str-10.d.ts +2 -0
  198. package/dist/rules/str/str-10.js +15 -0
  199. package/dist/rules/str/str-2.d.ts +2 -0
  200. package/dist/rules/str/str-2.js +19 -0
  201. package/dist/rules/str/str-3.d.ts +2 -0
  202. package/dist/rules/str/str-3.js +14 -0
  203. package/dist/rules/str/str-4.d.ts +2 -0
  204. package/dist/rules/str/str-4.js +14 -0
  205. package/dist/rules/str/str-5.d.ts +2 -0
  206. package/dist/rules/str/str-5.js +19 -0
  207. package/dist/rules/str/str-6.d.ts +2 -0
  208. package/dist/rules/str/str-6.js +22 -0
  209. package/dist/rules/str/str-7.d.ts +2 -0
  210. package/dist/rules/str/str-7.js +18 -0
  211. package/dist/rules/str/str-8.d.ts +2 -0
  212. package/dist/rules/str/str-8.js +24 -0
  213. package/dist/rules/str/str-9.d.ts +2 -0
  214. package/dist/rules/str/str-9.js +14 -0
  215. package/dist/rules/util.d.ts +1 -1
  216. package/dist/trace.d.ts +20 -0
  217. package/dist/trace.js +77 -0
  218. package/dist/types.d.ts +86 -6
  219. package/package.json +4 -9
  220. package/dist/rules/links.d.ts +0 -2
  221. package/dist/rules/links.js +0 -95
  222. package/dist/rules/page.d.ts +0 -2
  223. package/dist/rules/page.js +0 -145
  224. package/dist/rules/site.d.ts +0 -2
  225. package/dist/rules/site.js +0 -177
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/a-bibikov/search-auditor/compare/v0.1.1...v0.2.0) (2026-11-09)
4
+
5
+
6
+ ### New checks and features
7
+
8
+ * 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))
9
+ * 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))
10
+
3
11
  ## [0.1.1](https://github.com/a-bibikov/search-auditor/compare/v0.1.0...v0.1.1) (2026-11-04)
4
12
 
5
13
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
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.
4
4
 
5
5
  ```bash
6
6
  npx search-auditor https://example.com
@@ -8,65 +8,119 @@ npx search-auditor https://example.com
8
8
 
9
9
  No sign-up, no API keys. Node.js 22.18+.
10
10
 
11
- ## Why both in one report
11
+ ## AI search
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.
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.
13
+ 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
14
 
17
15
  ## What it checks
18
16
 
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.
17
+ 80 checks in ten groups.
20
18
 
21
19
  **IDX. Indexing**
22
20
  - **IDX-1** Audited pages answer 200
23
21
  - **IDX-2** A page that does not exist answers 404, and no «not found» page answers 200
24
-
25
- **SEC. HTTPS** (live sites only)
22
+ - **IDX-3** robots.txt answers without errors: no 5xx or timeout (Google then treats the site as closed), plain-text rules, under 500 KB
23
+ - **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
24
+ - **IDX-5** The site has one version: the other host (with or without www.) and the other trailing slash redirect to the main address
25
+ - **IDX-6** Redirects take one step: no chains, no loops
26
+ - **IDX-7** `noindex` does not close the home page and does not contradict the sitemap or the canonical
27
+ - **IDX-8** robots.txt does not block the CSS and JavaScript the page needs to render
28
+ - **IDX-9** The server answers quickly: first byte in under 0.8 s (live sites only)
29
+
30
+ **SEC. Security and HTTPS** (the connection checks need the live site)
26
31
  - **SEC-1** The site is on https, and http:// redirects to it
27
32
  - **SEC-2** Pages do not load anything over http://
33
+ - **SEC-3** The redirect from http:// is permanent (301 or 308), not a temporary 302
34
+ - **SEC-4** The certificate is trusted and does not expire within two weeks
35
+ - **SEC-5** HSTS tells browsers to use only https, for half a year at least
36
+ - **SEC-6** The site's own addresses use https://: links, canonical, hreflang, og tags, sitemap
37
+ - **SEC-7** Basic security headers: nosniff, frame protection, Referrer-Policy
28
38
 
29
- **CAN. Canonical**
39
+ **CAN. Canonical: the main address of each page**
30
40
  - **CAN-1** Every page has a canonical
31
41
  - **CAN-2** The canonical is an absolute address on this site
32
42
  - **CAN-3** The canonical leads to a page that answers 200, not to an error or a redirect
33
-
34
- **MAP. Sitemap**
43
+ - **CAN-4** One canonical per page: two different ones make Google ignore both
44
+ - **CAN-5** The canonical sits in `<head>`; one in `<body>` is ignored
45
+ - **CAN-6** The canonical in the HTML and in the `Link` header agree
46
+ - **CAN-7** Canonicals lead straight to the main page, not through another canonical
47
+ - **CAN-8** An address with UTM tags names the clean address as canonical
48
+ - **CAN-9** Language versions do not name each other as canonical
49
+
50
+ **MAP. Sitemap: the map of the site for search engines**
35
51
  - **MAP-1** There is a sitemap, and robots.txt points to it
36
52
  - **MAP-2** Sitemap addresses do not redirect
37
53
  - **MAP-3** Sitemap pages are their own canonical
54
+ - **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
55
+ - **MAP-5** Sitemap addresses are full and on this site
56
+ - **MAP-6** Pages the site links to are in the sitemap
57
+ - **MAP-7** Every sitemap address answers, including up to 50 beyond the audited sample
58
+ - **MAP-8** lastmod is honest: a W3C date, not in the future, not the same build time on every address
38
59
 
39
- **META. Titles and descriptions**
60
+ **META. Meta tags: how a page looks in search results**
40
61
  - **META-1** Every page has a title of about 10–60 characters
41
62
  - **META-2** Every page has a description of about 50–160 characters
42
63
  - **META-3** Titles and descriptions are not repeated across pages
43
-
44
- **STR. Page structure**
64
+ - **META-4** One title and one description per page
65
+ - **META-5** The title names the page, not a template: no «Home», «Untitled» or «React App»
66
+ - **META-6** The description adds to the title instead of repeating it
67
+ - **META-7** The page declares its encoding
68
+ - **META-8** The snippet is not forbidden with `nosnippet` or `max-snippet:0`
69
+ - **META-9** The site has a favicon
70
+
71
+ **STR. Page structure: headings, language, mobile**
45
72
  - **STR-1** One H1 per page
46
73
  - **STR-2** Heading levels do not skip
47
74
  - **STR-3** The page declares its language
48
75
  - **STR-4** Pages are set up for mobile
49
-
50
- **LNK. Links**
76
+ - **STR-5** Headings are not empty: an H1 that is only a logo reads as no H1
77
+ - **STR-6** The title and the H1 are about the same thing, or Google may rewrite the title
78
+ - **STR-7** The page can be zoomed on a phone
79
+ - **STR-8** `<html lang>` matches the language of the text
80
+ - **STR-9** The main content sits in `<main>`
81
+ - **STR-10** The page starts with `<!doctype html>`
82
+
83
+ **LNK. Links: how pages link to each other**
51
84
  - **LNK-1** Internal links work
52
85
  - **LNK-2** Internal links do not go through redirects
86
+ - **LNK-3** Every page is linked from another page: no orphans only the sitemap knows (with `--pages all`)
87
+ - **LNK-4** Links can be followed by a crawler: no `<a>` without href, `#` or `javascript:` for navigation
88
+ - **LNK-5** Internal links do not carry `nofollow`
89
+ - **LNK-6** Link text says where the link goes: no «read more», no image links without alt
90
+ - **LNK-7** Links to other sites still work: up to 30 checked, only 404, 410 and missing domains count as dead
53
91
 
54
- **IMG. Images**
92
+ **IMG. Images: image search and loading speed**
55
93
  - **IMG-1** Images have alt text
56
-
57
- **DATA. Structured data**
94
+ - **IMG-2** Images have width and height, so the layout does not jump (CLS)
95
+ - **IMG-3** Lazy loading where it helps: not on the first image, yes on long pages
96
+ - **IMG-4** Photos are served in modern formats: WebP or AVIF, not JPEG and PNG only
97
+ - **IMG-5** Images open
98
+ - **IMG-6** Images are not heavier than 500 KB
99
+ - **IMG-7** Alt text describes the image: not a file name, «photo» or a paragraph
100
+
101
+ **DATA. Structured data: rich results**
58
102
  - **DATA-1** JSON-LD has no syntax errors
103
+ - **DATA-2** Every block has a schema.org `@context`, every entity a `@type`
104
+ - **DATA-3** Rich-result types have the properties Google requires: Product, Event, BreadcrumbList, Recipe, VideoObject, LocalBusiness, JobPosting, Course, ratings and reviews
105
+ - **DATA-4** Nested pages have breadcrumbs (BreadcrumbList)
106
+ - **DATA-5** Addresses in the markup are full, dates are ISO 8601
107
+ - **DATA-6** The home page names the site with WebSite
59
108
 
60
- **LANG. Language versions** (sites with hreflang)
109
+ **LANG. Language versions: hreflang for multilingual sites** (sites with hreflang)
61
110
  - **LANG-1** hreflang codes are valid
62
111
  - **LANG-2** Language versions point at each other
63
112
  - **LANG-3** There is an x-default
113
+ - **LANG-4** Every version lists itself in hreflang
114
+ - **LANG-5** hreflang leads to working canonical pages: no redirects, errors or copies
115
+ - **LANG-6** The hreflang language matches the `<html lang>` of the page it points to
116
+ - **LANG-7** Each language is listed once
117
+ - **LANG-8** hreflang addresses are full
64
118
 
65
119
  Levels: `error` — search engines cannot index the page or index the wrong one; `warn` — lowers rankings or clicks; `info` — a recommendation.
66
120
 
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.
121
+ 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.
68
122
 
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.
123
+ **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
124
 
71
125
  ## Usage
72
126
 
@@ -80,8 +134,8 @@ npx search-auditor http://127.0.0.1:3000 --base https://example.com
80
134
  # Every page in the sitemap instead of a sample of 20
81
135
  npx search-auditor https://example.com --pages all
82
136
 
83
- # Classic search only, report in Russian
84
- npx search-auditor https://example.com --no-ai --lang ru
137
+ # Report in Russian
138
+ npx search-auditor https://example.com --lang ru
85
139
 
86
140
  # JSON for scripts and agents
87
141
  npx search-auditor https://example.com --json --out report.json
@@ -92,23 +146,21 @@ npx search-auditor https://example.com --json --out report.json
92
146
  | `--base <url>` | Public origin when auditing a local build |
93
147
  | `--pages <n\|all>` | Pages to audit: a number sampled across templates (default 20), or `all` for every page in the sitemap |
94
148
  | `--max-links <n>` | Internal links to request (default 100) |
95
- | `--no-ai` | Classic search checks only |
96
149
  | `--fail-on <level>` | Exit 1 on `error` (default), `warn` or `never` |
97
150
  | `--lang <lang>` | Report language: `en` (default), `ru` |
98
- | `--json` | JSON instead of Markdown |
151
+ | `--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 |
152
+ | `--json` | JSON instead of Markdown: each finding has its check `title`, the `message`, `why` it is a problem and how to `fix` it |
99
153
  | `--out <file>` | Also write the report to a file |
100
154
  | `--config <file>` | Config file (default `search-auditor.config.json`) |
101
155
  | `-v`, `--verbose` | Show progress |
102
156
 
103
- Config — the options of ai-search-auditor plus two of its own:
157
+ Config:
104
158
 
105
159
  ```json
106
160
  {
107
- "disable": ["STR-2", "C-7"],
161
+ "disable": ["STR-2", "IMG-4"],
108
162
  "maxPages": 20,
109
163
  "maxLinks": 100,
110
- "ai": true,
111
- "trainingBots": "any",
112
164
  "lang": "en"
113
165
  }
114
166
  ```
@@ -122,23 +174,20 @@ In CI the Markdown report also goes to the job summary (`GITHUB_STEP_SUMMARY`).
122
174
  ```js
123
175
  import { auditSearch, toMarkdown } from "search-auditor";
124
176
 
125
- const report = await auditSearch("https://example.com", { config: { lang: "en", ai: true } });
126
- console.log(report.score, report.sections);
177
+ const report = await auditSearch("https://example.com", { config: { lang: "en" } });
178
+ console.log(report.score, report.grade);
127
179
  console.log(toMarkdown(report));
128
180
  ```
129
181
 
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.
182
+ 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.
131
183
 
132
184
  ## Roadmap
133
185
 
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
186
  - **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
187
+ - **MAP-4** Every audited page is in the sitemap
138
188
  - **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
189
  - **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
190
+ - **PERF-1** Page weight and Core Web Vitals
142
191
  - **SOC-1** Twitter / X cards in addition to Open Graph
143
192
 
144
193
  ## License
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
  }