@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/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
+ }
@@ -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(/&amp;/g, '&')
15
+ .replace(/&lt;/g, '<')
16
+ .replace(/&gt;/g, '>')
17
+ .replace(/&quot;/g, '"')
18
+ .replace(/&#39;/g, "'")
19
+ .replace(/&nbsp;/g, ' ')
20
+ // The typographic entities a CMS emits into link text and headings. Left
21
+ // undecoded, "here's their page &raquo;" 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(/&hellip;/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
+ }