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.
- package/CHANGELOG.md +15 -0
- package/README.md +140 -43
- package/dist/cli.js +17 -9
- package/dist/collect.d.ts +5 -1
- package/dist/collect.js +186 -11
- package/dist/i18n/en.d.ts +462 -7
- package/dist/i18n/en.js +480 -25
- package/dist/i18n/index.d.ts +924 -14
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/ru.js +484 -24
- package/dist/index.d.ts +3 -145
- package/dist/index.js +14 -53
- package/dist/rules/can/can-1.d.ts +2 -0
- package/dist/rules/can/can-1.js +14 -0
- package/dist/rules/can/can-2.d.ts +2 -0
- package/dist/rules/can/can-2.js +20 -0
- package/dist/rules/can/can-3.d.ts +2 -0
- package/dist/rules/can/can-3.js +32 -0
- package/dist/rules/can/can-4.d.ts +2 -0
- package/dist/rules/can/can-4.js +18 -0
- package/dist/rules/can/can-5.d.ts +2 -0
- package/dist/rules/can/can-5.js +17 -0
- package/dist/rules/can/can-6.d.ts +2 -0
- package/dist/rules/can/can-6.js +22 -0
- package/dist/rules/can/can-7.d.ts +2 -0
- package/dist/rules/can/can-7.js +32 -0
- package/dist/rules/can/can-8.d.ts +2 -0
- package/dist/rules/can/can-8.js +23 -0
- package/dist/rules/can/can-9.d.ts +2 -0
- package/dist/rules/can/can-9.js +24 -0
- package/dist/rules/can/index.d.ts +7 -0
- package/dist/rules/can/index.js +10 -0
- package/dist/rules/can/shared.d.ts +11 -0
- package/dist/rules/can/shared.js +26 -0
- package/dist/rules/data/data-1.d.ts +2 -0
- package/dist/rules/data/data-1.js +16 -0
- package/dist/rules/data/data-2.d.ts +2 -0
- package/dist/rules/data/data-2.js +25 -0
- package/dist/rules/data/data-3.d.ts +2 -0
- package/dist/rules/data/data-3.js +29 -0
- package/dist/rules/data/data-4.d.ts +2 -0
- package/dist/rules/data/data-4.js +17 -0
- package/dist/rules/data/data-5.d.ts +2 -0
- package/dist/rules/data/data-5.js +28 -0
- package/dist/rules/data/data-6.d.ts +2 -0
- package/dist/rules/data/data-6.js +16 -0
- package/dist/rules/data/index.d.ts +8 -0
- package/dist/rules/data/index.js +7 -0
- package/dist/rules/data/shared.d.ts +22 -0
- package/dist/rules/data/shared.js +59 -0
- package/dist/rules/idx/idx-1.d.ts +2 -0
- package/dist/rules/idx/idx-1.js +15 -0
- package/dist/rules/idx/idx-2.d.ts +2 -0
- package/dist/rules/idx/idx-2.js +22 -0
- package/dist/rules/idx/idx-3.d.ts +2 -0
- package/dist/rules/idx/idx-3.js +35 -0
- package/dist/rules/idx/idx-4.d.ts +2 -0
- package/dist/rules/idx/idx-4.js +40 -0
- package/dist/rules/idx/idx-5.d.ts +2 -0
- package/dist/rules/idx/idx-5.js +24 -0
- package/dist/rules/idx/idx-6.d.ts +2 -0
- package/dist/rules/idx/idx-6.js +25 -0
- package/dist/rules/idx/idx-7.d.ts +2 -0
- package/dist/rules/idx/idx-7.js +29 -0
- package/dist/rules/idx/idx-8.d.ts +2 -0
- package/dist/rules/idx/idx-8.js +25 -0
- package/dist/rules/idx/idx-9.d.ts +2 -0
- package/dist/rules/idx/idx-9.js +22 -0
- package/dist/rules/idx/index.d.ts +7 -0
- package/dist/rules/idx/index.js +10 -0
- package/dist/rules/idx/shared.d.ts +18 -0
- package/dist/rules/idx/shared.js +18 -0
- package/dist/rules/img/img-1.d.ts +2 -0
- package/dist/rules/img/img-1.js +18 -0
- package/dist/rules/img/img-2.d.ts +2 -0
- package/dist/rules/img/img-2.js +20 -0
- package/dist/rules/img/img-3.d.ts +2 -0
- package/dist/rules/img/img-3.js +24 -0
- package/dist/rules/img/img-4.d.ts +2 -0
- package/dist/rules/img/img-4.js +19 -0
- package/dist/rules/img/img-5.d.ts +2 -0
- package/dist/rules/img/img-5.js +18 -0
- package/dist/rules/img/img-6.d.ts +2 -0
- package/dist/rules/img/img-6.js +20 -0
- package/dist/rules/img/img-7.d.ts +2 -0
- package/dist/rules/img/img-7.js +22 -0
- package/dist/rules/img/index.d.ts +7 -0
- package/dist/rules/img/index.js +8 -0
- package/dist/rules/img/shared.d.ts +26 -0
- package/dist/rules/img/shared.js +38 -0
- package/dist/rules/index.d.ts +1 -1
- package/dist/rules/index.js +23 -6
- package/dist/rules/lang/index.d.ts +8 -0
- package/dist/rules/lang/index.js +9 -0
- package/dist/rules/lang/lang-1.d.ts +2 -0
- package/dist/rules/lang/lang-1.js +17 -0
- package/dist/rules/lang/lang-2.d.ts +2 -0
- package/dist/rules/lang/lang-2.js +30 -0
- package/dist/rules/lang/lang-3.d.ts +2 -0
- package/dist/rules/lang/lang-3.js +22 -0
- package/dist/rules/lang/lang-4.d.ts +2 -0
- package/dist/rules/lang/lang-4.js +23 -0
- package/dist/rules/lang/lang-5.d.ts +2 -0
- package/dist/rules/lang/lang-5.js +33 -0
- package/dist/rules/lang/lang-6.d.ts +2 -0
- package/dist/rules/lang/lang-6.js +25 -0
- package/dist/rules/lang/lang-7.d.ts +2 -0
- package/dist/rules/lang/lang-7.js +25 -0
- package/dist/rules/lang/lang-8.d.ts +2 -0
- package/dist/rules/lang/lang-8.js +19 -0
- package/dist/rules/lang/shared.d.ts +7 -0
- package/dist/rules/lang/shared.js +5 -0
- package/dist/rules/lnk/index.d.ts +8 -0
- package/dist/rules/lnk/index.js +8 -0
- package/dist/rules/lnk/lnk-1.d.ts +2 -0
- package/dist/rules/lnk/lnk-1.js +22 -0
- package/dist/rules/lnk/lnk-2.d.ts +2 -0
- package/dist/rules/lnk/lnk-2.js +18 -0
- package/dist/rules/lnk/lnk-3.d.ts +2 -0
- package/dist/rules/lnk/lnk-3.js +32 -0
- package/dist/rules/lnk/lnk-4.d.ts +2 -0
- package/dist/rules/lnk/lnk-4.js +18 -0
- package/dist/rules/lnk/lnk-5.d.ts +2 -0
- package/dist/rules/lnk/lnk-5.js +22 -0
- package/dist/rules/lnk/lnk-6.d.ts +2 -0
- package/dist/rules/lnk/lnk-6.js +24 -0
- package/dist/rules/lnk/lnk-7.d.ts +2 -0
- package/dist/rules/lnk/lnk-7.js +26 -0
- package/dist/rules/lnk/shared.d.ts +20 -0
- package/dist/rules/lnk/shared.js +29 -0
- package/dist/rules/map/index.d.ts +7 -0
- package/dist/rules/map/index.js +9 -0
- package/dist/rules/map/map-1.d.ts +2 -0
- package/dist/rules/map/map-1.js +15 -0
- package/dist/rules/map/map-2.d.ts +2 -0
- package/dist/rules/map/map-2.js +26 -0
- package/dist/rules/map/map-3.d.ts +2 -0
- package/dist/rules/map/map-3.js +24 -0
- package/dist/rules/map/map-4.d.ts +2 -0
- package/dist/rules/map/map-4.js +34 -0
- package/dist/rules/map/map-5.d.ts +2 -0
- package/dist/rules/map/map-5.js +28 -0
- package/dist/rules/map/map-6.d.ts +2 -0
- package/dist/rules/map/map-6.js +22 -0
- package/dist/rules/map/map-7.d.ts +2 -0
- package/dist/rules/map/map-7.js +18 -0
- package/dist/rules/map/map-8.d.ts +2 -0
- package/dist/rules/map/map-8.js +31 -0
- package/dist/rules/map/shared.d.ts +14 -0
- package/dist/rules/map/shared.js +12 -0
- package/dist/rules/meta/index.d.ts +8 -0
- package/dist/rules/meta/index.js +10 -0
- package/dist/rules/meta/meta-1.d.ts +2 -0
- package/dist/rules/meta/meta-1.js +20 -0
- package/dist/rules/meta/meta-2.d.ts +2 -0
- package/dist/rules/meta/meta-2.js +19 -0
- package/dist/rules/meta/meta-3.d.ts +2 -0
- package/dist/rules/meta/meta-3.js +26 -0
- package/dist/rules/meta/meta-4.d.ts +2 -0
- package/dist/rules/meta/meta-4.js +22 -0
- package/dist/rules/meta/meta-5.d.ts +2 -0
- package/dist/rules/meta/meta-5.js +17 -0
- package/dist/rules/meta/meta-6.d.ts +2 -0
- package/dist/rules/meta/meta-6.js +18 -0
- package/dist/rules/meta/meta-7.d.ts +2 -0
- package/dist/rules/meta/meta-7.js +18 -0
- package/dist/rules/meta/meta-8.d.ts +2 -0
- package/dist/rules/meta/meta-8.js +17 -0
- package/dist/rules/meta/meta-9.d.ts +2 -0
- package/dist/rules/meta/meta-9.js +16 -0
- package/dist/rules/meta/shared.d.ts +15 -0
- package/dist/rules/meta/shared.js +9 -0
- package/dist/rules/sec/index.d.ts +8 -0
- package/dist/rules/sec/index.js +8 -0
- package/dist/rules/sec/sec-1.d.ts +2 -0
- package/dist/rules/sec/sec-1.js +21 -0
- package/dist/rules/sec/sec-2.d.ts +2 -0
- package/dist/rules/sec/sec-2.js +17 -0
- package/dist/rules/sec/sec-3.d.ts +2 -0
- package/dist/rules/sec/sec-3.js +18 -0
- package/dist/rules/sec/sec-4.d.ts +2 -0
- package/dist/rules/sec/sec-4.js +23 -0
- package/dist/rules/sec/sec-5.d.ts +2 -0
- package/dist/rules/sec/sec-5.js +23 -0
- package/dist/rules/sec/sec-6.d.ts +2 -0
- package/dist/rules/sec/sec-6.js +32 -0
- package/dist/rules/sec/sec-7.d.ts +2 -0
- package/dist/rules/sec/sec-7.js +26 -0
- package/dist/rules/sec/shared.d.ts +10 -0
- package/dist/rules/sec/shared.js +8 -0
- package/dist/rules/str/index.d.ts +8 -0
- package/dist/rules/str/index.js +11 -0
- package/dist/rules/str/shared.d.ts +14 -0
- package/dist/rules/str/shared.js +16 -0
- package/dist/rules/str/str-1.d.ts +2 -0
- package/dist/rules/str/str-1.js +17 -0
- package/dist/rules/str/str-10.d.ts +2 -0
- package/dist/rules/str/str-10.js +15 -0
- package/dist/rules/str/str-2.d.ts +2 -0
- package/dist/rules/str/str-2.js +19 -0
- package/dist/rules/str/str-3.d.ts +2 -0
- package/dist/rules/str/str-3.js +14 -0
- package/dist/rules/str/str-4.d.ts +2 -0
- package/dist/rules/str/str-4.js +14 -0
- package/dist/rules/str/str-5.d.ts +2 -0
- package/dist/rules/str/str-5.js +19 -0
- package/dist/rules/str/str-6.d.ts +2 -0
- package/dist/rules/str/str-6.js +22 -0
- package/dist/rules/str/str-7.d.ts +2 -0
- package/dist/rules/str/str-7.js +18 -0
- package/dist/rules/str/str-8.d.ts +2 -0
- package/dist/rules/str/str-8.js +24 -0
- package/dist/rules/str/str-9.d.ts +2 -0
- package/dist/rules/str/str-9.js +14 -0
- package/dist/rules/util.d.ts +1 -1
- package/dist/trace.d.ts +20 -0
- package/dist/trace.js +77 -0
- package/dist/types.d.ts +86 -6
- package/package.json +4 -9
- package/dist/rules/links.d.ts +0 -2
- package/dist/rules/links.js +0 -95
- package/dist/rules/page.d.ts +0 -2
- package/dist/rules/page.js +0 -145
- package/dist/rules/site.d.ts +0 -2
- 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
|
|
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
|
-
|
|
9
|
+

|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
No sign-up, no API keys. Node.js 22.18+.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## AI search
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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.
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
84
|
-
npx search-auditor https://example.com --
|
|
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
|
-
| `--
|
|
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
|
|
191
|
+
Config:
|
|
104
192
|
|
|
105
193
|
```json
|
|
106
194
|
{
|
|
107
|
-
"disable": ["STR-2", "
|
|
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"
|
|
126
|
-
console.log(report.score, report.
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
- **
|
|
139
|
-
- **
|
|
140
|
-
- **
|
|
141
|
-
- **
|
|
142
|
-
|
|
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 "
|
|
4
|
+
import { DEFAULT_LANG, LANGS, isLang, parsePages, toJSON, toMarkdown } from "auditor-engine";
|
|
5
5
|
import { auditSearch } from "./index.js";
|
|
6
|
-
const HELP = `search-auditor —
|
|
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
|
|
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
|
-
|
|
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") ?
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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)
|
|
45
|
-
|
|
46
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
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],
|
|
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:
|
|
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
|
}
|