@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/robots.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// robots.txt, read the way Google reads it.
|
|
2
|
+
//
|
|
3
|
+
// The subtlety that decides whether this check is useful or noise: Allow and
|
|
4
|
+
// Disallow are not first-match-wins. The most specific rule wins — the longest
|
|
5
|
+
// path pattern — and a tie goes to Allow. Implementing only Disallow would
|
|
6
|
+
// report every site that carves an exception out of a broad block, which is
|
|
7
|
+
// most sites that have ever written:
|
|
8
|
+
//
|
|
9
|
+
// Disallow: /wp-admin/
|
|
10
|
+
// Allow: /wp-admin/admin-ajax.php
|
|
11
|
+
//
|
|
12
|
+
// Patterns support `*` for any run of characters and a trailing `$` to anchor
|
|
13
|
+
// the end. Everything else is a literal prefix match.
|
|
14
|
+
|
|
15
|
+
/** Group records from a robots.txt body: [{ agents, rules }]. */
|
|
16
|
+
export function parseRobots(body) {
|
|
17
|
+
const groups = [];
|
|
18
|
+
let current = null;
|
|
19
|
+
// Consecutive User-agent lines share one group of rules; a User-agent line
|
|
20
|
+
// *after* a rule starts a new group.
|
|
21
|
+
let namingAgents = false;
|
|
22
|
+
|
|
23
|
+
for (const raw of (body ?? '').split(/\r?\n/)) {
|
|
24
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
25
|
+
if (!line) continue;
|
|
26
|
+
const match = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
|
|
27
|
+
if (!match) continue;
|
|
28
|
+
|
|
29
|
+
const field = match[1].toLowerCase();
|
|
30
|
+
const value = match[2].trim();
|
|
31
|
+
|
|
32
|
+
if (field === 'user-agent') {
|
|
33
|
+
if (!namingAgents) {
|
|
34
|
+
current = { agents: [], rules: [] };
|
|
35
|
+
groups.push(current);
|
|
36
|
+
namingAgents = true;
|
|
37
|
+
}
|
|
38
|
+
current.agents.push(value.toLowerCase());
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (field === 'allow' || field === 'disallow') {
|
|
42
|
+
if (!current) continue; // A rule before any User-agent line belongs to nobody.
|
|
43
|
+
namingAgents = false;
|
|
44
|
+
current.rules.push({ allow: field === 'allow', path: value });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return groups;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Does a rule pattern cover this path? */
|
|
51
|
+
function covers(pattern, path) {
|
|
52
|
+
// `Disallow:` with no value is the documented way to say "nothing", and must
|
|
53
|
+
// not be read as "everything".
|
|
54
|
+
if (pattern === '') return false;
|
|
55
|
+
const anchored = pattern.endsWith('$');
|
|
56
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
57
|
+
const rx = body.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
58
|
+
return new RegExp(`^${rx}${anchored ? '$' : ''}`).test(path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The group that applies to an agent: its own, or the `*` fallback. */
|
|
62
|
+
function groupFor(groups, agent) {
|
|
63
|
+
return (
|
|
64
|
+
groups.find((g) => g.agents.includes(agent)) ??
|
|
65
|
+
groups.find((g) => g.agents.includes('*')) ??
|
|
66
|
+
null
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `path` may be crawled, and which rule decided it.
|
|
72
|
+
*
|
|
73
|
+
* Googlebot is the default agent because it is the one whose opinion shows up
|
|
74
|
+
* in search results, which is the only reason this tool asks.
|
|
75
|
+
*/
|
|
76
|
+
export function robotsVerdict(groups, path, agent = 'googlebot') {
|
|
77
|
+
const group = groupFor(groups, agent);
|
|
78
|
+
if (!group) return { allowed: true, rule: null };
|
|
79
|
+
|
|
80
|
+
let best = null;
|
|
81
|
+
for (const rule of group.rules) {
|
|
82
|
+
if (!covers(rule.path, path)) continue;
|
|
83
|
+
if (!best) {
|
|
84
|
+
best = rule;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Longest pattern wins; on a tie, Allow does.
|
|
88
|
+
if (rule.path.length > best.path.length) best = rule;
|
|
89
|
+
else if (rule.path.length === best.path.length && rule.allow) best = rule;
|
|
90
|
+
}
|
|
91
|
+
return { allowed: best ? best.allow : true, rule: best };
|
|
92
|
+
}
|
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The hosted front end, running locally.
|
|
2
|
+
//
|
|
3
|
+
// Not a second implementation of it: `worker/index.mjs` is written against
|
|
4
|
+
// `Request` and `Response`, which Node has, so the same file answers both. What
|
|
5
|
+
// is here is thirty lines of adapter between `node:http` and the fetch API, and
|
|
6
|
+
// the reason the Worker was held to web standards in the first place.
|
|
7
|
+
//
|
|
8
|
+
// What this has that the Worker cannot: no CPU ceiling, no subrequest limit, no
|
|
9
|
+
// bill. A five-thousand-page site with `maxImageChecks` past a thousand fits
|
|
10
|
+
// here and nowhere else.
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
import { handle } from '../worker/index.mjs';
|
|
16
|
+
|
|
17
|
+
/** Start the local UI. Returns `{ url, close }`.
|
|
18
|
+
*
|
|
19
|
+
* Bound to the loopback address, which is the whole of its security model: a
|
|
20
|
+
* server only this machine can reach is as private as the terminal that
|
|
21
|
+
* started it.
|
|
22
|
+
*
|
|
23
|
+
* The Worker's password gate is left exactly as it is rather than given a
|
|
24
|
+
* local exemption — a bypass inside the deployed code is a bypass that can
|
|
25
|
+
* reach production one refactor later. Instead a random token is minted here
|
|
26
|
+
* and the adapter presents it on every request, so the gate is satisfied
|
|
27
|
+
* rather than skipped. */
|
|
28
|
+
export async function serve({ port = 4321, host = '127.0.0.1', maxPages, allowedHosts, userAgent } = {}) {
|
|
29
|
+
const token = randomUUID();
|
|
30
|
+
const env = {
|
|
31
|
+
AUDIT_TOKEN: token,
|
|
32
|
+
...(maxPages ? { MAX_PAGES: String(maxPages) } : {}),
|
|
33
|
+
...(allowedHosts ? { ALLOWED_HOSTS: allowedHosts } : {}),
|
|
34
|
+
...(userAgent ? { USER_AGENT: userAgent } : {}),
|
|
35
|
+
// PageSpeed is allowed here and nowhere else by default: --serve is bound to
|
|
36
|
+
// the loopback address and the person running it is the person it serves, so
|
|
37
|
+
// spending their own PSI quota is their decision. A deployed Worker leaves
|
|
38
|
+
// this unset, where a stranger passing ?psi= would be spending somebody
|
|
39
|
+
// else's.
|
|
40
|
+
ALLOW_PSI: '1',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const server = createServer(async (incoming, outgoing) => {
|
|
44
|
+
try {
|
|
45
|
+
const url = `http://${incoming.headers.host ?? `${host}:${port}`}${incoming.url}`;
|
|
46
|
+
const body =
|
|
47
|
+
incoming.method === 'GET' || incoming.method === 'HEAD'
|
|
48
|
+
? undefined
|
|
49
|
+
: await new Promise((resolve) => {
|
|
50
|
+
const chunks = [];
|
|
51
|
+
incoming.on('data', (chunk) => chunks.push(chunk));
|
|
52
|
+
incoming.on('end', () => resolve(Buffer.concat(chunks)));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const request = new Request(url, {
|
|
56
|
+
method: incoming.method,
|
|
57
|
+
headers: { ...incoming.headers, authorization: `Bearer ${token}` },
|
|
58
|
+
body,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const response = await handle(request, env, null);
|
|
62
|
+
outgoing.writeHead(response.status, Object.fromEntries(response.headers));
|
|
63
|
+
if (response.body) Readable.fromWeb(response.body).pipe(outgoing);
|
|
64
|
+
else outgoing.end();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
outgoing.writeHead(500, { 'content-type': 'text/plain' });
|
|
67
|
+
outgoing.end(`The local server failed: ${err.message}\n`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
server.once('error', reject);
|
|
73
|
+
server.listen(port, host, resolve);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const { port: bound } = server.address();
|
|
77
|
+
return {
|
|
78
|
+
url: `http://${host}:${bound}/`,
|
|
79
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
80
|
+
};
|
|
81
|
+
}
|