[object Object]

← back to Paul Conrad Cartoons

inkwell: link Shadow Man pieces to P24 articles (TK-12247)

96c2df5e8edc178599daf22dd1ea0ba63317ad15 · 2026-09-25 11:47:06 -0700 · Steve

- scripts/match-shadowman-articles.mjs: keyword shortlist + local-model judge ($0, ollama/mlx),
  links only score>=4 picks from the shortlist; NOT-MEASURED (exit 3, no writes) if no model responds
- GET /api/stories (P24 extract_stories.mjs, read-only) + POST /api/shadowman/link (loopback-only)
- Shadow Man cards show the linked article + fit, Link/Change/Unlink with an in-page search picker
- export writes cartoons/shadowman-*.html from P24's shared page template + idempotent
  shadowman-* manifest.js entries (other entries round-trip byte-identical)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq

Files touched

Diff

commit 96c2df5e8edc178599daf22dd1ea0ba63317ad15
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 11:47:06 2026 -0700

    inkwell: link Shadow Man pieces to P24 articles (TK-12247)
    
    - scripts/match-shadowman-articles.mjs: keyword shortlist + local-model judge ($0, ollama/mlx),
      links only score>=4 picks from the shortlist; NOT-MEASURED (exit 3, no writes) if no model responds
    - GET /api/stories (P24 extract_stories.mjs, read-only) + POST /api/shadowman/link (loopback-only)
    - Shadow Man cards show the linked article + fit, Link/Change/Unlink with an in-page search picker
    - export writes cartoons/shadowman-*.html from P24's shared page template + idempotent
      shadowman-* manifest.js entries (other entries round-trip byte-identical)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq
---
 public/index.html                    |  14 +++
 public/shadowman.js                  | 102 ++++++++++++++++-
 public/style.css                     |  26 +++++
 scripts/export-p24-shadowman.mjs     |  88 +++++++++++++-
 scripts/lib-stories.mjs              |  61 ++++++++++
 scripts/match-shadowman-articles.mjs | 216 +++++++++++++++++++++++++++++++++++
 server.js                            |  68 +++++++++++
 7 files changed, 568 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 084d049..bb866e5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -185,6 +185,20 @@
     <div class="modal" id="modal-body"></div>
   </div>
 
+  <div class="confirm-backdrop" id="link-backdrop">
+    <div class="confirm-box link-box" role="dialog" aria-modal="true" aria-labelledby="link-title" aria-describedby="link-for">
+      <h2 id="link-title">Link a P24 article</h2>
+      <p id="link-for"></p>
+      <label for="link-q">Search stories</label>
+      <input type="search" id="link-q" class="link-q" autocomplete="off" placeholder="Headline, tag or source">
+      <p class="link-status" id="link-status" aria-live="polite"></p>
+      <ul class="link-results" id="link-results"></ul>
+      <div class="confirm-actions">
+        <button type="button" class="big-btn plain" id="link-cancel">Cancel</button>
+      </div>
+    </div>
+  </div>
+
   <div class="confirm-backdrop" id="confirm-backdrop">
     <div class="confirm-box" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-msg">
       <h2 id="confirm-title">Please confirm</h2>
diff --git a/public/shadowman.js b/public/shadowman.js
index 3cc5bca..caeca93 100644
--- a/public/shadowman.js
+++ b/public/shadowman.js
@@ -47,10 +47,107 @@
         <div class="caption">${esc(it.caption)}</div>
         <div class="badges"><span class="topic-tag sm-theme">${esc(it.theme)}</span><span class="badge sm-era">${esc(it.era)}</span></div>
       </div>
+      ${articleBlock(it)}
       ${ctl.actions}
     </article>`;
   }
 
+  // Linked P24 article (TK-12247): headline link + fit score, or "No article linked", plus Link / Unlink.
+  function storyHref(url) { return /^https?:/i.test(url || '') ? url : P24_BASE + String(url || ''); }
+  function articleBlock(it) {
+    if (it.status === 'deleted') return '';
+    const linked = !!it.story_id;
+    const fit = it.match_by === 'manual' ? 'linked by hand' : (it.match_score ? `fit ${it.match_score}/5 · auto` : 'auto');
+    const info = linked
+      ? `<div class="sm-article"><span class="ar-label">Article</span><a href="${esc(storyHref(it.story_url))}" target="_blank" rel="noopener noreferrer">${esc(it.story_title)}</a><span class="sm-fit">${esc(it.story_source || '')} · ${esc(fit)}</span>${it.match_reason && it.match_by !== 'manual' ? `<span class="sm-reason">${esc(it.match_reason)}</span>` : ''}</div>`
+      : `<div class="sm-article none"><span class="ar-label">Article</span>No article linked</div>`;
+    return `${info}<div class="sm-link-actions"><button type="button" class="sm-link-btn link" data-link="pick" data-id="${esc(it.id)}">${linked ? 'Change article' : 'Link article'}</button>${linked ? `<button type="button" class="sm-link-btn unlink" data-link="unlink" data-id="${esc(it.id)}">Unlink</button>` : ''}</div>`;
+  }
+
+  // ---- Link picker (in-page dialog; never window.confirm/alert) ----
+  let STORIES = null;
+  let P24_BASE = 'http://127.0.0.1:9934/';
+  let pickFor = null;
+  let pickOpener = null;
+  async function loadStories() {
+    if (STORIES) return STORIES;
+    const r = await fetch('/api/stories');
+    if (!r.ok) throw new Error('HTTP ' + r.status);
+    const doc = await r.json();
+    P24_BASE = doc.p24_base || P24_BASE;
+    STORIES = doc.items || [];
+    return STORIES;
+  }
+  function renderPicks() {
+    const q = document.getElementById('link-q').value.trim().toLowerCase();
+    const words = q ? q.split(/\s+/) : [];
+    const it = ITEMS.find(x => x.id === pickFor) || {};
+    const list = (STORIES || []).filter(s => { const hay = [s.id, s.headline, s.summary, s.source_label, ...(s.tags || [])].join(' ').toLowerCase(); return words.every(w => hay.includes(w)); });
+    document.getElementById('link-status').textContent = `${list.length} of ${(STORIES || []).length} stories`;
+    document.getElementById('link-results').innerHTML = list.slice(0, 60).map(s => `<li><button type="button" class="link-pick${s.id === it.story_id ? ' current' : ''}" data-story="${esc(s.id)}">${esc(s.headline)}<span class="lp-meta">${esc(s.source_label)} · ${esc((s.tags || []).join(', '))}${s.has_cartoon ? ' · already has a cartoon' : ''}${s.id === it.story_id ? ' · current link' : ''}</span></button></li>`).join('');
+  }
+  function closePicker() {
+    document.getElementById('link-backdrop').classList.remove('open');
+    document.removeEventListener('keydown', pickerKeys, true);
+    if (pickOpener && document.body.contains(pickOpener)) pickOpener.focus();
+    else { const b = document.querySelector(`.sm-card[data-id="${CSS.escape(pickFor || '')}"] .sm-link-btn`); if (b) b.focus(); }
+    pickFor = null;
+  }
+  function pickerKeys(e) {
+    if (e.key === 'Escape') { e.stopPropagation(); closePicker(); return; }
+    if (e.key === 'Tab') {
+      const f = [...document.querySelectorAll('#link-backdrop input, #link-backdrop button')];
+      const i = f.indexOf(document.activeElement);
+      if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
+      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
+    }
+  }
+  async function openPicker(id, opener) {
+    pickFor = id; pickOpener = opener;
+    const it = ITEMS.find(x => x.id === id);
+    document.getElementById('link-for').textContent = `For “${it ? it.title : id}”. Pick the story this cartoon comments on.`;
+    const q = document.getElementById('link-q');
+    q.value = '';
+    document.getElementById('link-backdrop').classList.add('open');
+    document.addEventListener('keydown', pickerKeys, true);
+    q.focus();
+    document.getElementById('link-status').textContent = 'Loading stories…';
+    try { await loadStories(); renderPicks(); }
+    catch (err) { document.getElementById('link-status').textContent = 'Could not load stories — ' + err.message; }
+  }
+  async function postLink(id, storyId) {
+    const r = await fetch('/api/shadowman/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, story_id: storyId }) });
+    const body = await r.json().catch(() => ({}));
+    if (!r.ok) { document.getElementById('sm-count').textContent = 'Link failed — ' + (body.error || 'HTTP ' + r.status); return false; }
+    ITEMS = body.items || ITEMS;
+    render();
+    const b = document.querySelector(`.sm-card[data-id="${CSS.escape(id)}"] .sm-link-btn`);
+    if (b) b.focus();
+    return true;
+  }
+  function wirePicker() {
+    document.getElementById('link-q').addEventListener('input', renderPicks);
+    document.getElementById('link-cancel').addEventListener('click', closePicker);
+    document.getElementById('link-backdrop').addEventListener('click', (e) => { if (e.target.id === 'link-backdrop') closePicker(); });
+    document.getElementById('link-results').addEventListener('click', async (e) => {
+      const b = e.target.closest('.link-pick');
+      if (!b || !pickFor) return;
+      const id = pickFor;
+      document.getElementById('link-backdrop').classList.remove('open');
+      document.removeEventListener('keydown', pickerKeys, true);
+      pickFor = null;
+      await postLink(id, b.dataset.story);
+    });
+    document.getElementById('sm-grid').addEventListener('click', (e) => {
+      const b = e.target.closest('.sm-link-btn');
+      if (!b) return;
+      e.stopPropagation();
+      if (b.dataset.link === 'unlink') postLink(b.dataset.id, null);
+      else openPicker(b.dataset.id, b);
+    });
+    document.getElementById('sm-grid').addEventListener('keydown', (e) => { if (e.target.closest('.sm-link-btn, .sm-article a')) e.stopPropagation(); });
+  }
+
   function fillSelect(el, values, current, allLabel) {
     el.innerHTML = `<option value="all">${esc(allLabel)}</option>` + values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
     el.value = values.includes(current) ? current : 'all';
@@ -69,7 +166,7 @@
     const grid = document.getElementById('sm-grid');
     grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
     grid.querySelectorAll('.sm-card').forEach(c => {
-      c.addEventListener('click', (e) => { if (!e.target.closest('.curate-top, .card-actions')) open(c.dataset.id); });
+      c.addEventListener('click', (e) => { if (!e.target.closest('.curate-top, .card-actions, .sm-article, .sm-link-actions')) open(c.dataset.id); });
       c.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === c) open(c.dataset.id); });
     });
     if (curate) curate.syncBar();
@@ -95,6 +192,7 @@
     const eraEl = document.getElementById('sm-era');
     const sortEl = document.getElementById('sm-sort');
     const densEl = document.getElementById('sm-density');
+    wirePicker();
     const delEl = document.getElementById('sm-show-deleted');
     delEl.checked = !!state.deleted;
     delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
@@ -121,6 +219,6 @@
 
   fetch('/api/shadowman')
     .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
-    .then(doc => { ITEMS = doc.items || []; init(); render(); })
+    .then(doc => { ITEMS = doc.items || []; init(); return loadStories().catch(() => null).then(() => render()); })
     .catch(err => { document.getElementById('sm-grid').innerHTML = `<div class="empty-note">Could not load Shadow Man cartoons — ${esc(err.message)}</div>`; });
 })();
diff --git a/public/style.css b/public/style.css
index 191cb2e..1d1bffc 100644
--- a/public/style.css
+++ b/public/style.css
@@ -542,3 +542,29 @@ body {
 .confirm-actions { display: flex; gap: 10px; }
 .confirm-actions .big-btn { flex: 1; }
 @media (max-width: 560px) { .bulk-bar { top: 120px; margin: 0 10px 10px; } .bulk-bar .big-btn { flex: 1 1 45%; } }
+
+/* ---- Article links (TK-12247): linked P24 story on each Shadow Man card + the link picker ---- */
+.sm-article { padding: 8px 10px; border-top: 1px solid var(--line); font-size: 13px; line-height: 1.4; }
+.sm-article .ar-label { margin-bottom: 2px; }
+.sm-article a { color: var(--ink); font-weight: 700; }
+.sm-article .sm-fit { display: block; font-size: 12px; color: var(--ink-soft); }
+.sm-article .sm-reason { display: block; font-size: 12px; color: var(--ink-soft); font-style: italic; }
+.sm-article.none { color: var(--ink-soft); font-style: italic; }
+.sm-link-actions { display: flex; gap: 8px; padding: 0 10px 10px; }
+.sm-link-btn {
+  flex: 1; min-height: 48px; padding: 10px 14px; font: 700 14px/1.2 "Georgia", serif;
+  border-radius: var(--radius); border: 2px solid var(--ink); background: var(--panel-bg); color: var(--ink); cursor: pointer;
+}
+.sm-link-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+.sm-link-btn.unlink { border-color: var(--accent); color: var(--accent); }
+.link-box { max-width: 640px; }
+.link-box label { display: block; font-size: 12px; color: var(--ink-soft); margin-bottom: 4px; }
+.link-q { width: 100%; min-height: 48px; padding: 8px 12px; font-size: 16px; border: 2px solid var(--ink); border-radius: var(--radius); background: var(--bg); color: var(--ink); box-sizing: border-box; }
+.link-q:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+.link-status { font-size: 12px; color: var(--ink-soft); margin: 6px 0; }
+.link-results { list-style: none; margin: 0 0 12px; padding: 0; max-height: 50vh; overflow-y: auto; }
+.link-results li { margin: 0 0 6px; }
+.link-pick { display: block; width: 100%; min-height: 48px; text-align: left; padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-bg); color: var(--ink); font: 14px/1.35 "Georgia", serif; cursor: pointer; }
+.link-pick:hover, .link-pick:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); }
+.link-pick .lp-meta { display: block; font-size: 11.5px; color: var(--ink-soft); }
+.link-pick.current { border-color: #1f7a3d; }
diff --git a/scripts/export-p24-shadowman.mjs b/scripts/export-p24-shadowman.mjs
index e5179a9..3041cd0 100644
--- a/scripts/export-p24-shadowman.mjs
+++ b/scripts/export-p24-shadowman.mjs
@@ -3,19 +3,55 @@
 // Usage: node scripts/export-p24-shadowman.mjs [--dest=~/Projects/crazy-news-channel] [--test-include-pending]
 // Reads data/shadowman.json, keeps ONLY status === 'approved' (Steve approves in Inkwell), copies each
 // jpg to <dest>/cartoons/shadow-man/<id>.jpg, removes stale jpgs there, and writes
-// <dest>/cartoons/shadow-man-data.js (window.P24_SHADOW_MAN). Never touches manifest.js / the daily
-// queue / the Shorts pipeline. --test-include-pending exports pending pieces too; it exists only for
-// verification on a scratch copy and must never be used against the real P24 checkout.
+// <dest>/cartoons/shadow-man-data.js (window.P24_SHADOW_MAN).
+// TK-12247: every exported piece WITH a story_id (linked in Inkwell / by scripts/match-shadowman-articles.mjs)
+// also gets a single-image cartoon page <dest>/cartoons/shadowman-<date>-<n>.html rendered from the P24
+// repo's shared daily-cartoons/cartoon-page.template.html (the same template approve.py uses) and a
+// manifest.js entry {id:"shadowman-…", story_id, …} so index.html shows it on that article's card.
+// Idempotent: each run rewrites ONLY manifest entries whose id starts with "shadowman-" (drops the ones
+// no longer linked / approved, e.g. after an unlink) and deletes stale cartoons/shadowman-*.html pages;
+// every other manifest entry is left byte-for-byte as it was. Never touches the daily queue / Shorts.
+// --test-include-pending exports pending pieces too; it exists only for verification on a scratch copy
+// and must never be used against the real P24 checkout. --dest may also be given as P24_DIR.
 import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { loadStories, storyCategories } from './lib-stories.mjs';
 
 const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
 const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]; }));
-const DEST = path.resolve((args.dest || '~/Projects/crazy-news-channel').replace(/^~/, process.env.HOME));
+const DEST = path.resolve((args.dest || process.env.P24_DIR || '~/Projects/crazy-news-channel').replace(/^~/, process.env.HOME));
 const NAME = new RegExp(['con', 'rad'].join(''), 'i');
 const ok = (s) => s === 'approved' || (args['test-include-pending'] && s === 'pending');
 
+// ---- Article pages + manifest entries (TK-12247) ----
+const STORIES = new Map(loadStories(DEST).map(st => [st.id, st]));
+const CATS = storyCategories(DEST);
+const CART = path.join(DEST, 'cartoons');
+const pageId = (id) => 'shadowman-' + String(id).replace(/^shadowman-/, '').replace(/^(\d{8})T\d{6}-/, '$1-');
+// A stored story_url is either absolute (real-news outlet link) or P24-site-root-relative; pages live in cartoons/.
+const fromCartoons = (u) => (/^https?:/i.test(u) ? u : '../' + u);
+const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;');
+const unquote = (s) => String(s || '').trim().replace(/^["“]+|["”]+$/g, '').trim();
+function linkedStory(i) {
+  if (!i.story_id) return null;
+  const st = STORIES.get(i.story_id);
+  if (!st) { console.error(`WARN: ${i.id} links story_id "${i.story_id}" which is not in the P24 story list — page/manifest entry skipped`); return null; }
+  return st;
+}
+function articleFields(i) {
+  const st = linkedStory(i);
+  if (!st) return {};
+  return { story_id: st.id, story_title: i.story_title || st.headline, story_url: fromCartoons(i.story_url || (st.sourceUrl || `index.html#/article/${encodeURIComponent(st.id)}`)), page: `${pageId(i.id)}.html` };
+}
+function renderPage(fields) {
+  const tpl = fs.readFileSync(path.join(DEST, 'daily-cartoons', 'cartoon-page.template.html'), 'utf8').replace(/\n+$/, '');
+  return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => { if (!(k in fields)) throw new Error('template placeholder without value: ' + k); return fields[k]; });
+}
+// Python json.dumps(indent=2) (ensure_ascii) == JSON.stringify(indent 2) with non-ASCII as lowercase \uXXXX,
+// so entries we do not own round-trip byte-for-byte.
+const pyJson = (v) => JSON.stringify(v, null, 2).replace(/[\u007f-￿]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
+
 const doc = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'shadowman.json'), 'utf8'));
 const pick = doc.items.filter(i => ok(i.status || 'pending'));
 const imgDir = path.join(DEST, 'cartoons', 'shadow-man');
@@ -25,11 +61,53 @@ const items = pick.map(i => {
   const f = `${i.id}.jpg`;
   fs.copyFileSync(path.join(ROOT, 'public', 'shadowman', f), path.join(imgDir, f));
   keep.add(f);
-  return { id: i.id, title: i.title, caption: i.caption, theme: i.theme, era: i.era, created_at: i.created_at, signature: i.signature, src: `shadow-man/${f}` };
+  return { id: i.id, title: i.title, caption: i.caption, theme: i.theme, era: i.era, created_at: i.created_at, signature: i.signature, src: `shadow-man/${f}`, ...articleFields(i) };
 });
 for (const f of fs.readdirSync(imgDir)) if (f.endsWith('.jpg') && !keep.has(f)) fs.unlinkSync(path.join(imgDir, f));
 const js = `// Generated by paul-conrad-cartoons/scripts/export-p24-shadowman.mjs (TK-12241) — do not edit by hand.\n// Only pieces approved in the Inkwell curation UI are exported.\nwindow.P24_SHADOW_MAN = ${JSON.stringify({ exported_at: new Date().toISOString(), items }, null, 2)};\n`;
 const safe = js.replace(/^\/\/ Generated by [^\n]*\n/, '// Generated by the Inkwell export script (TK-12241) — do not edit by hand.\n');
 if (NAME.test(safe)) { console.error('FAIL: naming rule hit in export'); process.exit(1); }
 fs.writeFileSync(path.join(DEST, 'cartoons', 'shadow-man-data.js'), safe);
+
+// Pages + manifest entries for linked pieces.
+const entries = [];
+const keepPages = new Set();
+for (const i of pick) {
+  const st = linkedStory(i);
+  if (!st) continue;
+  const pid = pageId(i.id);
+  const af = articleFields(i);
+  const src = `shadow-man/${i.id}.jpg`;
+  const html = renderPage({
+    title: escHtml(i.title),
+    media: `<img src="${escHtml(src)}" alt="${escHtml(i.title)}">`,
+    caption: escHtml(unquote(i.caption)),
+    source: `<p class="src"><a href="${escHtml(af.story_url)}" target="_blank" rel="noopener noreferrer">Source: ${escHtml(af.story_title)}</a></p>`,
+    credit: `Shadow Man — original AI-generated editorial cartoon, signed &ldquo;Shadow Man&rdquo;. Invented figures only.`,
+  });
+  if (NAME.test(html)) { console.error(`FAIL: naming rule hit in page ${pid}`); process.exit(1); }
+  fs.writeFileSync(path.join(CART, `${pid}.html`), html);
+  keepPages.add(`${pid}.html`);
+  const outlet = (st.sourceName || '').toLowerCase();
+  const storyTags = (st.tags || []).filter(t => String(t).toLowerCase() !== outlet);
+  const cat = CATS.get(st.id) || {};
+  entries.push({
+    id: pid, title: i.title, file: `${pid}.html`, created_at: i.created_at, category: 'Political Cartoon',
+    section: cat.category || 'politics', blurb: unquote(i.caption), thumb: src, story_id: st.id, ai_generated: true,
+    style_reference: 'shadow-man', tags: [...new Set([...storyTags, 'editorial cartoon', 'shadow man'])],
+  });
+}
+for (const f of fs.readdirSync(CART)) if (/^shadowman-.*\.html$/.test(f) && !keepPages.has(f)) fs.unlinkSync(path.join(CART, f));
+const mf = path.join(CART, 'manifest.js');
+const msrc = fs.readFileSync(mf, 'utf8');
+const mm = msrc.match(/(window\.P24_CARTOONS\s*=\s*)(\[[\s\S]*\])(\s*;\s*)$/);
+if (!mm) { console.error('FAIL: could not locate window.P24_CARTOONS array in manifest.js'); process.exit(1); }
+const arr = JSON.parse(mm[2]);
+const others = arr.filter(x => !String(x.id).startsWith('shadowman-'));
+const next = [...others, ...entries];
+const before = mm.index + mm[1].length;
+const out = msrc.slice(0, before) + pyJson(next) + mm[3];
+if (NAME.test(pyJson(entries))) { console.error('FAIL: naming rule hit in manifest entries'); process.exit(1); }
+fs.writeFileSync(mf, out);
+console.log(`manifest: ${others.length} other entries untouched, ${arr.length - others.length} old shadowman-* entries replaced by ${entries.length}; pages: ${[...keepPages].join(', ') || 'none'}`);
 console.log(`exported ${items.length} ${args['test-include-pending'] ? 'approved+pending (TEST)' : 'approved'} pieces -> ${path.relative(process.env.HOME, DEST)}/cartoons/shadow-man/`);
diff --git a/scripts/lib-stories.mjs b/scripts/lib-stories.mjs
new file mode 100644
index 0000000..0471be8
--- /dev/null
+++ b/scripts/lib-stories.mjs
@@ -0,0 +1,61 @@
+// lib-stories.mjs — shared P24 story-list access for the Shadow Man article linker (TK-12247). $0, local.
+// Runs the P24 repo's own daily-cartoons/extract_stories.mjs (sandboxed vm parse of real-news-data.js +
+// stories-data.js, read-only) and returns its flat article list. Used by the matcher script and by the
+// Inkwell server's GET /api/stories + POST /api/shadowman/link validation.
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+
+export function p24Dir(explicit) {
+  const raw = explicit || process.env.P24_DIR || '~/Projects/crazy-news-channel';
+  return path.resolve(raw.replace(/^~/, process.env.HOME));
+}
+
+export function loadStories(dir) {
+  const d = p24Dir(dir);
+  const script = path.join(d, 'daily-cartoons', 'extract_stories.mjs');
+  if (!fs.existsSync(script)) throw new Error(`extract_stories.mjs not found under ${d}`);
+  const out = execFileSync(process.execPath, [script], { env: { ...process.env, P24_SITE_DIR: d }, maxBuffer: 32 * 1024 * 1024, timeout: 30000 });
+  const list = JSON.parse(out.toString('utf8'));
+  if (!Array.isArray(list)) throw new Error('extract_stories.mjs did not return an array');
+  return list;
+}
+
+// The article URL for a story: the original outlet link for real news, else the P24 article route
+// written SITE-ROOT-relative ("index.html#/article/<id>"). Consumers resolve a relative value against
+// wherever the P24 site lives (the exported cartoon page prefixes "../"; Inkwell prefixes P24_BASE).
+export function storyUrl(s) {
+  return s.sourceUrl || `index.html#/article/${encodeURIComponent(s.id)}`;
+}
+
+export function storySourceLabel(s) {
+  return s.source === 'real-news' ? (s.sourceName || 'Real news') : 'P24';
+}
+
+// id -> { category, categoryLabel } from the raw story globals (same sandboxed vm pattern as
+// extract_stories.mjs — never eval'd in this process). Used for the manifest entry's `section`.
+export function storyCategories(dir) {
+  const d = p24Dir(dir);
+  const ctx = { window: {}, console };
+  vm.createContext(ctx);
+  for (const f of ['real-news-data.js', 'stories-data.js']) {
+    const p = path.join(d, f);
+    if (fs.existsSync(p)) vm.runInContext(fs.readFileSync(p, 'utf8'), ctx, { filename: f });
+  }
+  const map = new Map();
+  for (const arr of [ctx.window.P24_REAL_STORIES, ctx.window.P24_EXTRA_STORIES]) {
+    for (const s of Array.isArray(arr) ? arr : []) if (s && s.id) map.set(s.id, { category: s.category || null, categoryLabel: s.categoryLabel || null });
+  }
+  return map;
+}
+
+// Parse a P24 cartoons/manifest.js (window.P24_CARTOONS = [...];) in a sandbox. Returns [] if absent.
+export function readManifest(dir) {
+  const p = path.join(p24Dir(dir), 'cartoons', 'manifest.js');
+  if (!fs.existsSync(p)) return [];
+  const ctx = { window: {} };
+  vm.createContext(ctx);
+  vm.runInContext(fs.readFileSync(p, 'utf8'), ctx, { filename: 'manifest.js' });
+  return Array.isArray(ctx.window.P24_CARTOONS) ? ctx.window.P24_CARTOONS : [];
+}
diff --git a/scripts/match-shadowman-articles.mjs b/scripts/match-shadowman-articles.mjs
new file mode 100644
index 0000000..549d9c5
--- /dev/null
+++ b/scripts/match-shadowman-articles.mjs
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+// match-shadowman-articles.mjs — link each APPROVED Shadow Man cartoon to its best-fitting P24 article
+// (TK-12247). $0: keyword shortlist + a LOCAL model judge (Ollama or the MLX server). No paid APIs.
+//
+// Usage: node scripts/match-shadowman-articles.mjs [--p24=~/Projects/crazy-news-channel] [--dry-run]
+//        [--min-score=4] [--shortlist=8] [--relink-manual]
+//
+// For each approved piece: shortlist the top N stories by keyword overlap (title+caption+scene+theme vs
+// headline+summary+tags, outlet-name tags ignored), then ask the local model to pick ONE story id from
+// that shortlist or NONE, with a 1-5 fit score and a one-line reason (JSON). A link is written only when
+// score >= min-score AND the pick is in the shortlist. Stories that already carry a non-Shadow-Man
+// cartoon are excluded (index.html shows the FIRST manifest cartoon per story, so a Shadow Man piece
+// there would never appear), and two pieces never share a story (higher score wins, the other is
+// re-judged without it). Pieces linked by hand in Inkwell (match_by:"manual") are left alone unless
+// --relink-manual. If no local model responds the run reports NOT-MEASURED, writes nothing, exits 3.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { loadStories, readManifest, storyUrl, storySourceLabel, p24Dir } from './lib-stories.mjs';
+
+const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]; }));
+const P24 = p24Dir(args.p24);
+const MAIN_P24 = p24Dir('~/Projects/crazy-news-channel');
+const MIN = Number(args['min-score'] || 4);
+const SHORT = Number(args.shortlist || 8);
+const DATA = path.join(ROOT, 'data', 'shadowman.json');
+
+const ENGINES = [
+  { name: 'ollama', model: 'hf.co/bartowski/Qwen2.5-Coder-32B-Instruct-GGUF:Q4_K_M', url: 'http://127.0.0.1:11434/api/chat' },
+  { name: 'mlx', model: 'mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit', url: 'http://127.0.0.1:8000/v1/chat/completions' },
+];
+
+async function callEngine(e, messages, timeoutMs) {
+  const body = e.name === 'ollama'
+    ? { model: e.model, messages, stream: false, format: 'json', options: { temperature: 0, num_predict: 200 } }
+    : { model: e.model, messages, temperature: 0, max_tokens: 200 };
+  const r = await fetch(e.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
+  if (!r.ok) throw new Error(`${e.name} HTTP ${r.status}`);
+  const j = await r.json();
+  const text = e.name === 'ollama' ? j.message && j.message.content : j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content;
+  if (typeof text !== 'string') throw new Error(`${e.name}: no content`);
+  return text;
+}
+
+async function pickEngine() {
+  for (const e of ENGINES) {
+    try {
+      const t = await callEngine(e, [{ role: 'user', content: 'Reply with exactly this JSON: {"ok":true}' }], 120000);
+      if (/"ok"\s*:\s*true/.test(t)) return e;
+    } catch (err) { console.error(`  engine ${e.name} unavailable: ${err.message}`); }
+  }
+  return null;
+}
+
+const STOP = new Set('the a an and or of to in on at for with by from as is are was were be been it its this that these those his her their our your my he she they we you i me him them who whom what which while into onto over under out up down off about than then so but not no yes all any each every some one two very just only also too more most much many such own same other another here there when where why how can will would should could may might do does did done has have had having being after before again further once per via own while like said says new latest news report reports amid says'.split(/\s+/));
+const stem = (w) => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '').replace(/(ing|ed)$/, '');
+function tokens(text) {
+  return new Set(String(text || '').toLowerCase().replace(/[’']/g, '').split(/[^a-z0-9]+/).filter(w => w.length >= 3 && !STOP.has(w)).map(stem).filter(w => w.length >= 3));
+}
+
+// Outlet names never count as topic words (a tag like "the new york times" says nothing about fit).
+function outletNames(stories) {
+  const s = new Set();
+  for (const st of stories) if (st.sourceName) s.add(st.sourceName.toLowerCase());
+  return s;
+}
+
+function storyTokens(st, outlets) {
+  const tags = (st.tags || []).filter(t => !outlets.has(String(t).toLowerCase()));
+  return tokens([st.headline, st.summary.replace(/^Via [^,]+,.*$/i, ''), tags.join(' '), tags.join(' ')].join(' '));
+}
+
+function pieceText(p) {
+  const scene = String(p.scene || '').replace(/,\s*(1960s|1970s|contemporary)[^,]*props.*$/i, '');
+  return [p.title, p.caption, scene, p.theme].join(' ');
+}
+
+// Theme / motif words -> the vocabulary P24 stories actually use (tags + headline words), so the keyword
+// shortlist can surface e.g. a Senate-race story for an elephant-vs-donkey cartoon. Shortlist only;
+// the local model still makes the call.
+const EXPAND = {
+  'Elections & party politics': 'elections election campaign senate race congress democrats republicans voters political',
+  'Corruption & money in politics': 'lobbying lobbyist donors campaign congress ethics political interference money',
+  'War & militarism': 'military conflict geopolitics attack airstrikes missiles war nato defense',
+  'Presidential power & scandal': 'white house president trump political interference scandal review',
+  'Local LA & California politics': 'city council infrastructure bureaucracy road traffic parking commute budget',
+  'Religion & politics': 'religion church faith charity donated',
+  'Guns & violence': 'crime guns shooting stabbing violence',
+  'Memorials & tributes': 'memorial tribute death history',
+};
+const MOTIF = { pothole: 'road infrastructure repaving', traffic: 'infrastructure commute road', gridlock: 'infrastructure commute', shredding: 'documents records', document: 'documents records', leak: 'white house reporter access', lobbyist: 'lobbying', donor: 'donors campaign', congress: 'congress senate', baby: 'campaign', district: 'elections race', map: 'elections race', elephant: 'republicans elections', donkey: 'democrats elections', tank: 'military', war: 'military conflict', plate: 'donated charity' };
+function expandedText(p) {
+  const base = pieceText(p);
+  const extra = [EXPAND[p.theme] || ''];
+  for (const [k, v] of Object.entries(MOTIF)) if (new RegExp(`\\b${k}`, 'i').test(base)) extra.push(v);
+  return base + ' ' + extra.join(' ');
+}
+
+function shortlist(piece, pool, outlets) {
+  const pt = tokens(expandedText(piece));
+  return pool.map(st => {
+    const tt = storyTokens(st, outlets);
+    let score = 0;
+    for (const w of pt) if (tt.has(w)) score += 1;
+    return { st, score };
+  }).sort((a, b) => b.score - a.score || (Date.parse(b.st.publishedAt || 0) || 0) - (Date.parse(a.st.publishedAt || 0) || 0))
+    .slice(0, SHORT).map(x => ({ ...x.st, kw: x.score }));
+}
+
+function prompt(piece, cands) {
+  const lines = cands.map(c => `- id: ${c.id}\n  headline: ${c.headline}\n  summary: ${c.summary.slice(0, 220)}\n  tags: ${(c.tags || []).join(', ')}`).join('\n');
+  return [
+    { role: 'system', content: 'You are a newspaper editor placing a single-panel editorial cartoon next to the news article it best comments on. Be strict: a cartoon fits only if a reader would immediately see it as commentary on that specific story. Topical overlap alone (both mention politics) is a weak fit. Answer with JSON only.' },
+    { role: 'user', content: `CARTOON\ntitle: ${piece.title}\ncaption: ${piece.caption}\nscene: ${piece.scene}\ntheme: ${piece.theme}\n\nCANDIDATE ARTICLES\n${lines}\n\nPick the ONE candidate article this cartoon best comments on, or NONE if none fits. Score fit 1-5 (5 = the cartoon is clearly about this story, 4 = strong natural fit, 3 = loose/thematic only, 1-2 = poor). Reply with JSON exactly like {"story_id":"<candidate id or NONE>","score":<1-5>,"reason":"<one short sentence>"}` },
+  ];
+}
+
+function parseJson(text) {
+  const i = text.indexOf('{');
+  const j = text.lastIndexOf('}');
+  if (i < 0 || j < i) throw new Error('no JSON object in model reply');
+  return JSON.parse(text.slice(i, j + 1));
+}
+
+async function judge(engine, piece, cands) {
+  let last;
+  for (let attempt = 0; attempt < 2; attempt++) {
+    try {
+      const out = parseJson(await callEngine(engine, prompt(piece, cands), 300000));
+      const sid = String(out.story_id || 'NONE').trim();
+      const score = Math.max(1, Math.min(5, Math.round(Number(out.score) || 1)));
+      return { story_id: sid, score, reason: String(out.reason || '').replace(/\s+/g, ' ').trim().slice(0, 240) };
+    } catch (err) { last = err; }
+  }
+  throw last;
+}
+
+(async () => {
+  const doc = JSON.parse(fs.readFileSync(DATA, 'utf8'));
+  const stories = loadStories(P24);
+  const outlets = outletNames(stories);
+  // Stories already carrying a non-Shadow-Man cartoon in the target OR the main checkout's manifest.
+  const taken = new Set();
+  for (const d of new Set([P24, MAIN_P24])) for (const c of readManifest(d)) if (c.story_id && !String(c.id).startsWith('shadowman-')) taken.add(c.story_id);
+  const pool = stories.filter(s => !taken.has(s.id));
+  console.log(`stories: ${stories.length} total, ${taken.size} already have a cartoon, ${pool.length} eligible (from ${path.relative(process.env.HOME, P24)})`);
+
+  const engine = await pickEngine();
+  if (!engine) {
+    console.log('NOT-MEASURED: no local model responded (ollama :11434, mlx :8000). Nothing linked, nothing written.');
+    process.exit(3);
+  }
+  console.log(`judge: ${engine.name} ${engine.model} ($0 local)`);
+
+  const manualIds = new Set(doc.items.filter(i => i.match_by === 'manual' && i.story_id && !args['relink-manual']).map(i => i.story_id));
+  const targets = doc.items.filter(i => i.status === 'approved' && !(i.match_by === 'manual' && !args['relink-manual']));
+  const results = new Map();
+  const judgeOne = async (piece, exclude) => {
+    const cands = shortlist(piece, pool.filter(s => !exclude.has(s.id) && !manualIds.has(s.id)), outlets);
+    const t0 = Date.now();
+    const v = await judge(engine, piece, cands);
+    const inList = cands.find(c => c.id === v.story_id);
+    const linked = !!inList && v.score >= MIN;
+    return { piece, cands, verdict: v, story: linked ? inList : null, secs: ((Date.now() - t0) / 1000).toFixed(1), note: !inList && v.story_id !== 'NONE' ? `pick "${v.story_id}" not in shortlist` : '' };
+  };
+  for (const piece of targets) {
+    const r = await judgeOne(piece, new Set());
+    results.set(piece.id, r);
+    console.log(`  ${piece.title} -> ${r.verdict.story_id} (${r.verdict.score}) ${r.secs}s`);
+  }
+  // Resolve collisions: two pieces on one story -> the higher score keeps it, the other is re-judged without it.
+  for (let round = 0; round < 3; round++) {
+    const byStory = new Map();
+    for (const r of results.values()) if (r.story) (byStory.get(r.story.id) || byStory.set(r.story.id, []).get(r.story.id)).push(r);
+    const losers = [];
+    for (const list of byStory.values()) if (list.length > 1) { list.sort((a, b) => b.verdict.score - a.verdict.score); losers.push(...list.slice(1)); }
+    if (!losers.length) break;
+    const claimed = new Set([...byStory.keys()]);
+    for (const l of losers) {
+      const r = await judgeOne(l.piece, claimed);
+      r.note = `re-judged: ${l.story.id} went to a higher-scoring piece`;
+      results.set(l.piece.id, r);
+      console.log(`  (collision) ${l.piece.title} -> ${r.verdict.story_id} (${r.verdict.score})`);
+    }
+  }
+
+  const now = new Date().toISOString();
+  const table = [];
+  for (const piece of targets) {
+    const r = results.get(piece.id);
+    const s = r.story;
+    Object.assign(piece, {
+      story_id: s ? s.id : null,
+      story_title: s ? s.headline : null,
+      story_url: s ? storyUrl(s) : null,
+      story_source: s ? storySourceLabel(s) : null,
+      match_score: r.verdict.score,
+      match_reason: r.verdict.reason + (r.note ? ` [${r.note}]` : '') + (!s && r.verdict.story_id !== 'NONE' && !r.note ? ` [best pick ${r.verdict.story_id} scored below ${MIN}]` : ''),
+      match_by: 'auto',
+      match_model: `${engine.name}:${engine.model}`,
+      matched_at: now,
+    });
+    table.push({ id: piece.id, title: piece.title, story_id: piece.story_id, story: piece.story_title, score: r.verdict.score, pick: r.verdict.story_id, reason: piece.match_reason, shortlist: r.cands.map(c => `${c.id}(${c.kw})`).join(' ') });
+  }
+  console.log('\nMATCH TABLE');
+  for (const t of table) console.log(`- ${t.title}\n    ${t.story_id ? `LINKED ${t.story_id} — ${t.story}` : `NONE (model pick: ${t.pick})`}  score ${t.score}\n    reason: ${t.reason}\n    shortlist: ${t.shortlist}`);
+  const n = table.filter(t => t.story_id).length;
+  console.log(`\n${n}/${table.length} linked (min score ${MIN}); ${doc.items.filter(i => i.match_by === 'manual').length} manual link(s) left untouched`);
+  if (args['dry-run']) { console.log('--dry-run: data/shadowman.json NOT written'); return; }
+  const tmp = DATA + '.tmp';
+  fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n');
+  fs.renameSync(tmp, DATA);
+  fs.appendFileSync(path.join(ROOT, 'data', 'shadowman-actions.jsonl'), JSON.stringify({ ts: now, collection: 'shadowman', action: 'auto-link', engine: `${engine.name}:${engine.model}`, linked: table.filter(t => t.story_id).map(t => [t.id, t.story_id]) }) + '\n');
+  console.log('wrote data/shadowman.json');
+})().catch(e => { console.error('FAIL', e); process.exit(1); });
diff --git a/server.js b/server.js
index 1a9ee2f..b045018 100644
--- a/server.js
+++ b/server.js
@@ -173,6 +173,74 @@ for (const [name, col] of Object.entries(COLLECTIONS)) {
   }
 }
 
+// ---- Article links (TK-12247): each Shadow Man piece can be linked to ONE P24 story ---------------
+// GET /api/stories lists the P24 story set by running the P24 repo's own daily-cartoons/extract_stories.mjs
+// (read-only, sandboxed vm parse; P24_DIR overrides the checkout, default ~/Projects/crazy-news-channel).
+// POST /api/shadowman/link {id, story_id|null} links / unlinks by hand (match_by:"manual"); loopback-only
+// like the other curation mutations, and the story id must exist in that same story list.
+const P24_BASE = (process.env.P24_BASE || 'http://127.0.0.1:9934/').replace(/\/?$/, '/');
+let storyCache = { at: 0, list: null };
+async function stories() {
+  if (storyCache.list && Date.now() - storyCache.at < 60000) return storyCache.list;
+  const lib = await import('./scripts/lib-stories.mjs');
+  const dir = lib.p24Dir();
+  const taken = new Map();
+  for (const c of lib.readManifest(dir)) if (c.story_id && !String(c.id).startsWith('shadowman-')) taken.set(c.story_id, c.title);
+  const list = lib.loadStories(dir).map(s => ({
+    id: s.id, source: s.source, source_label: lib.storySourceLabel(s), headline: s.headline, summary: s.summary,
+    tags: s.tags, url: lib.storyUrl(s), published_at: s.publishedAt, has_cartoon: taken.has(s.id) ? taken.get(s.id) : null,
+  }));
+  storyCache = { at: Date.now(), list };
+  return list;
+}
+
+app.get('/api/stories', async (req, res) => {
+  try {
+    const all = await stories();
+    const q = String(req.query.q || '').trim().toLowerCase();
+    const words = q ? q.split(/\s+/) : [];
+    const items = words.length ? all.filter(s => { const hay = [s.id, s.headline, s.summary, s.source_label, ...(s.tags || [])].join(' ').toLowerCase(); return words.every(w => hay.includes(w)); }) : all;
+    sendClean(res, { p24_base: P24_BASE, total: all.length, count: items.length, items });
+  } catch (err) {
+    res.status(500).json({ error: 'could not list P24 stories', detail: neutralText(err.message) });
+  }
+});
+
+app.post('/api/shadowman/link', loopbackOnly, async (req, res) => {
+  try {
+    const id = req.body && typeof req.body.id === 'string' ? req.body.id : '';
+    const sid = req.body && req.body.story_id;
+    if (!id) return res.status(400).json({ error: 'id required' });
+    if (sid !== null && typeof sid !== 'string') return res.status(400).json({ error: 'story_id must be a string or null' });
+    const file = COLLECTIONS.shadowman.file;
+    const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
+    const it = doc.items.find(i => i.id === id);
+    if (!it) return res.status(404).json({ error: 'unknown id', id });
+    let story = null;
+    if (sid !== null) {
+      story = (await stories()).find(s => s.id === sid);
+      if (!story) return res.status(400).json({ error: 'unknown story_id', story_id: sid });
+    }
+    const now = new Date().toISOString();
+    const before = it.story_id || null;
+    Object.assign(it, {
+      story_id: story ? story.id : null,
+      story_title: story ? story.headline : null,
+      story_url: story ? story.url : null,
+      story_source: story ? story.source_label : null,
+      match_score: null,
+      match_reason: story ? 'Linked by hand in Inkwell' : 'Unlinked by hand in Inkwell',
+      match_by: 'manual',
+      matched_at: now,
+    });
+    writeJsonAtomic(file, doc);
+    fs.appendFileSync(ACTIONS, JSON.stringify({ ts: now, collection: 'shadowman', action: story ? 'link' : 'unlink', ids: [id], story_id: it.story_id, previous_story_id: before }) + '\n');
+    sendClean(res, { ok: true, action: story ? 'link' : 'unlink', id, story_id: it.story_id, items: doc.items });
+  } catch (err) {
+    res.status(500).json({ error: 'link failed', detail: neutralText(err.message) });
+  }
+});
+
 app.get('/health', (req, res) => res.json({ ok: true, app: 'inkwell' }));
 
 app.listen(PORT, () => {

← d1f4379 auto-data-snapshot: 2026-09-25T11:23:40 (1 data files) — dat  ·  back to Paul Conrad Cartoons  ·  inkwell: link 2 Shadow Man pieces to P24 articles (Money Tan 185d3fd →