← back to Norma Platform
IG: add /spoonflower purge viewer (thumbnails + live delete-status board)
c573cf58a1e688ed1e3f51636bc13f3401f3b5aa · 2026-08-17 09:06:23 -0700 · Steve Abrams
The 4 flagged Spoonflower posts are reshares (not in post-ledger.jsonl), so /posts
can't show them. New /spoonflower route + board shows each with thumbnail, caption,
reshare-vs-incidental-hashtag class, and pending/deleted status that updates live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A agents/instagram-agent/public/spoonflower-viewer.htmlM agents/instagram-agent/server.jsA agents/instagram-agent/spoonflower-api.js
Diff
commit c573cf58a1e688ed1e3f51636bc13f3401f3b5aa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 17 09:06:23 2026 -0700
IG: add /spoonflower purge viewer (thumbnails + live delete-status board)
The 4 flagged Spoonflower posts are reshares (not in post-ledger.jsonl), so /posts
can't show them. New /spoonflower route + board shows each with thumbnail, caption,
reshare-vs-incidental-hashtag class, and pending/deleted status that updates live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.../instagram-agent/public/spoonflower-viewer.html | 82 ++++++++++++++++++++++
agents/instagram-agent/server.js | 4 ++
agents/instagram-agent/spoonflower-api.js | 64 +++++++++++++++++
3 files changed, 150 insertions(+)
diff --git a/agents/instagram-agent/public/spoonflower-viewer.html b/agents/instagram-agent/public/spoonflower-viewer.html
new file mode 100644
index 0000000..ffafb01
--- /dev/null
+++ b/agents/instagram-agent/public/spoonflower-viewer.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Spoonflower Purge — flagged posts</title>
+<style>
+ :root { --bg:#0c0f1a; --surface:#151a2b; --el:#1d2440; --line:#2a3350; --text:#eef1f8; --mut:#9aa3c0;
+ --red:#f4436b; --amber:#f5a623; --green:#25c281; }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+ header { padding:22px 26px; border-bottom:1px solid var(--line); background:linear-gradient(180deg,#141a2e,#0c0f1a); }
+ h1 { margin:0 0 4px; font-size:20px; letter-spacing:.2px; }
+ .sub { color:var(--mut); font-size:13px; }
+ .stats { display:flex; gap:10px; margin-top:14px; flex-wrap:wrap; }
+ .pill { background:var(--el); border:1px solid var(--line); border-radius:999px; padding:5px 13px; font-size:13px; }
+ .pill b { color:#fff; }
+ .pill.red b { color:var(--red); } .pill.green b { color:var(--green); }
+ main { padding:22px 26px; }
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:18px; }
+ .card { background:var(--surface); border:1px solid var(--line); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; }
+ .thumb { aspect-ratio:1/1; background:#0a0d17 center/cover no-repeat; position:relative; }
+ .badge { position:absolute; top:10px; left:10px; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase;
+ padding:4px 9px; border-radius:6px; }
+ .badge.pending { background:rgba(244,67,107,.16); color:var(--red); border:1px solid rgba(244,67,107,.4); }
+ .badge.deleted { background:rgba(37,194,129,.16); color:var(--green); border:1px solid rgba(37,194,129,.4); }
+ .kind { position:absolute; top:10px; right:10px; font-size:11px; padding:4px 8px; border-radius:6px; background:rgba(0,0,0,.5); color:var(--mut); }
+ .body { padding:13px 14px; display:flex; flex-direction:column; gap:8px; flex:1; }
+ .acct { font-weight:600; }
+ .acct span { color:var(--mut); font-weight:400; font-size:12px; }
+ .when { color:var(--mut); font-size:12px; }
+ .cap { color:#cfd6ee; font-size:13px; max-height:4.5em; overflow:hidden; }
+ .cap b { color:var(--amber); }
+ .row { display:flex; gap:8px; margin-top:auto; padding-top:6px; }
+ a.btn { text-decoration:none; text-align:center; flex:1; padding:8px; border-radius:8px; font-size:13px; font-weight:600;
+ background:var(--el); color:var(--text); border:1px solid var(--line); }
+ a.btn:hover { border-color:#3a4570; }
+ .note { color:var(--mut); font-size:12.5px; margin-top:18px; padding:12px 14px; background:var(--surface); border:1px solid var(--line); border-radius:10px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>🧹 Spoonflower Purge — flagged posts</h1>
+ <div class="sub">Competitor (Spoonflower) references across the DW Instagram fleet. Deletion runs via logged-in Chrome; this board updates as posts clear.</div>
+ <div class="stats" id="stats"></div>
+</header>
+<main>
+ <div class="grid" id="grid"></div>
+ <div class="note" id="note"></div>
+</main>
+<script>
+const esc = (s) => (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const hi = (s) => esc(s).replace(/(spoonflower)/ig, '<b>$1</b>');
+async function load() {
+ const r = await fetch('/api/spoonflower'); const d = await r.json();
+ const stats = document.getElementById('stats');
+ stats.innerHTML =
+ `<span class="pill">Total flagged <b>${d.total}</b></span>` +
+ `<span class="pill red">Pending delete <b>${d.pending}</b></span>` +
+ `<span class="pill green">Deleted <b>${d.deleted}</b></span>`;
+ document.getElementById('grid').innerHTML = d.posts.map(p => `
+ <div class="card">
+ <div class="thumb" style="background-image:url('${esc(p.image)}')">
+ <span class="badge ${p.status}">${p.status === 'deleted' ? '✓ deleted' : '● pending delete'}</span>
+ <span class="kind">${p.kind === 'reshare' ? 'reshare' : 'hashtag'}</span>
+ </div>
+ <div class="body">
+ <div class="acct">@${esc(p.handle)} <span>· ${esc(p.page_name)}</span></div>
+ <div class="when">🕓 ${new Date(p.timestamp).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</div>
+ <div class="cap">${hi(p.caption)}</div>
+ <div class="row"><a class="btn" href="${esc(p.permalink)}" target="_blank" rel="noopener">View on Instagram ↗</a></div>
+ </div>
+ </div>`).join('');
+ document.getElementById('note').innerHTML =
+ '<b>reshare</b> = Norma auto-generated post crediting Spoonflower (clearly in scope). ' +
+ '<b>hashtag</b> = a genuine 2022 repost where #spoonflowerwallpaper is 1 of 16 tags (Steve chose: delete it too). ' +
+ 'Deletion needs the openclaw Chrome logged in as each owning account, then runs serialized + verified.';
+}
+load(); setInterval(load, 15000);
+</script>
+</body>
+</html>
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index 095bdf6..932df7d 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -175,4 +175,8 @@ app.post('/api/skill/story', async (req, res) => {
// ──────────────────────────────────────
require('./posts-api').registerPostRoutes(app, AGENT_NAME);
+// Focused Spoonflower-purge viewer (GET /spoonflower). The flagged posts are reshares,
+// not in post-ledger.jsonl, so /posts can't show them — this board can, with live status.
+require('./spoonflower-api').registerSpoonflowerRoutes(app);
+
start();
diff --git a/agents/instagram-agent/spoonflower-api.js b/agents/instagram-agent/spoonflower-api.js
new file mode 100644
index 0000000..f207d83
--- /dev/null
+++ b/agents/instagram-agent/spoonflower-api.js
@@ -0,0 +1,64 @@
+/**
+ * spoonflower-api.js — a focused viewer for the Spoonflower-competitor purge.
+ * The 4 flagged posts are RESHARES (not in post-ledger.jsonl), so the normal /posts
+ * viewer can't show them. This surfaces them with thumbnails + captions + live
+ * delete-status (cross-referenced against deleted-posts.jsonl), so Steve can SEE
+ * exactly what's staged for deletion and watch it clear as deletes run.
+ *
+ * GET /spoonflower → viewer HTML (public/spoonflower-viewer.html)
+ * GET /api/spoonflower → { posts:[...], total, deleted, pending }
+ */
+const fs = require('fs');
+const path = require('path');
+
+const DIR = __dirname;
+const DATA = path.join(DIR, 'data');
+const ENRICHED = path.join(DATA, 'spoonflower-hitlist-enriched.json');
+const HITLIST = path.join(DATA, 'spoonflower-hitlist.jsonl');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const VIEWER = path.join(DIR, 'public', 'spoonflower-viewer.html');
+
+function readJsonl(file) {
+ if (!fs.existsSync(file)) return [];
+ return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean)
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+}
+
+function loadHits() {
+ if (fs.existsSync(ENRICHED)) { try { return JSON.parse(fs.readFileSync(ENRICHED, 'utf8')); } catch { /* fall through */ } }
+ return readJsonl(HITLIST);
+}
+
+function getSpoonflower() {
+ const deleted = new Set(readJsonl(TOMBS).map((t) => t.permalink).filter(Boolean));
+ const hits = loadHits().map((h) => {
+ // reshares literally headline Spoonflower; the 2022 post is an incidental hashtag
+ const reshare = /^(We saw|Okay)\s+Spoonflower/i.test(h.caption || '');
+ return {
+ handle: h.handle,
+ page_name: h.page_name || h.handle,
+ permalink: h.permalink,
+ media_id: h.media_id,
+ timestamp: h.timestamp,
+ image: h.thumbnail_url || h.media_url || '',
+ caption: h.caption || '',
+ kind: reshare ? 'reshare' : 'incidental-hashtag',
+ status: deleted.has(h.permalink) ? 'deleted' : 'pending',
+ };
+ }).sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
+ return { posts: hits, total: hits.length,
+ deleted: hits.filter((h) => h.status === 'deleted').length,
+ pending: hits.filter((h) => h.status === 'pending').length };
+}
+
+function registerSpoonflowerRoutes(app) {
+ app.get('/spoonflower', (req, res) => {
+ if (!fs.existsSync(VIEWER)) return res.status(404).send('spoonflower-viewer.html missing');
+ res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
+ });
+ app.get('/api/spoonflower', (req, res) => {
+ try { res.json(getSpoonflower()); } catch (e) { res.status(500).json({ error: e.message }); }
+ });
+}
+
+module.exports = { registerSpoonflowerRoutes, getSpoonflower };
← 3f46ac8 auto-data-snapshot: 2026-08-17T09:04:04 (1 data files) — age
·
back to Norma Platform
·
IG /spoonflower: server-inject data into the page so it rend 1e26b11 →