@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurkamol/seo-audit",
3
- "version": "1.39.0",
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
- this.cache.set(key, promise);
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) {