search-auditor 0.0.0-stage → 0.1.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 +23 -0
- package/LICENSE +21 -0
- package/README.md +145 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +112 -0
- package/dist/collect.d.ts +14 -0
- package/dist/collect.js +74 -0
- package/dist/i18n/en.d.ts +194 -0
- package/dist/i18n/en.js +192 -0
- package/dist/i18n/index.d.ts +381 -0
- package/dist/i18n/index.js +5 -0
- package/dist/i18n/ru.d.ts +2 -0
- package/dist/i18n/ru.js +187 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +76 -0
- package/dist/rules/index.d.ts +5 -0
- package/dist/rules/index.js +7 -0
- package/dist/rules/links.d.ts +2 -0
- package/dist/rules/links.js +95 -0
- package/dist/rules/page.d.ts +2 -0
- package/dist/rules/page.js +145 -0
- package/dist/rules/site.d.ts +2 -0
- package/dist/rules/site.js +177 -0
- package/dist/rules/util.d.ts +15 -0
- package/dist/rules/util.js +20 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +1 -0
- package/package.json +58 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/a-bibikov/search-auditor/compare/v0.1.0...v0.1.1) (2026-11-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixes
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
* 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))
|
|
10
|
+
|
|
11
|
+
## 0.1.0 (2026-11-04)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### New checks and features
|
|
15
|
+
|
|
16
|
+
* classic SEO and AI search checks in one report ([#1](https://github.com/a-bibikov/search-auditor/issues/1)) ([450ff97](https://github.com/a-bibikov/search-auditor/commit/450ff97db5f950e2708500ccd3389f4b97d5ab90))
|
|
17
|
+
* classic SEO and AI search checks in one report ([#1](https://github.com/a-bibikov/search-auditor/issues/1)) ([c272847](https://github.com/a-bibikov/search-auditor/commit/c272847b6b4ecaf5910e89bbc4e203480e00d324))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Fixes
|
|
21
|
+
|
|
22
|
+
* start versions at 0.1.0 ([#4](https://github.com/a-bibikov/search-auditor/issues/4)) ([d1d10c5](https://github.com/a-bibikov/search-auditor/commit/d1d10c5c25435f5eabdffd8c104473e82254ef45))
|
|
23
|
+
* start versions at 0.1.0 ([#4](https://github.com/a-bibikov/search-auditor/issues/4)) ([acbccc4](https://github.com/a-bibikov/search-auditor/commit/acbccc44da99a0eed0b2eeb42dbfa41d3330ccfc))
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksandr Bibikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# search-auditor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Can Google, Bing and AI search find, read and cite your site?** One crawl, one report: classic SEO checks — status codes, canonical, sitemap, titles, headings, broken links, hreflang, HTTPS — and the AI search checks of [ai-search-auditor](https://github.com/a-bibikov/ai-search-audit), with a score for each and one overall.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx search-auditor https://example.com
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No sign-up, no API keys. Node.js 22.18+.
|
|
10
|
+
|
|
11
|
+
## Why both in one report
|
|
12
|
+
|
|
13
|
+
People find sites in two ways now: a list of links in Google, Bing or Yandex, and an answer from ChatGPT, Claude, Perplexity or Google AI Overviews that cites a few sources. Both start from the same thing — a crawler that can reach the page, read it and tell what it is about — and then they diverge. A broken canonical drops a page from Google; a heading that glues words together makes AI search misquote it.
|
|
14
|
+
|
|
15
|
+
Running two tools means two crawls, two reports and two scores that never meet. search-auditor crawls once, runs both rule sets on the same pages and puts the results side by side, so the first three things to fix are the three that cost the most, whichever kind of search they hurt.
|
|
16
|
+
|
|
17
|
+
## What it checks
|
|
18
|
+
|
|
19
|
+
24 classic search checks in ten groups, plus the 20 AI search checks of ai-search-auditor (turn them off with `--no-ai`). Nothing is checked twice: robots.txt for search crawlers, `noindex` and link cards are part of the AI search set.
|
|
20
|
+
|
|
21
|
+
**IDX. Indexing**
|
|
22
|
+
- **IDX-1** Audited pages answer 200
|
|
23
|
+
- **IDX-2** A page that does not exist answers 404, and no «not found» page answers 200
|
|
24
|
+
|
|
25
|
+
**SEC. HTTPS** (live sites only)
|
|
26
|
+
- **SEC-1** The site is on https, and http:// redirects to it
|
|
27
|
+
- **SEC-2** Pages do not load anything over http://
|
|
28
|
+
|
|
29
|
+
**CAN. Canonical**
|
|
30
|
+
- **CAN-1** Every page has a canonical
|
|
31
|
+
- **CAN-2** The canonical is an absolute address on this site
|
|
32
|
+
- **CAN-3** The canonical leads to a page that answers 200, not to an error or a redirect
|
|
33
|
+
|
|
34
|
+
**MAP. Sitemap**
|
|
35
|
+
- **MAP-1** There is a sitemap, and robots.txt points to it
|
|
36
|
+
- **MAP-2** Sitemap addresses do not redirect
|
|
37
|
+
- **MAP-3** Sitemap pages are their own canonical
|
|
38
|
+
|
|
39
|
+
**META. Titles and descriptions**
|
|
40
|
+
- **META-1** Every page has a title of about 10–60 characters
|
|
41
|
+
- **META-2** Every page has a description of about 50–160 characters
|
|
42
|
+
- **META-3** Titles and descriptions are not repeated across pages
|
|
43
|
+
|
|
44
|
+
**STR. Page structure**
|
|
45
|
+
- **STR-1** One H1 per page
|
|
46
|
+
- **STR-2** Heading levels do not skip
|
|
47
|
+
- **STR-3** The page declares its language
|
|
48
|
+
- **STR-4** Pages are set up for mobile
|
|
49
|
+
|
|
50
|
+
**LNK. Links**
|
|
51
|
+
- **LNK-1** Internal links work
|
|
52
|
+
- **LNK-2** Internal links do not go through redirects
|
|
53
|
+
|
|
54
|
+
**IMG. Images**
|
|
55
|
+
- **IMG-1** Images have alt text
|
|
56
|
+
|
|
57
|
+
**DATA. Structured data**
|
|
58
|
+
- **DATA-1** JSON-LD has no syntax errors
|
|
59
|
+
|
|
60
|
+
**LANG. Language versions** (sites with hreflang)
|
|
61
|
+
- **LANG-1** hreflang codes are valid
|
|
62
|
+
- **LANG-2** Language versions point at each other
|
|
63
|
+
- **LANG-3** There is an x-default
|
|
64
|
+
|
|
65
|
+
Levels: `error` — search engines cannot index the page or index the wrong one; `warn` — lowers rankings or clicks; `info` — a recommendation.
|
|
66
|
+
|
|
67
|
+
The report opens with the overall score and one per part («Classic search», «AI search»), then what to do first — the three changes worth the most, in plain words and with how many pages they touch — then the findings with fixes, and ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note, – not checked. A check that cannot run on a site — hreflang on a site in one language, HTTPS on a local build — is marked «not checked» with the reason, never as passed.
|
|
68
|
+
|
|
69
|
+
**Score:** each part is scored like ai-search-auditor: 100 minus 15 for each check with an error and 5 for each check with a warning; a warning that covers at least half of the audited pages costs 10. The overall score is the mean of the parts. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below; if fewer than two thirds of the checks could run, the grade is capped at B.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# A live site
|
|
75
|
+
npx search-auditor https://example.com
|
|
76
|
+
|
|
77
|
+
# A local build: requests go to localhost, the report shows public URLs
|
|
78
|
+
npx search-auditor http://127.0.0.1:3000 --base https://example.com
|
|
79
|
+
|
|
80
|
+
# Every page in the sitemap instead of a sample of 20
|
|
81
|
+
npx search-auditor https://example.com --pages all
|
|
82
|
+
|
|
83
|
+
# Classic search only, report in Russian
|
|
84
|
+
npx search-auditor https://example.com --no-ai --lang ru
|
|
85
|
+
|
|
86
|
+
# JSON for scripts and agents
|
|
87
|
+
npx search-auditor https://example.com --json --out report.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Option | |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `--base <url>` | Public origin when auditing a local build |
|
|
93
|
+
| `--pages <n\|all>` | Pages to audit: a number sampled across templates (default 20), or `all` for every page in the sitemap |
|
|
94
|
+
| `--max-links <n>` | Internal links to request (default 100) |
|
|
95
|
+
| `--no-ai` | Classic search checks only |
|
|
96
|
+
| `--fail-on <level>` | Exit 1 on `error` (default), `warn` or `never` |
|
|
97
|
+
| `--lang <lang>` | Report language: `en` (default), `ru` |
|
|
98
|
+
| `--json` | JSON instead of Markdown |
|
|
99
|
+
| `--out <file>` | Also write the report to a file |
|
|
100
|
+
| `--config <file>` | Config file (default `search-auditor.config.json`) |
|
|
101
|
+
| `-v`, `--verbose` | Show progress |
|
|
102
|
+
|
|
103
|
+
Config — the options of ai-search-auditor plus two of its own:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"disable": ["STR-2", "C-7"],
|
|
108
|
+
"maxPages": 20,
|
|
109
|
+
"maxLinks": 100,
|
|
110
|
+
"ai": true,
|
|
111
|
+
"trainingBots": "any",
|
|
112
|
+
"lang": "en"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`maxPages`: a number or `"all"`. With `"all"` every page in the sitemap is audited, so raise `maxLinks` too if you want every internal link checked.
|
|
117
|
+
|
|
118
|
+
In CI the Markdown report also goes to the job summary (`GITHUB_STEP_SUMMARY`).
|
|
119
|
+
|
|
120
|
+
## As a library
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { auditSearch, toMarkdown } from "search-auditor";
|
|
124
|
+
|
|
125
|
+
const report = await auditSearch("https://example.com", { config: { lang: "en", ai: true } });
|
|
126
|
+
console.log(report.score, report.sections);
|
|
127
|
+
console.log(toMarkdown(report));
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`ai-search-auditor` is a peer dependency: the crawl, the scoring and the report come from it, so both parts always run on the same engine version. The SEO checks are also exported as `seoPlugin` — rules, texts, groups and the extra requests they need — for runners that bring their own crawl.
|
|
131
|
+
|
|
132
|
+
## Roadmap
|
|
133
|
+
|
|
134
|
+
- **IDX-3** `noindex` and canonical do not contradict each other
|
|
135
|
+
- **IDX-4** robots.txt does not block CSS and JavaScript the page needs to render
|
|
136
|
+
- **CAN-4** Canonical chains: a canonical target whose own canonical points further
|
|
137
|
+
- **MAP-4** Every audited page is in the sitemap; the sitemap has no pages blocked by robots.txt
|
|
138
|
+
- **LNK-3** Orphan pages: in the sitemap, but no internal link leads to them
|
|
139
|
+
- **LNK-4** Redirect chains of more than one hop
|
|
140
|
+
- **IMG-2** Images are sized and lazy-loaded, so they do not slow the page down
|
|
141
|
+
- **PERF-1** Server response time and page weight
|
|
142
|
+
- **SOC-1** Twitter / X cards in addition to Open Graph
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { DEFAULT_LANG, LANGS, isLang, parsePages, toMarkdown } from "ai-search-auditor";
|
|
5
|
+
import { auditSearch } from "./index.js";
|
|
6
|
+
const HELP = `search-auditor — one crawl, one report: can Google, Bing and AI search (ChatGPT, Claude, Perplexity) find, read and cite your site?
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
search-auditor <url> [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--base <url> public origin when auditing a local build (links in sitemap point there)
|
|
13
|
+
--pages <n|all> pages to audit: a number sampled across templates (default 20), or all pages in the sitemap
|
|
14
|
+
--max-links <n> internal links to request (default 100)
|
|
15
|
+
--no-ai classic search checks only
|
|
16
|
+
--fail-on <level> exit 1 on: error (default), warn, never
|
|
17
|
+
--lang <lang> report language: ${Object.keys(LANGS).join(", ")} (default ${DEFAULT_LANG})
|
|
18
|
+
--json print the JSON report instead of Markdown
|
|
19
|
+
--out <file> also write the report to a file (.json or .md)
|
|
20
|
+
--config <file> config file (default: search-auditor.config.json if present)
|
|
21
|
+
-v, --verbose show progress
|
|
22
|
+
-h, --help
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
npx search-auditor https://example.com
|
|
26
|
+
npx search-auditor http://127.0.0.1:3000 --base https://example.com --fail-on warn
|
|
27
|
+
npx search-auditor https://example.com --lang ru --no-ai
|
|
28
|
+
npx search-auditor https://example.com --pages all
|
|
29
|
+
`;
|
|
30
|
+
// An unknown or malformed flag gets one line with the help hint, not a stack trace.
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = parseArgs({
|
|
34
|
+
allowPositionals: true,
|
|
35
|
+
allowNegative: true,
|
|
36
|
+
options: {
|
|
37
|
+
base: { type: "string" },
|
|
38
|
+
pages: { type: "string" },
|
|
39
|
+
"max-links": { type: "string" },
|
|
40
|
+
ai: { type: "boolean" },
|
|
41
|
+
"fail-on": { type: "string", default: "error" },
|
|
42
|
+
lang: { type: "string" },
|
|
43
|
+
json: { type: "boolean", default: false },
|
|
44
|
+
out: { type: "string" },
|
|
45
|
+
config: { type: "string" },
|
|
46
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
47
|
+
help: { type: "boolean", short: "h", default: false },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
process.stderr.write(`search-auditor: ${e.message.split(". To specify")[0]}\nRun with --help to see the options.\n`);
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
const { values, positionals } = parsed;
|
|
56
|
+
const target = positionals[0];
|
|
57
|
+
if (values.help || !target) {
|
|
58
|
+
process.stdout.write(HELP);
|
|
59
|
+
process.exit(values.help ? 0 : 2);
|
|
60
|
+
}
|
|
61
|
+
// A function declaration, not an arrow: TypeScript treats only declared `never` functions as the end of a branch.
|
|
62
|
+
function fail(message) {
|
|
63
|
+
process.stderr.write(`search-auditor: ${message}\n`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
const count = (name, v) => {
|
|
67
|
+
if (v === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
const n = Number(v);
|
|
70
|
+
if (!Number.isInteger(n) || n < 1)
|
|
71
|
+
fail(`--${name} must be a whole number above 0, got «${v}»`);
|
|
72
|
+
return n;
|
|
73
|
+
};
|
|
74
|
+
const url = /^https?:\/\//.test(target) ? target : `https://${target}`;
|
|
75
|
+
const configPath = values.config ?? (existsSync("search-auditor.config.json") ? "search-auditor.config.json" : undefined);
|
|
76
|
+
const fileConfig = configPath ? JSON.parse(readFileSync(configPath, "utf8")) : {};
|
|
77
|
+
// Flags win over the config file; an unknown language stops before any request is made.
|
|
78
|
+
const lang = values.lang ?? fileConfig.lang ?? DEFAULT_LANG;
|
|
79
|
+
if (!isLang(lang))
|
|
80
|
+
fail(`unknown language «${lang}», use one of: ${Object.keys(LANGS).join(", ")}`);
|
|
81
|
+
if (!["error", "warn", "never"].includes(values["fail-on"]))
|
|
82
|
+
fail(`--fail-on must be error, warn or never, got «${values["fail-on"]}»`);
|
|
83
|
+
const pagesRaw = values.pages ?? fileConfig.maxPages;
|
|
84
|
+
const maxPages = pagesRaw === undefined ? undefined : parsePages(pagesRaw);
|
|
85
|
+
if (maxPages === null)
|
|
86
|
+
fail(`${values.pages !== undefined ? "--pages" : "maxPages in the config"} must be a whole number above 0 or «all», got «${pagesRaw}»`);
|
|
87
|
+
const maxLinks = count("max-links", values["max-links"]);
|
|
88
|
+
const config = {
|
|
89
|
+
...fileConfig,
|
|
90
|
+
lang,
|
|
91
|
+
...(maxPages ? { maxPages } : {}),
|
|
92
|
+
...(maxLinks ? { maxLinks } : {}),
|
|
93
|
+
...(values.ai === false ? { ai: false } : {}),
|
|
94
|
+
};
|
|
95
|
+
// Progress goes to stderr, so `--json > report.json` stays clean.
|
|
96
|
+
const log = values.verbose ? (line) => process.stderr.write(`· ${line}\n`) : undefined;
|
|
97
|
+
let report;
|
|
98
|
+
try {
|
|
99
|
+
report = await auditSearch(url, { base: values.base, config, log });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
fail(e.message);
|
|
103
|
+
}
|
|
104
|
+
const markdown = toMarkdown(report);
|
|
105
|
+
process.stdout.write(values.json ? JSON.stringify(report, null, 2) + "\n" : markdown);
|
|
106
|
+
if (values.out)
|
|
107
|
+
writeFileSync(values.out, values.out.endsWith(".json") ? JSON.stringify(report, null, 2) : markdown);
|
|
108
|
+
if (process.env.GITHUB_STEP_SUMMARY)
|
|
109
|
+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
|
|
110
|
+
const failOn = values["fail-on"];
|
|
111
|
+
const failed = failOn === "never" ? false : failOn === "warn" ? report.counts.error + report.counts.warn > 0 : report.counts.error > 0;
|
|
112
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Site } from "ai-search-auditor";
|
|
2
|
+
import type { SeoExtras, SeoSite } from "./types.ts";
|
|
3
|
+
export interface CollectOptions {
|
|
4
|
+
maxLinks: number;
|
|
5
|
+
concurrency?: number;
|
|
6
|
+
log?: (line: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Extra requests for the SEO checks, in the same run as the shared crawl: a made-up URL that must answer 404,
|
|
10
|
+
* canonical targets outside the sample, internal links (capped by maxLinks) and, for a live https site,
|
|
11
|
+
* the home page over plain http://.
|
|
12
|
+
*/
|
|
13
|
+
export declare function collect(site: Site, opts: CollectOptions): Promise<SeoSite>;
|
|
14
|
+
export declare const seoOf: (site: Site) => SeoExtras;
|
package/dist/collect.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fetchUrl, isSameSite, pool } from "ai-search-auditor";
|
|
2
|
+
import { norm } from "./rules/util.js";
|
|
3
|
+
const probeOf = (f, from) => ({
|
|
4
|
+
status: f.status,
|
|
5
|
+
redirected: Boolean(f.redirected),
|
|
6
|
+
finalUrl: f.finalUrl ?? f.publicUrl,
|
|
7
|
+
...(f.error ? { error: f.error } : {}),
|
|
8
|
+
...(from ? { from } : {}),
|
|
9
|
+
});
|
|
10
|
+
/** Links worth requesting: pages, not files a crawler would not index anyway. */
|
|
11
|
+
const isPageLike = (url) => !/\.(png|jpe?g|gif|webp|avif|svg|ico|css|js|mjs|map|woff2?|ttf|otf|mp4|webm|mp3|zip|gz)(\?|$)/i.test(new URL(url).pathname);
|
|
12
|
+
/**
|
|
13
|
+
* Extra requests for the SEO checks, in the same run as the shared crawl: a made-up URL that must answer 404,
|
|
14
|
+
* canonical targets outside the sample, internal links (capped by maxLinks) and, for a live https site,
|
|
15
|
+
* the home page over plain http://.
|
|
16
|
+
*/
|
|
17
|
+
export async function collect(site, opts) {
|
|
18
|
+
const o = { target: site.target, base: site.base };
|
|
19
|
+
const conc = opts.concurrency ?? 6;
|
|
20
|
+
const log = opts.log ?? (() => { });
|
|
21
|
+
// A local build (--base) or a dev server: HTTPS and the http:// redirect are the live site's business.
|
|
22
|
+
const local = site.target !== site.base || /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(new URL(site.base).hostname);
|
|
23
|
+
// Everything the crawl already requested, so nothing is fetched twice.
|
|
24
|
+
const known = new Map();
|
|
25
|
+
for (const p of site.pages)
|
|
26
|
+
known.set(norm(p.fetched.publicUrl), probeOf(p.fetched));
|
|
27
|
+
for (const f of site.failed)
|
|
28
|
+
known.set(norm(f.publicUrl), probeOf(f));
|
|
29
|
+
const canonicalTargets = [
|
|
30
|
+
...new Set(site.pages
|
|
31
|
+
.map((p) => {
|
|
32
|
+
try {
|
|
33
|
+
return p.canonical ? new URL(p.canonical, p.fetched.publicUrl).href : "";
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.filter((u) => u && isSameSite(u, o) && !known.has(norm(u)))),
|
|
40
|
+
].slice(0, 20);
|
|
41
|
+
const firstSeen = new Map();
|
|
42
|
+
for (const p of site.pages)
|
|
43
|
+
for (const href of p.links)
|
|
44
|
+
if (isSameSite(href, o) && isPageLike(href) && !firstSeen.has(norm(href)))
|
|
45
|
+
firstSeen.set(norm(href), p.fetched.publicUrl);
|
|
46
|
+
const linkTargets = [...firstSeen.keys()].filter((u) => !known.has(u));
|
|
47
|
+
const toCheck = linkTargets.slice(0, opts.maxLinks);
|
|
48
|
+
log(`${canonicalTargets.length} canonical targets, ${toCheck.length} of ${firstSeen.size} internal links`);
|
|
49
|
+
const light = { maxBytes: 65_536, timeoutMs: 10_000 };
|
|
50
|
+
const [probe404, httpHome, canonicals, linked] = await Promise.all([
|
|
51
|
+
fetchUrl(`${site.target}/__search-auditor-${Math.random().toString(36).slice(2, 10)}`, o, light),
|
|
52
|
+
!local && site.base.startsWith("https://") ? fetchUrl(`http://${new URL(site.base).host}/`, o, light) : Promise.resolve(null),
|
|
53
|
+
pool(canonicalTargets, conc, (u) => fetchUrl(u, o, light)),
|
|
54
|
+
pool(toCheck, conc, (u) => fetchUrl(u, o, light)),
|
|
55
|
+
]);
|
|
56
|
+
const links = new Map();
|
|
57
|
+
for (const [url, from] of firstSeen) {
|
|
58
|
+
const done = known.get(url);
|
|
59
|
+
if (done)
|
|
60
|
+
links.set(url, { ...done, from });
|
|
61
|
+
}
|
|
62
|
+
toCheck.forEach((url, i) => links.set(url, probeOf(linked[i], firstSeen.get(url))));
|
|
63
|
+
const seo = {
|
|
64
|
+
local,
|
|
65
|
+
probe404,
|
|
66
|
+
httpHome,
|
|
67
|
+
canonicals: new Map(canonicalTargets.map((u, i) => [norm(u), probeOf(canonicals[i])])),
|
|
68
|
+
links,
|
|
69
|
+
linksFound: firstSeen.size,
|
|
70
|
+
linksChecked: links.size,
|
|
71
|
+
};
|
|
72
|
+
return Object.assign(site, { seo });
|
|
73
|
+
}
|
|
74
|
+
export const seoOf = (site) => site.seo;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* English texts of the SEO checks: the reference for every other language. A new language is a file typed as
|
|
3
|
+
* `SeoMessages` (TypeScript lists what is missing) plus a line in ./index.ts. AI search texts come from ai-search-auditor.
|
|
4
|
+
*/
|
|
5
|
+
export declare const en: {
|
|
6
|
+
report: {
|
|
7
|
+
title: (site: string) => string;
|
|
8
|
+
clean: string;
|
|
9
|
+
footer: (pages: number, date: string) => string;
|
|
10
|
+
classic: string;
|
|
11
|
+
ai: string;
|
|
12
|
+
};
|
|
13
|
+
groups: {
|
|
14
|
+
IDX: string;
|
|
15
|
+
CAN: string;
|
|
16
|
+
MAP: string;
|
|
17
|
+
META: string;
|
|
18
|
+
STR: string;
|
|
19
|
+
LNK: string;
|
|
20
|
+
IMG: string;
|
|
21
|
+
DATA: string;
|
|
22
|
+
LANG: string;
|
|
23
|
+
SEC: string;
|
|
24
|
+
};
|
|
25
|
+
rules: {
|
|
26
|
+
"IDX-1": {
|
|
27
|
+
title: string;
|
|
28
|
+
fix: string;
|
|
29
|
+
impact: string;
|
|
30
|
+
};
|
|
31
|
+
"IDX-2": {
|
|
32
|
+
title: string;
|
|
33
|
+
fix: string;
|
|
34
|
+
impact: string;
|
|
35
|
+
};
|
|
36
|
+
"CAN-1": {
|
|
37
|
+
title: string;
|
|
38
|
+
fix: string;
|
|
39
|
+
impact: string;
|
|
40
|
+
};
|
|
41
|
+
"CAN-2": {
|
|
42
|
+
title: string;
|
|
43
|
+
fix: string;
|
|
44
|
+
impact: string;
|
|
45
|
+
};
|
|
46
|
+
"CAN-3": {
|
|
47
|
+
title: string;
|
|
48
|
+
fix: string;
|
|
49
|
+
impact: string;
|
|
50
|
+
};
|
|
51
|
+
"MAP-1": {
|
|
52
|
+
title: string;
|
|
53
|
+
fix: string;
|
|
54
|
+
impact: string;
|
|
55
|
+
};
|
|
56
|
+
"MAP-2": {
|
|
57
|
+
title: string;
|
|
58
|
+
fix: string;
|
|
59
|
+
impact: string;
|
|
60
|
+
};
|
|
61
|
+
"MAP-3": {
|
|
62
|
+
title: string;
|
|
63
|
+
fix: string;
|
|
64
|
+
impact: string;
|
|
65
|
+
};
|
|
66
|
+
"META-1": {
|
|
67
|
+
title: string;
|
|
68
|
+
fix: string;
|
|
69
|
+
impact: string;
|
|
70
|
+
};
|
|
71
|
+
"META-2": {
|
|
72
|
+
title: string;
|
|
73
|
+
fix: string;
|
|
74
|
+
impact: string;
|
|
75
|
+
};
|
|
76
|
+
"META-3": {
|
|
77
|
+
title: string;
|
|
78
|
+
fix: string;
|
|
79
|
+
impact: string;
|
|
80
|
+
};
|
|
81
|
+
"STR-1": {
|
|
82
|
+
title: string;
|
|
83
|
+
fix: string;
|
|
84
|
+
impact: string;
|
|
85
|
+
};
|
|
86
|
+
"STR-2": {
|
|
87
|
+
title: string;
|
|
88
|
+
fix: string;
|
|
89
|
+
impact: string;
|
|
90
|
+
};
|
|
91
|
+
"STR-3": {
|
|
92
|
+
title: string;
|
|
93
|
+
fix: string;
|
|
94
|
+
impact: string;
|
|
95
|
+
};
|
|
96
|
+
"STR-4": {
|
|
97
|
+
title: string;
|
|
98
|
+
fix: string;
|
|
99
|
+
impact: string;
|
|
100
|
+
};
|
|
101
|
+
"LNK-1": {
|
|
102
|
+
title: string;
|
|
103
|
+
fix: string;
|
|
104
|
+
impact: string;
|
|
105
|
+
};
|
|
106
|
+
"LNK-2": {
|
|
107
|
+
title: string;
|
|
108
|
+
fix: string;
|
|
109
|
+
impact: string;
|
|
110
|
+
};
|
|
111
|
+
"IMG-1": {
|
|
112
|
+
title: string;
|
|
113
|
+
fix: string;
|
|
114
|
+
impact: string;
|
|
115
|
+
};
|
|
116
|
+
"DATA-1": {
|
|
117
|
+
title: string;
|
|
118
|
+
fix: string;
|
|
119
|
+
impact: string;
|
|
120
|
+
};
|
|
121
|
+
"LANG-1": {
|
|
122
|
+
title: string;
|
|
123
|
+
fix: string;
|
|
124
|
+
impact: string;
|
|
125
|
+
};
|
|
126
|
+
"LANG-2": {
|
|
127
|
+
title: string;
|
|
128
|
+
fix: string;
|
|
129
|
+
impact: string;
|
|
130
|
+
};
|
|
131
|
+
"LANG-3": {
|
|
132
|
+
title: string;
|
|
133
|
+
fix: string;
|
|
134
|
+
impact: string;
|
|
135
|
+
};
|
|
136
|
+
"SEC-1": {
|
|
137
|
+
title: string;
|
|
138
|
+
fix: string;
|
|
139
|
+
impact: string;
|
|
140
|
+
};
|
|
141
|
+
"SEC-2": {
|
|
142
|
+
title: string;
|
|
143
|
+
fix: string;
|
|
144
|
+
impact: string;
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
/** Finding messages. URLs and quotes come in already formatted. */
|
|
148
|
+
m: {
|
|
149
|
+
status: (status: string) => string;
|
|
150
|
+
soft404Probe: (url: string) => string;
|
|
151
|
+
soft404Page: (title: string) => string;
|
|
152
|
+
noCanonical: string;
|
|
153
|
+
relativeCanonical: (href: string) => string;
|
|
154
|
+
foreignCanonical: (href: string) => string;
|
|
155
|
+
canonicalStatus: (href: string, status: string) => string;
|
|
156
|
+
canonicalRedirects: (href: string, to: string) => string;
|
|
157
|
+
noSitemap: string;
|
|
158
|
+
sitemapNotInRobots: string;
|
|
159
|
+
sitemapRedirects: (to: string) => string;
|
|
160
|
+
sitemapCanonicalElsewhere: (href: string) => string;
|
|
161
|
+
noTitle: string;
|
|
162
|
+
titleLength: (n: number, title: string) => string;
|
|
163
|
+
noDescription: string;
|
|
164
|
+
descriptionLength: (n: number) => string;
|
|
165
|
+
sameTitle: (n: number, title: string) => string;
|
|
166
|
+
sameDescription: (n: number, text: string) => string;
|
|
167
|
+
noH1: string;
|
|
168
|
+
manyH1: (n: number) => string;
|
|
169
|
+
skippedLevel: (from: number, to: number, text: string) => string;
|
|
170
|
+
noLang: string;
|
|
171
|
+
noViewport: string;
|
|
172
|
+
brokenLink: (status: string, from: string) => string;
|
|
173
|
+
redirectLink: (to: string, from: string) => string;
|
|
174
|
+
linksNotChecked: (checked: number, found: number) => string;
|
|
175
|
+
imagesNoAlt: (n: number, total: number, example: string) => string;
|
|
176
|
+
jsonLdError: (error: string) => string;
|
|
177
|
+
badHreflang: (code: string) => string;
|
|
178
|
+
oneWayHreflang: (lang: string, href: string) => string;
|
|
179
|
+
noXDefault: (langs: string) => string;
|
|
180
|
+
notHttps: string;
|
|
181
|
+
httpNoRedirect: (to: string) => string;
|
|
182
|
+
mixedContent: (n: number, example: string) => string;
|
|
183
|
+
skipLocal: string;
|
|
184
|
+
skipNoSitemap: string;
|
|
185
|
+
skipNoHreflang: string;
|
|
186
|
+
skipOneLanguage: string;
|
|
187
|
+
skipNoImages: string;
|
|
188
|
+
skipNoJsonLd: string;
|
|
189
|
+
skipNoLinks: string;
|
|
190
|
+
skipNotHttps: string;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
export type SeoMessages = typeof en;
|
|
194
|
+
export type SeoRuleId = keyof SeoMessages["rules"];
|