@nurkamol/seo-audit 1.39.0 → 1.40.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/package.json +1 -1
- package/src/http.mjs +22 -5
- package/src/score.mjs +26 -0
- package/src/site.mjs +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurkamol/seo-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/http.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export class Fetcher {
|
|
|
81
81
|
* @returns {Promise<{url: string, status: number, ok: boolean, headers: Headers,
|
|
82
82
|
* body: string, location: string|null, ms: number, error?: string}>}
|
|
83
83
|
*/
|
|
84
|
-
async get(url, { method = 'GET', retries = 2 } = {}) {
|
|
84
|
+
async get(url, { method = 'GET', retries = 2, keepBody = true } = {}) {
|
|
85
85
|
const key = `${method} ${url}`;
|
|
86
86
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
87
87
|
|
|
@@ -171,7 +171,24 @@ export class Fetcher {
|
|
|
171
171
|
return last;
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// The caller is handed the whole response; the cache may keep less.
|
|
175
|
+
//
|
|
176
|
+
// The cache is here so a URL is never fetched twice across checks, and for
|
|
177
|
+
// most of what a run fetches that means remembering a status and a
|
|
178
|
+
// content-type — the link sweep, the image sweep and the social-image
|
|
179
|
+
// sweeps all judge on those alone. Keeping every body as well made the
|
|
180
|
+
// cache the largest live object in the process: 403 requests on one 25-page
|
|
181
|
+
// site held 65MB, of which 18MB was still reachable once the run let go.
|
|
182
|
+
// That is under Node's default heap and over Raycast's, whose commands get
|
|
183
|
+
// 100MB and were dying on sites this tool audits comfortably from a
|
|
184
|
+
// terminal.
|
|
185
|
+
//
|
|
186
|
+
// So a sweep asks for `keepBody: false` and the body is dropped on the way
|
|
187
|
+
// into the cache, never on the way out — whoever fetched it reads it in
|
|
188
|
+
// full, and only a *second* reader of the same URL sees an empty body. The
|
|
189
|
+
// page crawl, robots.txt, the home page and the host checks all keep
|
|
190
|
+
// theirs, because something does read those twice.
|
|
191
|
+
this.cache.set(key, keepBody ? promise : promise.then((res) => ({ ...res, body: '' })));
|
|
175
192
|
return promise;
|
|
176
193
|
}
|
|
177
194
|
|
|
@@ -200,16 +217,16 @@ export class Fetcher {
|
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
/** Follow a chain by hand so the number of hops can be reported. */
|
|
203
|
-
async chain(url, max = 5) {
|
|
220
|
+
async chain(url, max = 5, { keepBody = true } = {}) {
|
|
204
221
|
const hops = [];
|
|
205
222
|
let current = url;
|
|
206
223
|
for (let i = 0; i < max; i++) {
|
|
207
|
-
const res = await this.get(current);
|
|
224
|
+
const res = await this.get(current, { keepBody });
|
|
208
225
|
hops.push({ url: current, status: res.status });
|
|
209
226
|
if (res.status < 300 || res.status >= 400 || !res.location) return { hops, final: res };
|
|
210
227
|
current = new URL(res.location, current).toString();
|
|
211
228
|
}
|
|
212
|
-
return { hops, final: await this.get(current) };
|
|
229
|
+
return { hops, final: await this.get(current, { keepBody }) };
|
|
213
230
|
}
|
|
214
231
|
}
|
|
215
232
|
|
package/src/score.mjs
CHANGED
|
@@ -349,6 +349,8 @@ export function scoreRun(findings, { pages = 0, applicable = {} } = {}) {
|
|
|
349
349
|
area: categoryOf(r.id),
|
|
350
350
|
pass: r.check.pass,
|
|
351
351
|
why: WHY_SKIPPED[r.check.needs] ?? 'Not applicable to this run.',
|
|
352
|
+
// Absent when nothing can be pressed, which is most of them.
|
|
353
|
+
...(ENABLED_BY[r.check.needs] ? { enabledBy: ENABLED_BY[r.check.needs] } : {}),
|
|
352
354
|
}));
|
|
353
355
|
|
|
354
356
|
return {
|
|
@@ -394,6 +396,30 @@ const WHY_SKIPPED = {
|
|
|
394
396
|
hosts: 'The rest of the domain was not enumerated — run with --hosts.',
|
|
395
397
|
};
|
|
396
398
|
|
|
399
|
+
/** The option that would have let a skipped check run, where one exists.
|
|
400
|
+
*
|
|
401
|
+
* A skip has two very different causes and only one of them is anybody's to
|
|
402
|
+
* fix. "No page declares hreflang" is a fact about the site — there is nothing
|
|
403
|
+
* to press. "Outbound links were not checked" is a run that was not asked to,
|
|
404
|
+
* and asking is one flag away.
|
|
405
|
+
*
|
|
406
|
+
* Saying which is which here rather than in each front end, because the engine
|
|
407
|
+
* is what decided the check was skipped and a client re-deriving that from the
|
|
408
|
+
* prose in WHY_SKIPPED would be parsing an English sentence for a flag name.
|
|
409
|
+
* A front end that can offer the re-run offers it; one that cannot ignores
|
|
410
|
+
* this, and nothing about the report changes.
|
|
411
|
+
*
|
|
412
|
+
* Only the reasons a *run* controls appear. `redirects` and `compareAs` are
|
|
413
|
+
* deliberately absent even though both are flags: one needs a file that only
|
|
414
|
+
* the person who did the migration has, and the other needs a second identity
|
|
415
|
+
* to fetch as. Offering a button that cannot be pressed without an argument
|
|
416
|
+
* is worse than offering nothing. */
|
|
417
|
+
export const ENABLED_BY = {
|
|
418
|
+
external: '--check-external',
|
|
419
|
+
hosts: '--hosts',
|
|
420
|
+
psi: '--psi',
|
|
421
|
+
};
|
|
422
|
+
|
|
397
423
|
/** The same sum, once per area, so "where is this site weak" is answerable
|
|
398
424
|
* without reading the list. Areas with nothing applicable are absent rather
|
|
399
425
|
* than shown at 100, which would read as a clean bill of health. */
|
package/src/site.mjs
CHANGED
|
@@ -379,7 +379,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
|
|
|
379
379
|
const targets = all.slice(0, limit);
|
|
380
380
|
opts.onProgress?.({ phase: 'links', detail: `${targets.length} distinct targets to check` });
|
|
381
381
|
const results = await mapLimit(targets, 6, async (target) => {
|
|
382
|
-
const res = await fetcher.get(target);
|
|
382
|
+
const res = await fetcher.get(target, { keepBody: false });
|
|
383
383
|
opts.onProgress?.({ phase: 'links', status: res.status, ms: res.ms, url: target });
|
|
384
384
|
const type = res.headers.get('content-type') ?? '';
|
|
385
385
|
// A third question the same response answers — and the only place it can be
|
|
@@ -499,9 +499,9 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
|
|
|
499
499
|
const imageTargets = [...imageSources.values()].slice(0, imageLimit).map((entry) => entry.src);
|
|
500
500
|
opts.onProgress?.({ phase: 'images', detail: `${imageTargets.length} distinct images to check` });
|
|
501
501
|
const imageResults = await mapLimit(imageTargets, 6, async (src) => {
|
|
502
|
-
let res = await fetcher.get(src, { method: 'HEAD' });
|
|
502
|
+
let res = await fetcher.get(src, { method: 'HEAD', keepBody: false });
|
|
503
503
|
// Some hosts answer HEAD with 405 or 501 and serve the file perfectly well.
|
|
504
|
-
if (res.status === 405 || res.status === 501) res = await fetcher.get(src);
|
|
504
|
+
if (res.status === 405 || res.status === 501) res = await fetcher.get(src, { keepBody: false });
|
|
505
505
|
opts.onProgress?.({ phase: 'images', status: res.status, ms: res.ms, url: src });
|
|
506
506
|
return { src, status: res.status, error: res.error };
|
|
507
507
|
});
|
|
@@ -590,8 +590,8 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
|
|
|
590
590
|
}
|
|
591
591
|
const schemaTargets = [...schemaImages.keys()].slice(0, opts.maxImageChecks ?? 200);
|
|
592
592
|
const schemaResults = await mapLimit(schemaTargets, 4, async (href) => {
|
|
593
|
-
let res = await fetcher.get(href, { method: 'HEAD' });
|
|
594
|
-
if (res.status === 405 || res.status === 501) res = await fetcher.get(href);
|
|
593
|
+
let res = await fetcher.get(href, { method: 'HEAD', keepBody: false });
|
|
594
|
+
if (res.status === 405 || res.status === 501) res = await fetcher.get(href, { keepBody: false });
|
|
595
595
|
return { href, status: res.status, error: res.error };
|
|
596
596
|
});
|
|
597
597
|
const deadSchemaImages = schemaResults.filter(
|
|
@@ -733,7 +733,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
|
|
|
733
733
|
// Conservative about what counts as broken, for the same reason as the image
|
|
734
734
|
// sweep: 403 is hotlink protection working, not a missing file.
|
|
735
735
|
const ogResults = await mapLimit([...ogImages.keys()], 4, async (src) => {
|
|
736
|
-
const { final } = await fetcher.chain(src);
|
|
736
|
+
const { final } = await fetcher.chain(src, 5, { keepBody: false });
|
|
737
737
|
return { src, final };
|
|
738
738
|
});
|
|
739
739
|
for (const { src, final } of ogResults) {
|
|
@@ -768,7 +768,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
|
|
|
768
768
|
twitterImages.set(src, page.url);
|
|
769
769
|
}
|
|
770
770
|
const twitterResults = await mapLimit([...twitterImages.keys()], 4, async (src) => {
|
|
771
|
-
const { final } = await fetcher.chain(src);
|
|
771
|
+
const { final } = await fetcher.chain(src, 5, { keepBody: false });
|
|
772
772
|
return { src, final };
|
|
773
773
|
});
|
|
774
774
|
for (const { src, final } of twitterResults) {
|