← back to CelebritySignatures
yoloforever PIVOT (Cody unanimous KILL-the-museum-loop): stop bolting museums, build SEO surface area — the 7,211 signatures lived in ONE JS-rendered page Google couldn't crawl. Now: server-rendered crawlable /a/<QID> page per signature (JSON-LD Person schema + canonical + OG), /sitemap.xml (7,197 URLs), /robots.txt; grid card links point at /a/<QID> (JS still opens the popup, href is the SEO/no-JS fallback). Factored mergedSignatures() helper w/ 60s cache. Verified: /a/ 200 + renders server-side, sitemap 7197 locs, popup still works (TK-10181)
5d6a79e74380731f101625055de18e76053d94bc · 2026-08-04 07:12:46 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit 5d6a79e74380731f101625055de18e76053d94bc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 07:12:46 2026 -0700
yoloforever PIVOT (Cody unanimous KILL-the-museum-loop): stop bolting museums, build SEO surface area — the 7,211 signatures lived in ONE JS-rendered page Google couldn't crawl. Now: server-rendered crawlable /a/<QID> page per signature (JSON-LD Person schema + canonical + OG), /sitemap.xml (7,197 URLs), /robots.txt; grid card links point at /a/<QID> (JS still opens the popup, href is the SEO/no-JS fallback). Factored mergedSignatures() helper w/ 60s cache. Verified: /a/ 200 + renders server-side, sitemap 7197 locs, popup still works (TK-10181)
---
public/index.html | 2 +-
server.js | 118 ++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 94 insertions(+), 26 deletions(-)
diff --git a/public/index.html b/public/index.html
index 4f32ecc..28c0450 100644
--- a/public/index.html
+++ b/public/index.html
@@ -425,7 +425,7 @@ function render() {
<div class="sig"><img loading="lazy" src="${r.signature_image_url}" alt="Signature of ${r.full_name}" onerror="this.parentNode.innerHTML='<span style=color:#bbb;font-size:12px>image unavailable</span>'"></div>
<div class="meta">
<div class="name">${r.full_name}</div>
- <a class="info-chip" data-qid="${qid}" href="?artist=${qid}">${PORTRAITS[qid]?`<img class="chip-face" loading="lazy" referrerpolicy="no-referrer" src="${esc(PORTRAITS[qid])}" alt="" onerror="this.remove()">`:''}Details${evoN>1?` · ${evoN} signatures`:''}</a>
+ <a class="info-chip" data-qid="${qid}" href="/a/${qid}">${PORTRAITS[qid]?`<img class="chip-face" loading="lazy" referrerpolicy="no-referrer" src="${esc(PORTRAITS[qid])}" alt="" onerror="this.remove()">`:''}Details${evoN>1?` · ${evoN} signatures`:''}</a>
</div></div>`;
}).join('');
}
diff --git a/server.js b/server.js
index 792f6a5..9bd3e07 100644
--- a/server.js
+++ b/server.js
@@ -46,6 +46,30 @@ function readBodyBig(req) {
// which appends a commission entry to data/download-ledger.jsonl per download.
const UPLOADS_DIR = join(DATA, 'uploads-private');
const LB_HITS = new Map(); // per-IP leaderboard POST timestamps (rate limit)
+
+// Merged signatures feed (used by /api/signatures, the crawlable /a/:qid pages,
+// and the sitemap). The server-side occupation gate keeps the Artists category
+// clean regardless of what a build/merge pipeline writes to artists.json.
+const qidOf = r => (r.wikidata || '').split('/').pop();
+const ART_OCC = /(paint|sculpt|printmak|draughts|illustrat|photograph|architect|designer|engrav|\bartist\b|animat|ceramic|etcher|watercolo|muralist|lithograph|graphic|calligraph|craft|goldsmith|jewel|potter|weav|textile|cartoonist)/;
+const ART_OCC_EXCLUDE = /(fashion designer|costume designer|couturier|game designer|sound designer|web designer)/;
+const isArtOcc = o => { const s = String(o).toLowerCase(); return ART_OCC.test(s) && !ART_OCC_EXCLUDE.test(s); };
+let _sigCache = null, _sigCacheAt = 0;
+async function mergedSignatures() {
+ if (_sigCache && Date.now() - _sigCacheAt < 60000) return _sigCache; // 60s cache
+ const base = await load('celebrity_signatures.json', []);
+ const artists = await load('artists.json', []);
+ const authors = await load('authors.json', []);
+ const raw = await load('artists-raw.json', []);
+ const artistQids = new Set(raw.filter(r => (r.occupations || []).some(isArtOcc)).map(qidOf));
+ const cleanArtists = raw.length ? artists.filter(a => artistQids.has(qidOf(a))) : artists;
+ const seenQ = new Set([...base.map(qidOf), ...cleanArtists.map(qidOf)]);
+ const cleanAuthors = authors.filter(a => !seenQ.has(qidOf(a)));
+ _sigCache = [...base.filter(r => r.category !== 'Artists' && r.category !== 'Authors'), ...cleanArtists, ...cleanAuthors];
+ _sigCacheAt = Date.now();
+ return _sigCache;
+}
+const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const DEFAULT_PRICE_USD = 20; // download price (payments wired later — Steve-gated)
const DEFAULT_COMMISSION_PCT = 50; // owner's cut per download
async function adminToken() {
@@ -309,31 +333,7 @@ createServer(async (req, res) => {
// no artist double-lists with Politics. The base file owns only the
// hand-curated categories (Oldest Signatures / Declaration / Politics).
if (path === '/api/signatures' && M === 'GET') {
- const base = await load('celebrity_signatures.json', []);
- const artists = await load('artists.json', []);
- const authors = await load('authors.json', []); // generated 'Authors' set (fetch-authors.mjs)
- const raw = await load('artists-raw.json', []);
- // Occupation gate lives in the SERVER (not the data file) so the live grid
- // stays correct no matter what a build/merge pipeline writes to artists.json.
- // artists-raw.json carries each person's Wikidata occupations; keep only
- // genuine artists — museum portrait SUBJECTS (Shakespeare, Newton, Lincoln)
- // and donors get dropped. Falls back to raw as-is if raw is unavailable.
- const ART_OCC = /(paint|sculpt|printmak|draughts|illustrat|photograph|architect|designer|engrav|\bartist\b|animat|ceramic|etcher|watercolo|muralist|lithograph|graphic|calligraph|craft|goldsmith|jewel|potter|weav|textile|cartoonist)/;
- // "designer" legitimately matches fashion/costume designers (Dior) who read
- // as odd next to Van Gogh — a matched occ in this EXCLUDE set doesn't count
- // as a visual-art occupation (they still appear if they ALSO have a real one).
- const ART_OCC_EXCLUDE = /(fashion designer|costume designer|couturier|game designer|sound designer|web designer)/;
- const isArtOcc = o => { const s = String(o).toLowerCase(); return ART_OCC.test(s) && !ART_OCC_EXCLUDE.test(s); };
- const qid = r => (r.wikidata || '').split('/').pop();
- const artistQids = new Set(raw
- .filter(r => (r.occupations || []).some(isArtOcc))
- .map(qid));
- const cleanArtists = raw.length ? artists.filter(a => artistQids.has(qid(a))) : artists;
- // Authors: same single-authority rule; artists win cross-category ties.
- const seenQ = new Set([...base.map(qid), ...cleanArtists.map(qid)]);
- const cleanAuthors = authors.filter(a => !seenQ.has(qid(a)));
- const merged = [...base.filter(r => r.category !== 'Artists' && r.category !== 'Authors'), ...cleanArtists, ...cleanAuthors];
- return sendJSON(res, 200, merged, { 'Cache-Control': 'no-cache' });
+ return sendJSON(res, 200, await mergedSignatures(), { 'Cache-Control': 'no-cache' });
}
// ===== static + page routes =====
@@ -341,6 +341,74 @@ createServer(async (req, res) => {
if (path === '/murals') path = '/public/murals.html';
if (path === '/upload') path = '/public/upload-signature.html';
if (path === '/game') path = '/public/game.html';
+
+ // ===== SEO: crawlable per-signature page =====
+ // The grid is JS-rendered (Google can't index 7,000 cards). Each signature
+ // also gets a real server-rendered HTML page at /a/<QID> with JSON-LD Person
+ // schema, canonical, OG — the indexable surface the sitemap points at.
+ const am = path.match(/^\/a\/(Q\d+)$/);
+ if (am && M === 'GET') {
+ const r = (await mergedSignatures()).find(x => qidOf(x) === am[1]);
+ if (!r) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('signature not found'); return; }
+ const url = `https://celebsignatures.com/a/${am[1]}`;
+ const life = r.deceased === 'yes' ? `† ${(r.death_date || '').slice(0, 10)}` : 'living';
+ const why = (r.reason_for_ranking || '').replace('Cross-wiki notability: ', '');
+ const museums = Array.isArray(r.museums) ? r.museums : [];
+ const jsonld = {
+ '@context': 'https://schema.org', '@type': 'Person', name: r.full_name,
+ sameAs: r.wikidata || undefined,
+ image: r.signature_image_url,
+ description: `Authentic signature of ${r.full_name}${r.death_date ? ', d. ' + String(r.death_date).slice(0, 4) : ''} — ${r.category}.`,
+ };
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(r.full_name)} — signature | Celebrity Signatures</title>
+<link rel="canonical" href="${url}">
+<meta name="description" content="The authentic signature of ${esc(r.full_name)} (${esc(r.category)}${r.death_date ? ', d. ' + esc(String(r.death_date).slice(0, 4)) : ''}), traced to a real archival source.">
+<link rel="icon" type="image/png" href="/assets/favicon.png">
+<meta property="og:type" content="profile"><meta property="og:title" content="${esc(r.full_name)} — signature">
+<meta property="og:description" content="Authentic signature of ${esc(r.full_name)}, on Celebrity Signatures.">
+<meta property="og:url" content="${url}"><meta property="og:image" content="${esc(r.signature_image_url)}">
+<meta name="twitter:card" content="summary_large_image">
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;1,400&display=swap" rel="stylesheet">
+<script type="application/ld+json">${JSON.stringify(jsonld)}</script>
+<style>body{margin:0;font:16px/1.6 -apple-system,BlinkMacSystemFont,sans-serif;color:#1a1a1a;background:#f7f5f0}
+.wrap{max-width:640px;margin:0 auto;padding:30px 20px 70px}a{color:#1d7a36}
+.mast{font:italic 600 22px 'Playfair Display',serif;text-decoration:none;color:#1a1a1a}
+.sig{background:#fff;border:1px solid #e6e3dc;border-radius:12px;padding:26px;text-align:center;margin:20px 0}
+.sig img{max-width:90%;max-height:150px;object-fit:contain}
+h1{font:400 32px/1.2 'Playfair Display',serif;margin:6px 0}
+.meta{color:#6b6b6b;font-size:14px}.sec{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b6b6b;margin:18px 0 4px}
+.cta{display:inline-block;margin-top:18px;padding:11px 24px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}</style>
+</head><body><div class="wrap">
+<a class="mast" href="/">Celebrity Signatures</a>
+<div class="sig"><img src="${esc(r.signature_image_url)}" alt="Signature of ${esc(r.full_name)}"></div>
+<h1>${esc(r.full_name)}</h1>
+<div class="meta">${esc(life)} · ${esc(r.category)}${why ? ' · ' + esc(why) : ''}</div>
+${museums.length ? `<div class="sec">In the collections of</div><div class="meta">${esc(museums.join(' · '))}</div>` : ''}
+<div class="sec">License & source</div><div class="meta">${esc(r.image_license || '')} · <a href="${esc(r.wikidata)}" rel="nofollow noopener" target="_blank">Wikidata</a></div>
+<p style="margin-top:22px"><a href="/?artist=${am[1]}">See ${esc(r.full_name)} in the full gallery →</a></p>
+<a class="cta" href="/murals?name=${encodeURIComponent(r.full_name)}">Order a mural featuring this signature →</a>
+<p style="margin-top:26px"><a href="/">← Browse all ${(await mergedSignatures()).length.toLocaleString()} signatures</a></p>
+</div></body></html>`;
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' });
+ res.end(html); return;
+ }
+ if (path === '/sitemap.xml' && M === 'GET') {
+ const sigs = await mergedSignatures();
+ const B = 'https://celebsignatures.com';
+ const urls = ['/', '/game', '/murals', '/upload'].map(u => `<url><loc>${B}${u}</loc></url>`)
+ .concat(sigs.map(r => `<url><loc>${B}/a/${qidOf(r)}</loc></url>`).filter(u => /\/a\/Q\d+/.test(u)));
+ res.writeHead(200, { 'Content-Type': 'application/xml' });
+ res.end(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`);
+ return;
+ }
+ if (path === '/robots.txt' && M === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('User-agent: *\nAllow: /\nSitemap: https://celebsignatures.com/sitemap.xml\n');
+ return;
+ }
+
if (path.startsWith('/assets/')) path = '/public' + path;
if (path === '/favicon.ico') path = '/public/assets/favicon.png';
if (path === '/account.js') path = '/public/account.js';
← 248c7da World-collections expansion: +12 major museums (Hermitage, B
·
back to CelebritySignatures
·
Stripe Checkout for mural orders (Steve APPROVED, TEST-mode) fc549c4 →