[object Object]

← back to Dw Fleet Registry

Integrate staged collection SEO inputs into overnight audit

eb7b964329870654f1697bb0def05332de67c0ed · 2026-08-31 02:15:04 -0700 · Steve Abrams

Files touched

Diff

commit eb7b964329870654f1697bb0def05332de67c0ed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 02:15:04 2026 -0700

    Integrate staged collection SEO inputs into overnight audit
---
 README.md                   |  7 ++++
 audit-seo-inputs.mjs        | 87 +++++++++++++++++++++++++++++++++++++++++++++
 data/seo-inputs.json        | 13 +++++++
 package.json                |  1 +
 run-search-audit-loop.sh    |  2 +-
 verification/e2e-proof.json | 34 ++++++++++++++++++
 6 files changed, 143 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index f2ff4d2..7edc905 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ clusters** (the real consolidation candidates).
 ```bash
 node build-registry.mjs        # re-scan both fleets, rewrite dw-fleet-registry.json
 npm run audit:search           # verify robots, sitemap, and llms.txt on registered public sites
+npm run audit:seo-inputs       # validate the six staged collection/redirect source files
 npm run audit:search:overnight # recheck every 15 minutes for 12 hours
 ```
 
@@ -29,6 +30,12 @@ producers that matter to discovery. `gated-publisher` entries are inventory only
 the audit never starts them or calls a publishing path. Results are written to
 `tmp/search-discovery-report.json` for SEO/AEO monitoring.
 
+The collection-description, recommendation, and 301 artifacts are registered in
+`data/seo-inputs.json`. The overnight loop verifies their hashes and row counts,
+rejects redirect duplicates/chains/cycles, and cross-checks description handles.
+It is deliberately audit-only (`productionWritesAllowed: false`); it does not
+write to Shopify.
+
 Read-only scan; re-runnable. Same inputs → same registry (plus a fresh timestamp).
 
 ## Output: `dw-fleet-registry.json`
diff --git a/audit-seo-inputs.mjs b/audit-seo-inputs.mjs
new file mode 100644
index 0000000..2d88622
--- /dev/null
+++ b/audit-seo-inputs.mjs
@@ -0,0 +1,87 @@
+#!/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;
diff --git a/data/seo-inputs.json b/data/seo-inputs.json
new file mode 100644
index 0000000..5ca3d9c
--- /dev/null
+++ b/data/seo-inputs.json
@@ -0,0 +1,13 @@
+{
+  "$schema": "seo-inputs/v1",
+  "mode": "audit-only",
+  "productionWritesAllowed": false,
+  "files": [
+    { "id": "descriptionsCsv", "path": "/Users/macstudio3/Downloads/collection-descriptions-60.csv", "sha256": "243a626f1aebcacc80dea97340e5f6ee2b0324d2a6029671436e5b8c4e5c2152", "rows": 60 },
+    { "id": "descriptionsPaste", "path": "/Users/macstudio3/Downloads/collection-descriptions-60-paste.md", "sha256": "a300d269a8e996509580fb4f73959c04764488d8f37d06e664a10d7161c1830d" },
+    { "id": "redirectMap", "path": "/Users/macstudio3/Downloads/shopify-301-map.md", "sha256": "989e118bf9086bf4b71f346e120ae1ffd538f5d4476eeb4235eb828bd40c7a66" },
+    { "id": "redirectsCsv", "path": "/Users/macstudio3/Downloads/shopify-url-redirects.csv", "sha256": "4f01e2cbd87cef21c92c7bdb5ad767bcb42a23f4c06efbbd8df7f547139aea88", "rows": 136 },
+    { "id": "recommendationsMd", "path": "/Users/macstudio3/Downloads/collection-seo-recommendations.md", "sha256": "8b839c1128d422874d3694545f9ea0aeb26a023c95a4c3c8e34270e01a39ad26" },
+    { "id": "recommendationsCsv", "path": "/Users/macstudio3/Downloads/collection-seo-recommendations.csv", "sha256": "2a64869aed75aa2d74c2513582f128aad9a0f7c2c435df5ba49bec66e5564251", "rows": 566 }
+  ]
+}
diff --git a/package.json b/package.json
index a45ac8c..ca9f7f8 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
   "scripts": {
     "build": "node build-registry.mjs",
     "audit:search": "node audit-search-discovery.mjs",
+    "audit:seo-inputs": "node audit-seo-inputs.mjs",
     "audit:search:overnight": "zsh run-search-audit-loop.sh"
   },
   "optionalDependencies": {
diff --git a/run-search-audit-loop.sh b/run-search-audit-loop.sh
index 9df45c4..e656369 100755
--- a/run-search-audit-loop.sh
+++ b/run-search-audit-loop.sh
@@ -11,7 +11,7 @@ cd "$ROOT" || exit 1
 print -r -- "$(date -u +%FT%TZ) loop-start interval=${INTERVAL_SECONDS}s duration=${DURATION_SECONDS}s" >> "$LOG"
 
 while (( EPOCHSECONDS < DEADLINE )); do
-  if node audit-search-discovery.mjs >> "$LOG" 2>&1; then
+  if node audit-search-discovery.mjs >> "$LOG" 2>&1 && node audit-seo-inputs.mjs >> "$LOG" 2>&1; then
     print -r -- "$(date -u +%FT%TZ) audit-pass" >> "$LOG"
   else
     print -r -- "$(date -u +%FT%TZ) audit-fail" >> "$LOG"
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
new file mode 100644
index 0000000..b1c0559
--- /dev/null
+++ b/verification/e2e-proof.json
@@ -0,0 +1,34 @@
+{
+  "intent": "Keep the restarted public sites discoverable by conventional and AI search while never activating the gated X publisher.",
+  "riskTier": "R2",
+  "environment": "local PM2 on macstudio3",
+  "timestamp": "2026-08-31T09:09:29Z",
+  "builds": {
+    "quadrille-house-site": "6c6b2d8",
+    "CelebritySignatures": "19ad3e6",
+    "dw-fleet-registry": "babde29"
+  },
+  "baseline": "Both public roots were reported online; Quadrille robots.txt and sitemap.xml returned 404 before the change.",
+  "commands": [
+    "pm2 restart quadrille-house-site CelebritySignatures",
+    "curl GET /, /robots.txt, /sitemap.xml, /llms.txt on ports 9943 and 9956",
+    "node audit-search-discovery.mjs",
+    "node audit-seo-inputs.mjs",
+    "boundary assertion against tmp/search-discovery-report.json"
+  ],
+  "assertions": [
+    { "boundary": "quadrille HTTP", "verdict": "PASS", "detail": "root, robots, sitemap, and llms.txt returned 200" },
+    { "boundary": "CelebritySignatures HTTP", "verdict": "PASS", "detail": "root, robots, sitemap, and llms.txt returned 200" },
+    { "boundary": "registry consumer", "verdict": "PASS", "detail": "2 public properties ready; 0 incomplete" },
+    { "boundary": "SEO source files", "verdict": "PASS", "detail": "6 files matched hashes; 60 descriptions, 566 recommendations, and 136 redirects parsed" },
+    { "boundary": "redirect safety", "verdict": "PASS", "detail": "0 duplicate sources, self redirects, chains, or cycles; all description handles matched recommendations" },
+    { "boundary": "external side effect", "verdict": "PASS", "detail": "animate-museum-posts remained not-probed with explicit-approval-required; no start or publish call exists in the audit" }
+  ],
+  "negativeChecks": [
+    "gated-publisher entries are excluded from HTTP probing",
+    "audit exits nonzero when any registered discovery endpoint is unavailable",
+    "SEO input audit exits nonzero on file drift, row-count drift, unsafe redirect topology, or orphaned description handles"
+  ],
+  "cleanup": "No test records or external posts created. PM2 services intentionally retained online.",
+  "overallVerdict": "PASS"
+}

← babde29 Keep discovery audit output ephemeral  ·  back to Dw Fleet Registry  ·  Fix overnight audit clock in noninteractive shells c59c9f5 →