@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/prompt.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The questions asked when someone runs the bare command.
|
|
2
|
+
//
|
|
3
|
+
// This exists to be a friendlier first thirty seconds, not a menu to live in.
|
|
4
|
+
// It asks the minimum, then prints the command it assembled and runs that — so
|
|
5
|
+
// the second use is a one-liner and the flags are learned rather than hidden.
|
|
6
|
+
//
|
|
7
|
+
// The readline interface is passed in rather than created here, so the flow can
|
|
8
|
+
// be tested without a terminal. Whether to ask at all is `isInteractive`, and
|
|
9
|
+
// the answer is no far more often than people expect: a pipe, a CI runner, a
|
|
10
|
+
// `| tee`, an editor's task runner. A prompt that blocks a build waiting for
|
|
11
|
+
// input nobody can type is much worse than the help text it replaced.
|
|
12
|
+
|
|
13
|
+
export const isInteractive = (streams = process) =>
|
|
14
|
+
Boolean(streams.stdin?.isTTY && streams.stdout?.isTTY);
|
|
15
|
+
|
|
16
|
+
/** The one-line command that reproduces this run. */
|
|
17
|
+
export function invocation(url, { html } = {}) {
|
|
18
|
+
return ['seo-audit', url, html ? `--html ${html}` : ''].filter(Boolean).join(' ');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{question: (q: string) => Promise<string>}} rl
|
|
23
|
+
* @returns {Promise<{url: string, html?: string}|null>} null if there is no
|
|
24
|
+
* usable answer — an empty line, or a stream that closed under us.
|
|
25
|
+
*/
|
|
26
|
+
export async function askForSite(rl) {
|
|
27
|
+
try {
|
|
28
|
+
const url = (await rl.question(' Site to audit: ')).trim();
|
|
29
|
+
if (!url) return null;
|
|
30
|
+
|
|
31
|
+
const html = /^y(es)?$/i.test((await rl.question(' Save an HTML report? [y/N] ')).trim());
|
|
32
|
+
return html ? { url, html: 'seo-audit.html' } : { url };
|
|
33
|
+
} catch {
|
|
34
|
+
// Ctrl-C, or EOF on a stream that looked like a terminal and was not.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/psi.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// PageSpeed Insights.
|
|
2
|
+
//
|
|
3
|
+
// Everywhere else this tool refuses to talk about performance, because a fetch
|
|
4
|
+
// loop cannot see rendering and a plausible-looking wrong number is worse than
|
|
5
|
+
// no number. Asking Google for its own measurement is a different thing: this
|
|
6
|
+
// is Lighthouse, run by Google, on Google's hardware — the same figure the
|
|
7
|
+
// PageSpeed Insights page shows.
|
|
8
|
+
//
|
|
9
|
+
// It is slow (~12s per URL) and rate-limited, so it runs on the pages you name
|
|
10
|
+
// rather than the whole sitemap, and never by default.
|
|
11
|
+
//
|
|
12
|
+
// A key is optional but raises the quota well above the anonymous limit. Set
|
|
13
|
+
// PSI_API_KEY, or put it in ~/.config/seo-audit/.env — never in the repo.
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { matchGlob } from './config.mjs';
|
|
18
|
+
|
|
19
|
+
const ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
|
|
20
|
+
|
|
21
|
+
// Roughly a minute of measuring per section. Enough to tell whether a template
|
|
22
|
+
// is slow — which is the question a section is asked — without turning an audit
|
|
23
|
+
// into a coffee break.
|
|
24
|
+
export const DEFAULT_SAMPLE = 3;
|
|
25
|
+
|
|
26
|
+
// What one PSI call costs, near enough to warn someone before they wait for it.
|
|
27
|
+
const SECONDS_PER_URL = 12;
|
|
28
|
+
|
|
29
|
+
// Google's own thresholds for "good" and "poor".
|
|
30
|
+
const CWV = {
|
|
31
|
+
lcp: { good: 2500, poor: 4000, label: 'Largest Contentful Paint' },
|
|
32
|
+
cls: { good: 0.1, poor: 0.25, label: 'Cumulative Layout Shift' },
|
|
33
|
+
inp: { good: 200, poor: 500, label: 'Interaction to Next Paint' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function findKey() {
|
|
37
|
+
if (process.env.PSI_API_KEY) return process.env.PSI_API_KEY;
|
|
38
|
+
const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
|
|
39
|
+
if (!existsSync(dotfile)) return null;
|
|
40
|
+
const match = readFileSync(dotfile, 'utf8').match(/^\s*PSI_API_KEY\s*=\s*(\S+)/m);
|
|
41
|
+
return match?.[1] ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function run(url, strategy, key) {
|
|
45
|
+
const params = new URLSearchParams({ url, strategy, category: 'performance' });
|
|
46
|
+
if (key) params.set('key', key);
|
|
47
|
+
const res = await fetch(`${ENDPOINT}?${params}`);
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
if (data.error) throw new Error(data.error.message);
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
|
|
54
|
+
|
|
55
|
+
/** `n` items spread across a list, rather than the first n.
|
|
56
|
+
*
|
|
57
|
+
* Deterministic on purpose. A random sample would measure different pages on
|
|
58
|
+
* every run, and --baseline would then report the change as a regression. */
|
|
59
|
+
function spread(list, n) {
|
|
60
|
+
if (list.length <= n) return list;
|
|
61
|
+
const step = list.length / n;
|
|
62
|
+
return Array.from({ length: n }, (_, i) => list[Math.floor(i * step)]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const estimateSeconds = (n) => n * SECONDS_PER_URL;
|
|
66
|
+
|
|
67
|
+
/** Resolve --psi entries against the pages actually crawled.
|
|
68
|
+
*
|
|
69
|
+
* A URL or a path is measured as given. A path glob names a section — every
|
|
70
|
+
* crawled page under it, sampled down to `sample`, because a section of forty
|
|
71
|
+
* pages measured whole is eight minutes of waiting.
|
|
72
|
+
*
|
|
73
|
+
* Returns { urls, notes }. The notes report what matched but was not measured:
|
|
74
|
+
* a sampled section must never read as a clean bill of health for the whole
|
|
75
|
+
* section, which is exactly how a silent cap would read. */
|
|
76
|
+
export function psiTargets(entries, pageUrls, { origin, sample = DEFAULT_SAMPLE } = {}) {
|
|
77
|
+
const urls = [];
|
|
78
|
+
const notes = [];
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.includes('*')) {
|
|
82
|
+
try {
|
|
83
|
+
urls.push(new URL(entry, origin).toString());
|
|
84
|
+
} catch {
|
|
85
|
+
notes.push(f('info', 'psi-no-match', `Not a URL or path: ${entry}`,
|
|
86
|
+
'Pass a full URL, a path, or a path glob such as /journal/**.', origin));
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const matched = pageUrls.filter((u) => {
|
|
92
|
+
try {
|
|
93
|
+
return matchGlob(entry, new URL(u).pathname);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!matched.length) {
|
|
100
|
+
notes.push(f('info', 'psi-no-match', `No crawled page matches ${entry}`,
|
|
101
|
+
'Nothing was measured for this pattern. Globs match URL paths, where `*` stops at a slash and `**` does not.', origin));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const picked = spread(matched, sample);
|
|
106
|
+
urls.push(...picked);
|
|
107
|
+
if (picked.length < matched.length) {
|
|
108
|
+
notes.push(f('info', 'psi-sampled', `Measured ${picked.length} of the ${matched.length} pages under ${entry}`,
|
|
109
|
+
`A sample, spread across the section — at ~${SECONDS_PER_URL}s a page, measuring all ${matched.length} would take ` +
|
|
110
|
+
`${Math.ceil(estimateSeconds(matched.length) / 60)} minutes. The other ${matched.length - picked.length} were not looked at; ` +
|
|
111
|
+
'raise the sample with --psi-sample, and expect the wait.', origin));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { urls: [...new Set(urls)], notes };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string[]} urls pages to measure — a handful, not a sitemap
|
|
120
|
+
* @param {{strategy?: 'mobile'|'desktop', key?: string|null}} opts
|
|
121
|
+
*/
|
|
122
|
+
export async function psiChecks(urls, { strategy = 'mobile', key = findKey(), onProgress } = {}) {
|
|
123
|
+
const out = [];
|
|
124
|
+
|
|
125
|
+
for (const [i, url] of urls.entries()) {
|
|
126
|
+
let data;
|
|
127
|
+
// Announced before rather than after: each call takes about twelve seconds,
|
|
128
|
+
// which is a long time to sit looking at nothing.
|
|
129
|
+
onProgress?.({ phase: 'psi', url, detail: `measuring ${i + 1} of ${urls.length} (~12s)` });
|
|
130
|
+
try {
|
|
131
|
+
data = await run(url, strategy, key);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
out.push(f('info', 'psi-failed', 'PageSpeed Insights could not measure this page',
|
|
134
|
+
`${err.message}${key ? '' : ' (no PSI_API_KEY set — the anonymous quota is small)'}`, url));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lr = data.lighthouseResult;
|
|
139
|
+
const audits = lr.audits;
|
|
140
|
+
const score = Math.round((lr.categories.performance.score ?? 0) * 100);
|
|
141
|
+
|
|
142
|
+
if (score < 50) {
|
|
143
|
+
out.push(f('error', 'psi-score', `Performance ${score}/100 on ${strategy}`,
|
|
144
|
+
'Google rates this poor. The opportunities below say why.', url));
|
|
145
|
+
} else if (score < 90) {
|
|
146
|
+
out.push(f('warn', 'psi-score', `Performance ${score}/100 on ${strategy}`,
|
|
147
|
+
'Google rates this "needs improvement".', url));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Lab metrics against Google's own good/poor boundaries.
|
|
151
|
+
const metrics = {
|
|
152
|
+
lcp: audits['largest-contentful-paint']?.numericValue,
|
|
153
|
+
cls: audits['cumulative-layout-shift']?.numericValue,
|
|
154
|
+
};
|
|
155
|
+
for (const [name, value] of Object.entries(metrics)) {
|
|
156
|
+
if (value == null) continue;
|
|
157
|
+
const { good, poor, label } = CWV[name];
|
|
158
|
+
const shown = audits[name === 'lcp' ? 'largest-contentful-paint' : 'cumulative-layout-shift']
|
|
159
|
+
.displayValue;
|
|
160
|
+
if (value > poor) {
|
|
161
|
+
out.push(f('error', `psi-${name}`, `${label} is poor: ${shown}`,
|
|
162
|
+
`Google's threshold for "good" is ${name === 'cls' ? good : `${good / 1000}s`}.`, url));
|
|
163
|
+
} else if (value > good) {
|
|
164
|
+
out.push(f('warn', `psi-${name}`, `${label} needs improvement: ${shown}`,
|
|
165
|
+
`Google's threshold for "good" is ${name === 'cls' ? good : `${good / 1000}s`}.`, url));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Opportunities worth a quarter of a second or more, named by Google.
|
|
170
|
+
for (const audit of Object.values(audits)) {
|
|
171
|
+
const saving = audit.details?.overallSavingsMs ?? 0;
|
|
172
|
+
if (saving >= 250) {
|
|
173
|
+
out.push(f('warn', 'psi-opportunity', `${audit.title} — ${Math.round(saving)}ms`,
|
|
174
|
+
(audit.description ?? '').split('. ')[0] + '.', url));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Field data, when Google has enough real visitors to report it. This is
|
|
179
|
+
// what actually counts for ranking; the lab numbers above are a rehearsal.
|
|
180
|
+
const field = data.loadingExperience?.metrics;
|
|
181
|
+
if (field) {
|
|
182
|
+
for (const [key_, metric] of Object.entries({
|
|
183
|
+
LARGEST_CONTENTFUL_PAINT_MS: 'lcp',
|
|
184
|
+
CUMULATIVE_LAYOUT_SHIFT_SCORE: 'cls',
|
|
185
|
+
INTERACTION_TO_NEXT_PAINT: 'inp',
|
|
186
|
+
})) {
|
|
187
|
+
const entry = field[key_];
|
|
188
|
+
if (entry && entry.category === 'SLOW') {
|
|
189
|
+
out.push(f('error', `psi-field-${metric}`, `Real visitors see a poor ${CWV[metric].label}`,
|
|
190
|
+
`Chrome field data, not a lab test — this is the number Google ranks on.`, url));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
out.push(f('info', 'psi-no-field-data', 'No real-visitor performance data yet',
|
|
195
|
+
'Chrome reports field data once a site has enough traffic. Lab numbers are all there is for now.', url));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Redirect maps, checked against the live site.
|
|
2
|
+
//
|
|
3
|
+
// A migration's redirect map is written once, verified once, and then rots
|
|
4
|
+
// quietly: a later change to the destination turns an entry into a hop through
|
|
5
|
+
// a 404, and nothing tells anyone. The old URLs are the ones with the links and
|
|
6
|
+
// the rankings, so this is one of the few SEO failures that is expensive and
|
|
7
|
+
// completely silent.
|
|
8
|
+
//
|
|
9
|
+
// Reads the Netlify `_redirects` shape, which is also the simplest thing
|
|
10
|
+
// anyone writes by hand:
|
|
11
|
+
//
|
|
12
|
+
// /old-path /new-path 301
|
|
13
|
+
// /also-old /new-path # status optional
|
|
14
|
+
// /just-a-list-of-old-urls # destination optional too
|
|
15
|
+
//
|
|
16
|
+
// A rule with a wildcard or a placeholder cannot be tested by asking for it
|
|
17
|
+
// literally, so those are counted and reported rather than guessed at.
|
|
18
|
+
|
|
19
|
+
const HAS_PATTERN = /[*:]/;
|
|
20
|
+
|
|
21
|
+
/** Rules from a redirect map. `to` and `status` may be null. */
|
|
22
|
+
export function parseRedirectMap(text) {
|
|
23
|
+
const rules = [];
|
|
24
|
+
for (const raw of (text ?? '').split(/\r?\n/)) {
|
|
25
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
26
|
+
if (!line) continue;
|
|
27
|
+
const [from, to, status] = line.split(/\s+/);
|
|
28
|
+
if (!from) continue;
|
|
29
|
+
rules.push({
|
|
30
|
+
from,
|
|
31
|
+
to: to ?? null,
|
|
32
|
+
// Netlify writes `301!` to force a rule ahead of an existing file.
|
|
33
|
+
status: status ? Number(status.replace('!', '')) || null : null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return rules;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
|
|
40
|
+
|
|
41
|
+
const bare = (u) => (u ?? '').replace(/\/$/, '');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ask the live site for every old URL and report what actually happens.
|
|
45
|
+
*
|
|
46
|
+
* Findings are aggregated by outcome: a migration map runs to hundreds of
|
|
47
|
+
* entries, and one finding per entry would be a wall nobody reads. Each one
|
|
48
|
+
* names the first few and says how many more there are.
|
|
49
|
+
*/
|
|
50
|
+
export async function redirectChecks(rules, fetcher, origin, { limit = 200, onProgress } = {}) {
|
|
51
|
+
const out = [];
|
|
52
|
+
if (!rules.length) return out;
|
|
53
|
+
|
|
54
|
+
const patterned = rules.filter((r) => HAS_PATTERN.test(r.from));
|
|
55
|
+
const testable = rules.filter((r) => !HAS_PATTERN.test(r.from));
|
|
56
|
+
const checked = testable.slice(0, limit);
|
|
57
|
+
|
|
58
|
+
if (patterned.length) {
|
|
59
|
+
out.push(f('info', 'redirect-pattern-skipped', `${patterned.length} wildcard rule(s) were not tested`,
|
|
60
|
+
`Rules like ${patterned.slice(0, 2).map((r) => r.from).join(', ')} match a shape rather than a URL, ` +
|
|
61
|
+
'so asking for them literally proves nothing. Add a real example of each to the map, or test them by hand.',
|
|
62
|
+
origin));
|
|
63
|
+
}
|
|
64
|
+
if (testable.length > checked.length) {
|
|
65
|
+
out.push(f('info', 'redirect-map-capped', `${testable.length - checked.length} rule(s) were not tested`,
|
|
66
|
+
`The map has ${testable.length} testable rules and the limit is ${limit}. Raise it with maxRedirectChecks.`,
|
|
67
|
+
origin));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const results = await Promise.all(
|
|
71
|
+
checked.map(async (rule) => {
|
|
72
|
+
let from;
|
|
73
|
+
try {
|
|
74
|
+
from = new URL(rule.from, origin).toString();
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const { hops, final } = await fetcher.chain(from);
|
|
79
|
+
onProgress?.({ phase: 'redirects', status: hops[0]?.status ?? 0, url: rule.from, detail: `→ ${final.url}` });
|
|
80
|
+
return { rule, from, hops, final, first: hops[0]?.status ?? 0 };
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const buckets = new Map();
|
|
85
|
+
const add = (key, line) => buckets.set(key, [...(buckets.get(key) ?? []), line]);
|
|
86
|
+
|
|
87
|
+
for (const r of results.filter(Boolean)) {
|
|
88
|
+
const { rule, from, hops, final, first } = r;
|
|
89
|
+
|
|
90
|
+
if (first === 404 || first === 410 || first === 0) {
|
|
91
|
+
add('gone', `${rule.from} → ${first || final.error}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (first >= 200 && first < 300) {
|
|
95
|
+
add('notRedirecting', `${rule.from} answers ${first}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// It redirects. Does it arrive somewhere real, and in one hop?
|
|
99
|
+
if (!final.ok) {
|
|
100
|
+
add('broken', `${rule.from} → ${final.url} (${final.status || final.error})`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// hops includes the final response, so two entries is one redirect.
|
|
104
|
+
if (hops.length > 2) {
|
|
105
|
+
add('hops', `${rule.from} takes ${hops.length - 1} hops → ${final.url}`);
|
|
106
|
+
}
|
|
107
|
+
if (rule.status === 301 && first === 302) {
|
|
108
|
+
add('temporary', `${rule.from} answers 302 where the map says 301`);
|
|
109
|
+
}
|
|
110
|
+
if (rule.to && !HAS_PATTERN.test(rule.to)) {
|
|
111
|
+
let expected;
|
|
112
|
+
try {
|
|
113
|
+
expected = new URL(rule.to, origin).toString();
|
|
114
|
+
} catch {
|
|
115
|
+
expected = null;
|
|
116
|
+
}
|
|
117
|
+
if (expected && bare(expected) !== bare(final.url)) {
|
|
118
|
+
add('elsewhere', `${rule.from} → ${final.url}, map says ${rule.to}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const say = (key, level, id, title, detail) => {
|
|
124
|
+
const lines = buckets.get(key);
|
|
125
|
+
if (!lines?.length) return;
|
|
126
|
+
const shown = lines.slice(0, 3).join('; ');
|
|
127
|
+
out.push(f(level, id, title(lines.length),
|
|
128
|
+
`${shown}${lines.length > 3 ? `, and ${lines.length - 3} more` : ''}. ${detail}`, origin));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
say('gone', 'error', 'redirect-dead', (n) => `${n} old URL(s) in the redirect map are simply gone`,
|
|
132
|
+
'The rule is not in effect, so every link and every ranking pointing at these lands on nothing.');
|
|
133
|
+
say('broken', 'error', 'redirect-broken', (n) => `${n} redirect(s) land on a page that does not load`,
|
|
134
|
+
'The rule fires and then arrives nowhere, which is worse than no rule: it looks handled.');
|
|
135
|
+
say('notRedirecting', 'warn', 'redirect-not-applied', (n) => `${n} old URL(s) answer 200 instead of redirecting`,
|
|
136
|
+
'The map says these moved, and the server disagrees. Either the rule never shipped or something serves the old path.');
|
|
137
|
+
say('hops', 'warn', 'redirect-hops', (n) => `${n} redirect(s) take more than one hop`,
|
|
138
|
+
'Each hop is a round trip a visitor and a crawler both pay for. Point the first rule at the final URL.');
|
|
139
|
+
say('elsewhere', 'warn', 'redirect-elsewhere', (n) => `${n} redirect(s) land somewhere the map does not expect`,
|
|
140
|
+
'Another rule is probably matching first. The map is no longer describing what the site does.');
|
|
141
|
+
say('temporary', 'warn', 'redirect-temporary', (n) => `${n} permanent redirect(s) are served as 302`,
|
|
142
|
+
'A 302 tells Google the move is temporary, so it keeps the old URL indexed and passes less through it.');
|
|
143
|
+
|
|
144
|
+
return out;
|
|
145
|
+
}
|