← back to Dw Fleet Registry

audit-seo-inputs.mjs

88 lines

#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs/promises';

const manifest = JSON.parse(await fs.readFile(new URL('./data/seo-inputs.json', import.meta.url), 'utf8'));
const reportUrl = new URL('./tmp/seo-inputs-report.json', import.meta.url);

function parseCsv(text) {
  const rows = []; let row = []; let field = ''; let quoted = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') quoted = false;
      else field += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field.replace(/\r$/, '')); rows.push(row); row = []; field = ''; }
    else field += c;
  }
  if (field || row.length) { row.push(field.replace(/\r$/, '')); rows.push(row); }
  const headers = rows.shift() || [];
  return rows.filter(r => r.some(Boolean)).map(r => Object.fromEntries(headers.map((h, i) => [h, r[i] ?? ''])));
}

const files = {};
const failures = [];
for (const entry of manifest.files) {
  try {
    const bytes = await fs.readFile(entry.path);
    const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
    const item = { path: entry.path, bytes: bytes.length, sha256, hashMatches: sha256 === entry.sha256 };
    if (entry.path.endsWith('.csv')) {
      item.data = parseCsv(bytes.toString('utf8').replace(/^\uFEFF/, ''));
      item.rows = item.data.length;
      item.rowCountMatches = item.rows === entry.rows;
    }
    files[entry.id] = item;
    if (!item.hashMatches || item.rowCountMatches === false) failures.push(`${entry.id}: source changed`);
  } catch (error) {
    files[entry.id] = { path: entry.path, error: error.message };
    failures.push(`${entry.id}: unavailable`);
  }
}

const redirects = files.redirectsCsv?.data || [];
const recommendations = files.recommendationsCsv?.data || [];
const descriptions = files.descriptionsCsv?.data || [];
const sources = redirects.map(r => r['Redirect from']?.trim());
const targets = redirects.map(r => r['Redirect to']?.trim());
const redirectMap = new Map(sources.map((source, i) => [source, targets[i]]));
const duplicateSources = sources.filter((source, i) => sources.indexOf(source) !== i);
const selfRedirects = sources.filter((source, i) => source === targets[i]);
const cycles = [];
const chains = [];
for (const source of sources) {
  const seen = []; let cursor = source;
  while (redirectMap.has(cursor)) {
    if (seen.includes(cursor)) { cycles.push([...seen.slice(seen.indexOf(cursor)), cursor]); break; }
    seen.push(cursor); cursor = redirectMap.get(cursor);
  }
  if (seen.length > 1) chains.push({ source, hops: seen.length, destination: cursor });
}
if (duplicateSources.length) failures.push(`${duplicateSources.length} duplicate redirect sources`);
if (selfRedirects.length) failures.push(`${selfRedirects.length} self redirects`);
if (cycles.length) failures.push(`${cycles.length} redirect cycles`);
if (chains.length) failures.push(`${chains.length} redirect chains`);

const recommendationHandles = new Set(recommendations.map(r => r.Handle));
const missingDescriptionHandles = descriptions.filter(r => !recommendationHandles.has(r.handle)).map(r => r.handle);
if (missingDescriptionHandles.length) failures.push(`${missingDescriptionHandles.length} description handles absent from recommendations`);

const report = {
  $schema: 'seo-inputs-report/v1', generatedAt: new Date().toISOString(), mode: manifest.mode,
  productionWritesAllowed: manifest.productionWritesAllowed,
  summary: { files: manifest.files.length, descriptions: descriptions.length, recommendations: recommendations.length,
    redirects: redirects.length, duplicateSources: duplicateSources.length, selfRedirects: selfRedirects.length,
    redirectChains: chains.length, redirectCycles: cycles.length,
    missingDescriptionHandles: missingDescriptionHandles.length, failures: failures.length },
  files: Object.fromEntries(Object.entries(files).map(([id, f]) => [id, { path: f.path, bytes: f.bytes,
    sha256: f.sha256, hashMatches: f.hashMatches, rows: f.rows, rowCountMatches: f.rowCountMatches, error: f.error }])),
  failures
};
await fs.mkdir(new URL('./tmp/', import.meta.url), { recursive: true });
await fs.writeFile(reportUrl, JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report.summary));
if (failures.length) process.exitCode = 1;