← 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
M README.mdA audit-seo-inputs.mjsA data/seo-inputs.jsonM package.jsonM run-search-audit-loop.shA verification/e2e-proof.json
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 →