search-auditor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +87 -38
- 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,21 @@
|
|
|
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
|
+
|
|
11
|
+
## [0.1.1](https://github.com/a-bibikov/search-auditor/compare/v0.1.0...v0.1.1) (2026-11-04)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
* work with any ai-search-auditor 0.x from 0.3.0 ([#7](https://github.com/a-bibikov/search-auditor/issues/7)) ([81899dc](https://github.com/a-bibikov/search-auditor/commit/81899dc7b1e7b99c06329a56fa458c10b0fdeb4d))
|
|
17
|
+
* work with any ai-search-auditor 0.x from 0.3.0 ([#7](https://github.com/a-bibikov/search-auditor/issues/7)) ([#8](https://github.com/a-bibikov/search-auditor/issues/8)) ([8e06e8d](https://github.com/a-bibikov/search-auditor/commit/8e06e8d8258ec75c7bb92b79ae6e7e91a8f029d8))
|
|
18
|
+
|
|
3
19
|
## 0.1.0 (2026-11-04)
|
|
4
20
|
|
|
5
21
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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.
|
|
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
|
-
##
|
|
11
|
+
## AI search
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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.
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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:**
|
|
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
|
-
#
|
|
84
|
-
npx search-auditor https://example.com --
|
|
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
|
-
| `--
|
|
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
|
|
157
|
+
Config:
|
|
104
158
|
|
|
105
159
|
```json
|
|
106
160
|
{
|
|
107
|
-
"disable": ["STR-2", "
|
|
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"
|
|
126
|
-
console.log(report.score, report.
|
|
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
|
-
|
|
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
|
|
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**
|
|
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 "
|
|
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
|
}
|