@nurkamol/seo-audit 1.31.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/LICENSE +21 -0
- package/README.md +792 -0
- package/action.yml +194 -0
- package/bin/seo-audit.mjs +483 -0
- package/package.json +52 -0
- package/src/agents.mjs +122 -0
- package/src/areas.mjs +135 -0
- package/src/audit.mjs +700 -0
- package/src/baseline.mjs +71 -0
- package/src/causes.mjs +167 -0
- package/src/checks.mjs +1253 -0
- package/src/compare.mjs +100 -0
- package/src/config.mjs +156 -0
- package/src/console.mjs +146 -0
- package/src/dupes.mjs +164 -0
- package/src/graph.mjs +89 -0
- package/src/http.mjs +228 -0
- package/src/options.mjs +77 -0
- package/src/parse.mjs +347 -0
- package/src/prompt.mjs +37 -0
- package/src/psi.mjs +200 -0
- package/src/redirects.mjs +145 -0
- package/src/report.mjs +868 -0
- package/src/robots.mjs +92 -0
- package/src/serve.mjs +81 -0
- package/src/site.mjs +714 -0
- package/src/sitemap.mjs +183 -0
package/src/http.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Fetching, with the two things a crawler actually needs: a concurrency limit
|
|
2
|
+
// so a small site is not hammered, and a cache so the same URL is never
|
|
3
|
+
// fetched twice across checks.
|
|
4
|
+
|
|
5
|
+
const DEFAULT_UA = 'seo-audit (+https://github.com/nurkamol/seo-audit)';
|
|
6
|
+
|
|
7
|
+
export class Fetcher {
|
|
8
|
+
/** @param {{concurrency?: number, timeout?: number}} opts */
|
|
9
|
+
constructor({ concurrency = 6, timeout = 20000, userAgent = DEFAULT_UA } = {}) {
|
|
10
|
+
this.timeout = timeout;
|
|
11
|
+
this.userAgent = userAgent;
|
|
12
|
+
/** Consecutive timeouts. Some hosts accept the TLS handshake and then
|
|
13
|
+
* never answer — Cloudflare's bot management does this to clients whose
|
|
14
|
+
* TLS fingerprint is not a browser. Retrying that is 20 seconds of
|
|
15
|
+
* nothing, per attempt, per URL. */
|
|
16
|
+
this.timeouts = 0;
|
|
17
|
+
this.reachable = false;
|
|
18
|
+
/** Set when a host answers 429. Nothing starts before it, and the
|
|
19
|
+
* concurrency comes down with it — retrying a rate limit at the speed
|
|
20
|
+
* that caused it just spends the budget again. */
|
|
21
|
+
this.quietUntil = 0;
|
|
22
|
+
this.rateLimited = 0;
|
|
23
|
+
this.cache = new Map();
|
|
24
|
+
this.queue = [];
|
|
25
|
+
this.active = 0;
|
|
26
|
+
this.concurrency = concurrency;
|
|
27
|
+
this.count = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Wait out a rate limit, if one is in force. */
|
|
31
|
+
async #quiet() {
|
|
32
|
+
const wait = this.quietUntil - Date.now();
|
|
33
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A 429 is the server asking for a slower crawl, and it is asking about the
|
|
37
|
+
* whole run rather than about one URL. So the pause is global, and the
|
|
38
|
+
* concurrency halves and stays down: a short run has no time to earn back
|
|
39
|
+
* the trust, and finishing slowly beats finishing wrong.
|
|
40
|
+
*
|
|
41
|
+
* Retry-After is honoured where it is sent and capped where it is absurd —
|
|
42
|
+
* a store asking for an hour is asking for a different tool. Shopify sends
|
|
43
|
+
* none at all, which is why there is a default. */
|
|
44
|
+
#backOff(res) {
|
|
45
|
+
this.rateLimited++;
|
|
46
|
+
const asked = Number.parseInt(res.headers?.get?.('retry-after') ?? '', 10);
|
|
47
|
+
// Without a Retry-After the wait doubles to a ceiling of eight seconds.
|
|
48
|
+
// Thirty would be defensible per request and ruinous across two hundred of
|
|
49
|
+
// them: the concurrency coming down is what actually gets a run through a
|
|
50
|
+
// rate limit, and the pause only has to be long enough to let it.
|
|
51
|
+
const seconds =
|
|
52
|
+
Number.isFinite(asked) && asked > 0 ? Math.min(asked, 30) : Math.min(2 ** Math.min(this.rateLimited, 3), 8);
|
|
53
|
+
this.quietUntil = Math.max(this.quietUntil, Date.now() + seconds * 1000);
|
|
54
|
+
this.concurrency = Math.max(1, Math.floor(this.concurrency / 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Run `fn` when a slot frees up. */
|
|
58
|
+
#schedule(fn) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const run = async () => {
|
|
61
|
+
this.active++;
|
|
62
|
+
try {
|
|
63
|
+
resolve(await fn());
|
|
64
|
+
} catch (err) {
|
|
65
|
+
reject(err);
|
|
66
|
+
} finally {
|
|
67
|
+
this.active--;
|
|
68
|
+
const next = this.queue.shift();
|
|
69
|
+
if (next) next();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (this.active < this.concurrency) run();
|
|
73
|
+
else this.queue.push(run);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* GET a URL. Redirects are NOT followed — a redirect is a finding, not a
|
|
79
|
+
* detour, and following it silently is how "page with redirect" problems go
|
|
80
|
+
* unnoticed.
|
|
81
|
+
* @returns {Promise<{url: string, status: number, ok: boolean, headers: Headers,
|
|
82
|
+
* body: string, location: string|null, ms: number, error?: string}>}
|
|
83
|
+
*/
|
|
84
|
+
async get(url, { method = 'GET', retries = 2 } = {}) {
|
|
85
|
+
const key = `${method} ${url}`;
|
|
86
|
+
if (this.cache.has(key)) return this.cache.get(key);
|
|
87
|
+
|
|
88
|
+
const attempt = async () => {
|
|
89
|
+
await this.#quiet();
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(url, {
|
|
95
|
+
method,
|
|
96
|
+
redirect: 'manual',
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
headers: { 'User-Agent': this.userAgent, Accept: '*/*' },
|
|
99
|
+
});
|
|
100
|
+
const type = res.headers.get('content-type') ?? '';
|
|
101
|
+
// Only read a body worth parsing; a 40MB video would stall the run.
|
|
102
|
+
const body = method === 'GET' && /text|json|xml/.test(type) ? await res.text() : '';
|
|
103
|
+
this.count++;
|
|
104
|
+
this.timeouts = 0;
|
|
105
|
+
this.reachable = true;
|
|
106
|
+
return {
|
|
107
|
+
url,
|
|
108
|
+
status: res.status,
|
|
109
|
+
ok: res.status >= 200 && res.status < 300,
|
|
110
|
+
headers: res.headers,
|
|
111
|
+
body,
|
|
112
|
+
location: res.headers.get('location'),
|
|
113
|
+
ms: Date.now() - started,
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
url,
|
|
118
|
+
status: 0,
|
|
119
|
+
ok: false,
|
|
120
|
+
headers: new Headers(),
|
|
121
|
+
body: '',
|
|
122
|
+
location: null,
|
|
123
|
+
ms: Date.now() - started,
|
|
124
|
+
error: err.name === 'AbortError' ? 'timed out' : err.message,
|
|
125
|
+
// A refused connection or an unknown host is an answer, not a blip:
|
|
126
|
+
// retrying cannot change it, and doing so makes a dead site slow to
|
|
127
|
+
// report instead of fast.
|
|
128
|
+
permanent: /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ERR_INVALID_URL/.test(
|
|
129
|
+
`${err.code ?? ''} ${err.cause?.code ?? ''} ${err.message}`,
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
} finally {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// One blip in a 200-page crawl should not be reported as a broken page.
|
|
138
|
+
// Transport failures, 5xx and 429 are retried; a 404 is an answer.
|
|
139
|
+
//
|
|
140
|
+
// 429 was not, until a Shopify store answered it to 70 of 200 pages at the
|
|
141
|
+
// default concurrency and every one was reported as a page that did not
|
|
142
|
+
// load. The pages were fine. Spaced eight seconds apart the same URLs
|
|
143
|
+
// answered 200, on the tool's own user agent — it was the crawl's speed,
|
|
144
|
+
// and reporting the site for it is the worst kind of false positive,
|
|
145
|
+
// because it looks exactly like a site that is broken.
|
|
146
|
+
const promise = this.#schedule(async () => {
|
|
147
|
+
let last;
|
|
148
|
+
for (let i = 0; i <= retries; i++) {
|
|
149
|
+
// Once a host has timed out repeatedly and never once answered, stop
|
|
150
|
+
// paying 20 seconds a go to confirm it. Reported as unreachable.
|
|
151
|
+
if (this.timeouts >= 3 && !this.reachable) {
|
|
152
|
+
return {
|
|
153
|
+
url, status: 0, ok: false, headers: new Headers(), body: '',
|
|
154
|
+
location: null, ms: 0, error: 'host is not answering', permanent: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
last = await attempt();
|
|
158
|
+
if (last.error === 'timed out') this.timeouts++;
|
|
159
|
+
if (last.permanent) return last;
|
|
160
|
+
if (last.status === 429) {
|
|
161
|
+
this.#backOff(last);
|
|
162
|
+
// A host that has said no twenty times is not going to say yes
|
|
163
|
+
// because it was asked again. Let each page report the rate limit
|
|
164
|
+
// and let the run finish, rather than spending an hour proving it.
|
|
165
|
+
if (this.rateLimited > 20) return last;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (last.status !== 0 && last.status < 500) return last;
|
|
169
|
+
if (i < retries) await new Promise((r) => setTimeout(r, 300 * (i + 1)));
|
|
170
|
+
}
|
|
171
|
+
return last;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.cache.set(key, promise);
|
|
175
|
+
return promise;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wait until a URL serves the same bytes several times in a row.
|
|
180
|
+
*
|
|
181
|
+
* A CDN rolls a deploy out unevenly: for a minute or two one edge answers
|
|
182
|
+
* with the new page and another with the old, and a crawl during that window
|
|
183
|
+
* produces a snapshot that is wrong in a way nobody can reproduce later.
|
|
184
|
+
*/
|
|
185
|
+
async settle(url, seconds) {
|
|
186
|
+
const deadline = Date.now() + seconds * 1000;
|
|
187
|
+
const wanted = 3;
|
|
188
|
+
let previous = null;
|
|
189
|
+
let same = 0;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
this.cache.delete(`GET ${url}`);
|
|
192
|
+
const res = await this.get(url, { retries: 0 });
|
|
193
|
+
const fingerprint = `${res.status}:${res.body.length}:${res.headers.get('etag') ?? ''}`;
|
|
194
|
+
same = fingerprint === previous ? same + 1 : 0;
|
|
195
|
+
previous = fingerprint;
|
|
196
|
+
if (same >= wanted - 1) return true;
|
|
197
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Follow a chain by hand so the number of hops can be reported. */
|
|
203
|
+
async chain(url, max = 5) {
|
|
204
|
+
const hops = [];
|
|
205
|
+
let current = url;
|
|
206
|
+
for (let i = 0; i < max; i++) {
|
|
207
|
+
const res = await this.get(current);
|
|
208
|
+
hops.push({ url: current, status: res.status });
|
|
209
|
+
if (res.status < 300 || res.status >= 400 || !res.location) return { hops, final: res };
|
|
210
|
+
current = new URL(res.location, current).toString();
|
|
211
|
+
}
|
|
212
|
+
return { hops, final: await this.get(current) };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Run tasks with a cap, preserving input order in the results. */
|
|
217
|
+
export async function mapLimit(items, limit, fn) {
|
|
218
|
+
const results = new Array(items.length);
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
221
|
+
while (cursor < items.length) {
|
|
222
|
+
const i = cursor++;
|
|
223
|
+
results[i] = await fn(items[i], i);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
await Promise.all(workers);
|
|
227
|
+
return results;
|
|
228
|
+
}
|
package/src/options.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Every flag, and whether the window can reach it.
|
|
2
|
+
//
|
|
3
|
+
// The command line grew thirty-two flags and the macOS window reached ten of
|
|
4
|
+
// them, and nothing anywhere said whether that was a decision or an oversight.
|
|
5
|
+
// It was both, in different places, and there was no way to tell which from
|
|
6
|
+
// outside — the same failure this project refuses in its reports, where a
|
|
7
|
+
// missing finding reads exactly like a passing one.
|
|
8
|
+
//
|
|
9
|
+
// So: one table, and a test that fails when a flag is added without an answer.
|
|
10
|
+
// `app: true` means the window reaches it. A **string** means it does not, and
|
|
11
|
+
// the string is the reason — "not yet" is a perfectly good reason, written
|
|
12
|
+
// down, and it stops being invisible.
|
|
13
|
+
//
|
|
14
|
+
// This is not a parser. `bin/seo-audit.mjs` still owns argument handling and
|
|
15
|
+
// `src/audit.mjs` still owns defaults; duplicating either here would create the
|
|
16
|
+
// second source of truth this file exists to prevent.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `flag` what the command line calls it
|
|
20
|
+
* `query` the /stream parameter the window sends on a run, when it sends one
|
|
21
|
+
* `via` how the window reaches it when it is not a run parameter
|
|
22
|
+
* `app` true, or the reason it is not reachable
|
|
23
|
+
*/
|
|
24
|
+
export const OPTIONS = [
|
|
25
|
+
// --- what a run does ----------------------------------------------------
|
|
26
|
+
{ flag: '--limit', query: 'limit', app: true },
|
|
27
|
+
{ flag: '--concurrency', query: 'concurrency', app: true },
|
|
28
|
+
{ flag: '--check-external', query: 'external', app: true },
|
|
29
|
+
{ flag: '--sitemap', query: 'sitemap', app: true },
|
|
30
|
+
{ flag: '--browser', query: 'browser', app: true },
|
|
31
|
+
{ flag: '--os', query: 'os', app: true },
|
|
32
|
+
{ flag: '--user-agent', query: 'userAgent', app: true },
|
|
33
|
+
{ flag: '--write-sitemap', query: 'sitemap-out', app: true, via: 'the Export menu' },
|
|
34
|
+
{ flag: '--ignore', query: 'ignore', app: true, via: 'right-clicking a finding, and the Settings list' },
|
|
35
|
+
{ flag: '--psi', query: 'psi', app: true, via: 'Settings → Performance' },
|
|
36
|
+
{ flag: '--psi-sample', query: 'psi-sample', app: true, via: 'Settings → Performance' },
|
|
37
|
+
{ flag: '--psi-strategy', query: 'psi-strategy', app: true, via: 'Settings → Performance' },
|
|
38
|
+
{ flag: '--since', query: null, app: 'not yet — it needs a date picker and a sense of when the last run was, which the window has in the library and does not offer yet' },
|
|
39
|
+
{ flag: '--exclude', query: null, app: 'not yet — a list of patterns needs somewhere to live in Settings, and one text field would be worse than nothing' },
|
|
40
|
+
|
|
41
|
+
// --- reached another way ------------------------------------------------
|
|
42
|
+
{ flag: '--dry-run', query: null, app: true, via: 'the Preview button' },
|
|
43
|
+
{ flag: '--baseline', query: null, app: true, via: 'the Compare menu, over /diff' },
|
|
44
|
+
{ flag: '--json', query: null, app: true, via: 'the Export menu' },
|
|
45
|
+
{ flag: '--csv', query: null, app: true, via: 'the Export menu' },
|
|
46
|
+
{ flag: '--md', query: null, app: true, via: 'the Export menu' },
|
|
47
|
+
{ flag: '--html', query: null, app: true, via: 'the Export menu' },
|
|
48
|
+
|
|
49
|
+
// --- deliberately not in a window ---------------------------------------
|
|
50
|
+
{ flag: '--help', query: null, app: 'a window has no command line to explain' },
|
|
51
|
+
{ flag: '--version', query: null, app: 'the sidebar shows it, and offers every other one' },
|
|
52
|
+
{ flag: '--quiet', query: null, app: 'the crawl log is on screen while it runs' },
|
|
53
|
+
{ flag: '--verbose', query: null, app: 'the crawl log is on screen while it runs' },
|
|
54
|
+
{ flag: '--serve', query: null, app: 'the window is what --serve serves' },
|
|
55
|
+
{ flag: '--fail-on', query: null, app: 'a window has no exit code for a build to read' },
|
|
56
|
+
{ flag: '--update-baseline', query: null, app: 'a baseline is a file a repository commits' },
|
|
57
|
+
{ flag: '--config', query: null, app: 'a config file is a file a repository commits' },
|
|
58
|
+
{ flag: '--redirects', query: null, app: 'a migration map is a file a repository commits' },
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
// --- not yet, and that is a decision rather than an oversight ------------
|
|
62
|
+
{ flag: '--search-console', query: null, app: 'not yet — needs an OAuth client, and has never run against the live API' },
|
|
63
|
+
{ flag: '--against', query: null, app: 'not yet — the window compares two kept runs instead of two live deployments' },
|
|
64
|
+
{ flag: '--compare-as', query: null, app: 'not yet — it fetches a sample of pages a second time, and the window has no control for spending that' },
|
|
65
|
+
{ flag: '--compare-sample', query: null, app: 'not yet — see --compare-as' },
|
|
66
|
+
{ flag: '--settle', query: null, app: 'not yet — waiting out a deploy that is still rolling out is a CI shape, not something somebody watches a window do' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** The parameters a client should send on a run, for one that wants to build
|
|
70
|
+
* its own controls rather than hard-code this list. */
|
|
71
|
+
export const runParameters = () =>
|
|
72
|
+
OPTIONS.filter((o) => o.app === true && o.query).map(({ flag, query }) => ({ flag, query }));
|
|
73
|
+
|
|
74
|
+
/** Everything the window does not reach, and why. Served so the answer is
|
|
75
|
+
* discoverable rather than only being in a source file. */
|
|
76
|
+
export const notInApp = () =>
|
|
77
|
+
OPTIONS.filter((o) => o.app !== true).map(({ flag, app }) => ({ flag, reason: app }));
|
package/src/parse.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Minimal HTML extraction.
|
|
2
|
+
//
|
|
3
|
+
// No parser dependency on purpose: this tool should run anywhere with `npx`
|
|
4
|
+
// and nothing installed. The regexes below are deliberately narrow — they read
|
|
5
|
+
// well-formed markup produced by a static site generator, which is what this
|
|
6
|
+
// audits. Anything ambiguous is reported as unknown rather than guessed.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import { fingerprint } from './dupes.mjs';
|
|
10
|
+
const stripTags = (s) => s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
11
|
+
|
|
12
|
+
const decode = (s) =>
|
|
13
|
+
s
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, "'")
|
|
19
|
+
.replace(/ /g, ' ')
|
|
20
|
+
// The typographic entities a CMS emits into link text and headings. Left
|
|
21
|
+
// undecoded, "here's their page »" normalises to a phrase with the
|
|
22
|
+
// word "raquo" in it, which is nobody's anchor text.
|
|
23
|
+
.replace(/&(l|r)aquo;/g, (_, side) => (side === 'l' ? '«' : '»'))
|
|
24
|
+
.replace(/&(m|n)dash;/g, (_, kind) => (kind === 'm' ? '—' : '–'))
|
|
25
|
+
.replace(/…/g, '…')
|
|
26
|
+
.replace(/&(l|r)squo;/g, "'")
|
|
27
|
+
.replace(/&(l|r)dquo;/g, '"')
|
|
28
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
|
|
29
|
+
|
|
30
|
+
/** Attribute value from a tag string: attr(`<img alt="x">`, 'alt') → 'x'
|
|
31
|
+
*
|
|
32
|
+
* The lookbehind matters, and has been widened twice by real sites:
|
|
33
|
+
*
|
|
34
|
+
* - `\b` treats the hyphen in `data-src` as a boundary, so a plain
|
|
35
|
+
* word-boundary match reads a lazy-loading site's `data-src` as its `src`
|
|
36
|
+
* and reports images that are not there.
|
|
37
|
+
* - `:` and `[` introduce a framework binding — `:src`, `v-bind:src`,
|
|
38
|
+
* `x-bind:src`, `[src]` — whose value is a JavaScript expression, not a URL.
|
|
39
|
+
* allbirds.com binds `:src="(cardRefs['7205190238288']?.selectedImage…)"`,
|
|
40
|
+
* and reading those as real sources reported twenty-four of its images as
|
|
41
|
+
* 404s that do not exist. */
|
|
42
|
+
export function attr(tag, name) {
|
|
43
|
+
const start = `(?<![-:\\[\\w])${name}`;
|
|
44
|
+
const m =
|
|
45
|
+
tag.match(new RegExp(`${start}\\s*=\\s*"([^"]*)"`, 'i')) ??
|
|
46
|
+
tag.match(new RegExp(`${start}\\s*=\\s*'([^']*)'`, 'i')) ??
|
|
47
|
+
// Unquoted, which HTML permits and minifiers produce: smashingmagazine.com
|
|
48
|
+
// ships `<meta name=viewport content="…">`, and reading only quoted values
|
|
49
|
+
// reported nine of its pages as having no viewport at all.
|
|
50
|
+
tag.match(new RegExp(`${start}\\s*=\\s*([^\\s"'\`=<>]+)`, 'i'));
|
|
51
|
+
if (m) return decode(m[1]);
|
|
52
|
+
// Bare boolean attribute (`<img alt>`) — present, with an empty value.
|
|
53
|
+
return new RegExp(`${start}(?=[\\s/>])`, 'i').test(tag) ? '' : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Blank out attribute values that contain whole tags.
|
|
57
|
+
*
|
|
58
|
+
* Markup inside an attribute value is a code sample, not part of the page.
|
|
59
|
+
* astro.build stores an entire Astro component in a `data-code` attribute for
|
|
60
|
+
* its copy button, and the `<img src={product.imageUrl}>` in that string was
|
|
61
|
+
* read as a real image with no alt — an error, on a site that has no such
|
|
62
|
+
* problem.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately narrow: the value must contain something shaped like a tag, so
|
|
65
|
+
* `title="a < b"` and `content="Tea & Cake"` are untouched. */
|
|
66
|
+
export const stripMarkupInAttributes = (html) =>
|
|
67
|
+
html.replace(/="[^"]*<[a-z][^">]*>[^"]*"/gi, '=""');
|
|
68
|
+
|
|
69
|
+
// Japanese, Chinese and Thai do not put spaces between words, so splitting on
|
|
70
|
+
// whitespace counts an entire paragraph as one. The Japanese translation of a
|
|
71
|
+
// React docs page counted 177 against the English original's 411 — the same
|
|
72
|
+
// page, the same content — and was reported as thin.
|
|
73
|
+
//
|
|
74
|
+
// Counted at roughly two characters to the word, the usual working equivalence.
|
|
75
|
+
// It is an approximation, and deliberately a generous one: over-counting keeps
|
|
76
|
+
// a real page quiet, while under-counting calls it thin, and only one of those
|
|
77
|
+
// is a finding somebody has to argue with.
|
|
78
|
+
const UNSPACED_SCRIPT =
|
|
79
|
+
/[-ヿ㐀-䶿一-鿿豈--]/g;
|
|
80
|
+
|
|
81
|
+
export function countWords(text) {
|
|
82
|
+
if (!text) return 0;
|
|
83
|
+
const unspaced = text.match(UNSPACED_SCRIPT)?.length ?? 0;
|
|
84
|
+
const spaced = text
|
|
85
|
+
.replace(UNSPACED_SCRIPT, ' ')
|
|
86
|
+
.split(/\s+/)
|
|
87
|
+
.filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
88
|
+
return spaced + Math.round(unspaced / 2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseHtml(rawHtml, pageUrl) {
|
|
92
|
+
const html = stripMarkupInAttributes(rawHtml);
|
|
93
|
+
|
|
94
|
+
// Elements are read from markup with <script> and <style> contents removed.
|
|
95
|
+
// A script that builds HTML by concatenation — `'<li><a href="' + a.url + '">'`
|
|
96
|
+
// — is code, not links on the page, and smashingmagazine.com's offline-article
|
|
97
|
+
// list had nine of those reported as links to a page that does not exist.
|
|
98
|
+
//
|
|
99
|
+
// JSON-LD is read from `html` instead, because it lives inside a <script>.
|
|
100
|
+
const markup = html
|
|
101
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
102
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
|
103
|
+
|
|
104
|
+
const head = (markup.match(/<head[\s\S]*?<\/head>/i) ?? [''])[0];
|
|
105
|
+
const mainRegion = (markup.match(/<main[\s\S]*?<\/main>/i) ?? [''])[0]
|
|
106
|
+
|| (markup.match(/<article[\s\S]*?<\/article>/i) ?? [''])[0]
|
|
107
|
+
|| null;
|
|
108
|
+
const main = mainRegion || markup;
|
|
109
|
+
|
|
110
|
+
const metas = [...markup.matchAll(/<meta\b[^>]*>/gi)].map((m) => m[0]);
|
|
111
|
+
const metaBy = (key, value) => {
|
|
112
|
+
const tag = metas.find((t) => (attr(t, key) ?? '').toLowerCase() === value);
|
|
113
|
+
return tag ? attr(tag, 'content') : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const links = [...markup.matchAll(/<link\b[^>]*>/gi)].map((m) => m[0]);
|
|
117
|
+
const linkRel = (rel) => links.filter((t) => (attr(t, 'rel') ?? '').toLowerCase() === rel);
|
|
118
|
+
|
|
119
|
+
// The three rel values Google reads a favicon from, and `rel` is a token
|
|
120
|
+
// list, so the legacy `shortcut icon` is matched by the `icon` in it without
|
|
121
|
+
// needing a rule of its own.
|
|
122
|
+
const ICON_RELS = new Set(['icon', 'apple-touch-icon', 'apple-touch-icon-precomposed']);
|
|
123
|
+
|
|
124
|
+
const abs = (href) => {
|
|
125
|
+
try {
|
|
126
|
+
return new URL(href, pageUrl).toString();
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const anchors = [...markup.matchAll(/<a\b[^>]*>/gi)].map((m) => m[0]);
|
|
133
|
+
const mainAnchors = [...main.matchAll(/<a\b[^>]*>/gi)].map((m) => m[0]);
|
|
134
|
+
const hrefs = (list) =>
|
|
135
|
+
list
|
|
136
|
+
.map((t) => attr(t, 'href'))
|
|
137
|
+
.filter((h) => h && !/^(#|mailto:|tel:|javascript:|data:)/i.test(h))
|
|
138
|
+
.map(abs)
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
|
|
141
|
+
const origin = new URL(pageUrl).origin;
|
|
142
|
+
const internal = (list) => hrefs(list).filter((h) => h.startsWith(origin));
|
|
143
|
+
|
|
144
|
+
// Anchors paired with the words attached to them. Google reads those words as
|
|
145
|
+
// a description of the destination — they are the one signal a page gets from
|
|
146
|
+
// outside itself — and until now they were parsed and thrown away.
|
|
147
|
+
//
|
|
148
|
+
// The name is resolved the way a browser resolves an accessible name, in
|
|
149
|
+
// order, because each of these is a real way to label a link and reporting
|
|
150
|
+
// any of them as unlabelled would be wrong:
|
|
151
|
+
//
|
|
152
|
+
// the text inside → an image's alt → aria-label → the anchor's own title
|
|
153
|
+
//
|
|
154
|
+
// aria-labelledby points at another element by id. It is not followed here —
|
|
155
|
+
// that means reading the rest of the document — and its mere presence counts
|
|
156
|
+
// as named, since the alternative is calling a labelled link unlabelled.
|
|
157
|
+
//
|
|
158
|
+
// A missing </a> makes the match run to the next one, which produces text
|
|
159
|
+
// where there was none. That direction is safe: it can only silence this,
|
|
160
|
+
// never invent it.
|
|
161
|
+
const namedAnchors = [...markup.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)].map((m) => {
|
|
162
|
+
const tag = `<a${m[1]}>`;
|
|
163
|
+
const inner = m[2];
|
|
164
|
+
const img = inner.match(/<img\b[^>]*>/i)?.[0];
|
|
165
|
+
const svgTitle = inner.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
166
|
+
const name =
|
|
167
|
+
decode(stripTags(inner)) ||
|
|
168
|
+
(img && attr(img, 'alt')) ||
|
|
169
|
+
attr(tag, 'aria-label') ||
|
|
170
|
+
attr(tag, 'title') ||
|
|
171
|
+
(svgTitle && decode(stripTags(svgTitle))) ||
|
|
172
|
+
(img && attr(img, 'title')) ||
|
|
173
|
+
// A framework binding is a label the author supplied and this cannot
|
|
174
|
+
// read — `:alt="item.title"`, `[ariaLabel]="…"`. The same trap that made
|
|
175
|
+
// img-alt report twenty-four of allbirds.com's images as missing alt.
|
|
176
|
+
(img && /[:[]alt\b/i.test(img) ? '…' : '') ||
|
|
177
|
+
(/[:[](attr\.)?aria-?label\b/i.test(tag) ? '…' : '') ||
|
|
178
|
+
// Labelled by something elsewhere in the document, or by a child that
|
|
179
|
+
// labels itself. Not resolved, only believed.
|
|
180
|
+
(attr(tag, 'aria-labelledby') !== null || /aria-label(ledby)?=/i.test(inner) ? '…' : '') ||
|
|
181
|
+
'';
|
|
182
|
+
return { tag, href: attr(tag, 'href'), name: name.slice(0, 300) };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Internal only, self-links dropped: a page linking to itself says nothing
|
|
186
|
+
// about anywhere, and a logo in the header does it on every page of the site.
|
|
187
|
+
const anchorTexts = namedAnchors
|
|
188
|
+
.filter((a) => a.href && !/^(#|mailto:|tel:|javascript:|data:)/i.test(a.href))
|
|
189
|
+
.map((a) => ({ ...a, href: abs(a.href) }))
|
|
190
|
+
.filter((a) => a.href?.startsWith(origin))
|
|
191
|
+
.map((a) => ({ href: a.href.split('#')[0], name: a.name }))
|
|
192
|
+
.filter((a) => a.href.replace(/\/$/, '') !== pageUrl.split('#')[0].replace(/\/$/, ''));
|
|
193
|
+
|
|
194
|
+
const jsonld = [...html.matchAll(/<script\b[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)]
|
|
195
|
+
.map((m) => {
|
|
196
|
+
try {
|
|
197
|
+
return { ok: true, data: JSON.parse(m[1]) };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return { ok: false, error: err.message };
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const images = [...markup.matchAll(/<img\b[^>]*>/gi)].map((m) => {
|
|
204
|
+
const tag = m[0];
|
|
205
|
+
return {
|
|
206
|
+
tag,
|
|
207
|
+
src: attr(tag, 'src'),
|
|
208
|
+
alt: attr(tag, 'alt'),
|
|
209
|
+
width: attr(tag, 'width'),
|
|
210
|
+
height: attr(tag, 'height'),
|
|
211
|
+
srcset: attr(tag, 'srcset'),
|
|
212
|
+
loading: attr(tag, 'loading'),
|
|
213
|
+
fetchpriority: attr(tag, 'fetchpriority'),
|
|
214
|
+
role: attr(tag, 'role'),
|
|
215
|
+
// Captured for the two things it can contradict, never for its absence:
|
|
216
|
+
// an image with no title has nothing wrong with it.
|
|
217
|
+
title: attr(tag, 'title'),
|
|
218
|
+
// `:alt="item.title"` is alt text the framework fills in on render. The
|
|
219
|
+
// value cannot be read from here, but the author plainly provided one,
|
|
220
|
+
// and calling that a missing alt is guessing wrong at error level.
|
|
221
|
+
altBound: /[:[]alt\b/i.test(tag),
|
|
222
|
+
// Inside a <picture> the sibling <source> may carry the srcset instead.
|
|
223
|
+
inPicture: false,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const pictures = [...markup.matchAll(/<picture[\s\S]*?<\/picture>/gi)].map((m) => m[0]);
|
|
228
|
+
for (const img of images) {
|
|
229
|
+
if (img.src && pictures.some((p) => p.includes(img.src))) img.inPicture = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const headings = (level) =>
|
|
233
|
+
[...markup.matchAll(new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)</h${level}>`, 'gi'))].map((m) =>
|
|
234
|
+
stripTags(m[1]),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Heading levels in document order, so a skipped level is visible — read
|
|
238
|
+
// from <main> only. The footer's column headings are furniture repeated on
|
|
239
|
+
// every page, not part of this page's outline, and counting them reports a
|
|
240
|
+
// jump on exactly the pages whose content happens to have no h2.
|
|
241
|
+
const headingLevels = [...main.matchAll(/<h([1-6])\b/gi)].map((m) => Number(m[1]));
|
|
242
|
+
|
|
243
|
+
const bodyText = stripTags(
|
|
244
|
+
main
|
|
245
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
246
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' '),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
title: (markup.match(/<title[^>]*>([\s\S]*?)<\/title>/i) ?? [null, null])[1]?.trim(),
|
|
251
|
+
description: metaBy('name', 'description'),
|
|
252
|
+
robots: metaBy('name', 'robots'),
|
|
253
|
+
// <meta http-equiv="refresh" content="0;url=…"> — a redirect that is not
|
|
254
|
+
// one, and the only kind this tool can see in the markup.
|
|
255
|
+
refresh: metaBy('http-equiv', 'refresh'),
|
|
256
|
+
viewport: metaBy('name', 'viewport'),
|
|
257
|
+
lang: attr((markup.match(/<html\b[^>]*>/i) ?? [''])[0], 'lang'),
|
|
258
|
+
canonical: linkRel('canonical').map((t) => abs(attr(t, 'href'))).filter(Boolean),
|
|
259
|
+
hreflang: linkRel('alternate')
|
|
260
|
+
.filter((t) => attr(t, 'hreflang'))
|
|
261
|
+
.map((t) => ({ lang: attr(t, 'hreflang'), href: abs(attr(t, 'href')) })),
|
|
262
|
+
og: Object.fromEntries(
|
|
263
|
+
metas
|
|
264
|
+
.filter((t) => (attr(t, 'property') ?? '').startsWith('og:'))
|
|
265
|
+
.map((t) => [attr(t, 'property'), attr(t, 'content')]),
|
|
266
|
+
),
|
|
267
|
+
twitter: Object.fromEntries(
|
|
268
|
+
metas
|
|
269
|
+
.filter((t) => (attr(t, 'name') ?? '').startsWith('twitter:'))
|
|
270
|
+
.map((t) => [attr(t, 'name'), attr(t, 'content')]),
|
|
271
|
+
),
|
|
272
|
+
h1: headings(1),
|
|
273
|
+
h2: headings(2),
|
|
274
|
+
headingLevels,
|
|
275
|
+
charset:
|
|
276
|
+
metaBy('charset', undefined) ??
|
|
277
|
+
(metas.some((t) => attr(t, 'charset') !== null)
|
|
278
|
+
? attr(metas.find((t) => attr(t, 'charset') !== null), 'charset')
|
|
279
|
+
: /charset=/i.test(head)
|
|
280
|
+
? 'declared'
|
|
281
|
+
: null),
|
|
282
|
+
images,
|
|
283
|
+
// Declared favicons, in the order a search engine would prefer them: the
|
|
284
|
+
// plain `icon` first, then the iOS ones. Google looks for these on the
|
|
285
|
+
// home page and draws the result beside every listing the site owns.
|
|
286
|
+
icons: links
|
|
287
|
+
.map((tag) => ({
|
|
288
|
+
rel: (attr(tag, 'rel') ?? '').toLowerCase().split(/\s+/).filter(Boolean),
|
|
289
|
+
href: abs(attr(tag, 'href')),
|
|
290
|
+
}))
|
|
291
|
+
.filter((icon) => icon.href && icon.rel.some((r) => ICON_RELS.has(r)))
|
|
292
|
+
.sort((a, b) => Number(b.rel.includes('icon')) - Number(a.rel.includes('icon')))
|
|
293
|
+
.map((icon) => icon.href)
|
|
294
|
+
.filter((href, i, all) => all.indexOf(href) === i),
|
|
295
|
+
jsonld,
|
|
296
|
+
links: {
|
|
297
|
+
internal: [...new Set(internal(anchors))],
|
|
298
|
+
inMain: [...new Set(internal(mainAnchors))],
|
|
299
|
+
external: [...new Set(hrefs(anchors).filter((h) => !h.startsWith(origin)))],
|
|
300
|
+
// Internal links the page tells Google not to follow. `rel` carries a
|
|
301
|
+
// space-separated list, so nofollow travels with noopener and friends.
|
|
302
|
+
//
|
|
303
|
+
// Fragments are stripped and self-links dropped: WordPress marks its
|
|
304
|
+
// comment-reply links rel="nofollow" pointing at #respond on the page
|
|
305
|
+
// they are already on, and every article on a WordPress site would report
|
|
306
|
+
// a withheld path that leads nowhere new.
|
|
307
|
+
// Every internal link with the words attached to it — see above.
|
|
308
|
+
anchorTexts,
|
|
309
|
+
nofollowInternal: [
|
|
310
|
+
...new Set(
|
|
311
|
+
internal(anchors.filter((t) => /(^|\s)nofollow(\s|$)/i.test(attr(t, 'rel') ?? '')))
|
|
312
|
+
.map((h) => h.split('#')[0])
|
|
313
|
+
.filter((h) => h.replace(/\/$/, '') !== pageUrl.split('#')[0].replace(/\/$/, '')),
|
|
314
|
+
),
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
words: countWords(bodyText),
|
|
318
|
+
// A sketch of the content, for finding pages that are the same page again.
|
|
319
|
+
// Only when the page marked its content region: without `<main>` or
|
|
320
|
+
// `<article>` the text above is the whole document, navigation and footer
|
|
321
|
+
// included, and every page of a small site would look like every other.
|
|
322
|
+
// Saying "not compared" is the honest answer; guessing is not.
|
|
323
|
+
fingerprint: mainRegion ? fingerprint(bodyText) : null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** URLs from a sitemap or sitemap index. Returns {urls, sitemaps, entries}.
|
|
328
|
+
*
|
|
329
|
+
* `entries` pairs each <loc> with its own <lastmod>, read from inside the
|
|
330
|
+
* <url> block so a date cannot drift onto a neighbouring URL. It is additional
|
|
331
|
+
* rather than a replacement: `urls` stays a plain list of strings, because
|
|
332
|
+
* every caller wants exactly that and changing it would ripple through
|
|
333
|
+
* discovery for no gain. */
|
|
334
|
+
export function parseSitemap(xml) {
|
|
335
|
+
const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => decode(m[1]));
|
|
336
|
+
const isIndex = /<sitemapindex/i.test(xml);
|
|
337
|
+
if (isIndex) return { urls: [], sitemaps: locs, entries: [] };
|
|
338
|
+
|
|
339
|
+
const entries = [...xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/gi)]
|
|
340
|
+
.map((m) => ({
|
|
341
|
+
loc: decode(m[1].match(/<loc>\s*([^<\s]+)\s*<\/loc>/i)?.[1] ?? ''),
|
|
342
|
+
lastmod: m[1].match(/<lastmod>\s*([^<\s]+)\s*<\/lastmod>/i)?.[1] ?? null,
|
|
343
|
+
}))
|
|
344
|
+
.filter((entry) => entry.loc);
|
|
345
|
+
|
|
346
|
+
return { urls: locs, sitemaps: [], entries };
|
|
347
|
+
}
|