[object Object]

← back to Norma Platform

Add delete-novasuede-graph.js — gated Graph-API removal of 33 Novasuede IG posts (dry-run default, --go to execute)

60d2b88713f71700418b4b03494d28277ff6fc74 · 2026-08-25 08:58:37 -0700 · Steve Abrams

Files touched

Diff

commit 60d2b88713f71700418b4b03494d28277ff6fc74
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 25 08:58:37 2026 -0700

    Add delete-novasuede-graph.js — gated Graph-API removal of 33 Novasuede IG posts (dry-run default, --go to execute)
---
 agents/instagram-agent/delete-novasuede-graph.js | 219 +++++++++++++++++++++++
 1 file changed, 219 insertions(+)

diff --git a/agents/instagram-agent/delete-novasuede-graph.js b/agents/instagram-agent/delete-novasuede-graph.js
new file mode 100644
index 0000000..8bf7b95
--- /dev/null
+++ b/agents/instagram-agent/delete-novasuede-graph.js
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+/**
+ * delete-novasuede-graph.js — BROWSER-FREE Graph-API deletion of the Novasuede IG posts.
+ *
+ * Steve-approved removal of the Novasuede line from the DW Instagram account. Modeled on the
+ * proven delete-gate2-graph.js (same token-loading, https req helper, pacing, live-recheck,
+ * verify-after-delete, and tombstone conventions) — no browser (openclaw is a ban vector);
+ * DELETE /{ig-media-id} with the shared META PAGE token works directly.
+ *
+ * CANDIDATE SET  — built from data/live-media.json (`byId` map):
+ *   include: caption matches /novasuede/, /#novasuede/, or /nova suede/ (case-insensitive)
+ *   exclude: the DWCC-prefix "Carlisle & Co" wallpaper post and the generic "we are open" post
+ *            (any match whose ONLY signal is a DWCC- SKU string and whose caption is not actually
+ *            about the Novasuede line).
+ *   skip:    ids already recorded in data/deleted-posts.jsonl (idempotent/resumable).
+ *   The live not-yet-deleted count is expected to be 33; DRY RUN flags if it differs.
+ *
+ *   node delete-novasuede-graph.js                 # DRY RUN (default): live-recheck + numbered list, delete NOTHING
+ *   node delete-novasuede-graph.js --go            # delete each still-live match, paced, verified, tombstoned
+ *   node delete-novasuede-graph.js --go --pace 20  # override the inter-delete pace (seconds)
+ *
+ * SAFETY: default dry-run. --go paces PACE_S seconds between deletes (default 15). Every delete is
+ * VERIFIED (a GET afterward must return gone/error) before it is tombstoned. A delete that fails or
+ * can't be verified is SKIPPED (never aborts the run), collected, and reported in a FAILURES summary.
+ * HARD GUARD: the script only ever touches media ids that passed the Novasuede caption filter — the
+ * delete loop re-reads each target's id off the built set; nothing outside the set is ever deleted.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const { execFileSync } = require('child_process');
+
+const DATA = path.join(__dirname, 'data');
+const LIVE = path.join(DATA, 'live-media.json');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+// Same shared META PAGE token env as delete-gate2-graph.js (.env in this dir, dotenv-loaded).
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = process.env.IG_GRAPH_HOST || 'graph.facebook.com';
+const REASON = 'novasuede-removal-steve-approved';
+const EXPECTED_LIVE = 33;
+const LEDGER = path.join(process.env.HOME, '.claude', 'yolo-queue', 'executed-reversible', 'log-exec.mjs');
+
+const args = process.argv.slice(2);
+const GO = args.includes('--go');
+const paceArg = (() => { const i = args.indexOf('--pace'); return i >= 0 ? Number(args[i + 1]) : null; })();
+const PACE_MS = (paceArg && paceArg > 0 ? paceArg : 15) * 1000;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const shortOf = (p) => (String(p).match(/\/p\/([^/]+)/) || [])[1] || null;
+const capHead = (c) => String(c || '').slice(0, 70).replace(/\s+/g, ' ').trim();
+
+// --- Graph helper (identical shape to delete-gate2-graph.js) --------------------------------
+function req(method, pathq) {
+  return new Promise((resolve) => {
+    const r = https.request(`https://${HOST}/${VER}/${pathq}`, { method }, (res) => {
+      let s = ''; res.on('data', (d) => (s += d));
+      res.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: res.statusCode, body: j, raw: s }); });
+    });
+    r.on('error', (e) => resolve({ status: 0, body: null, raw: String(e.message) }));
+    r.end();
+  });
+}
+const get = (p) => req('GET', p);
+const del = (p) => req('DELETE', p);
+
+// --- Candidate-set construction --------------------------------------------------------------
+const NOVA_RX = /novasuede|#novasuede|nova\s?suede/i;
+// A caption is genuinely about the Novasuede line if it carries Novasuede product context.
+const NOVA_LINE_RX = /novasuede™|luxury\s+(faux\s+)?suede|microfiber\s+suede|new\s+colou?rs/i;
+
+// EXCLUDE known false positives: the DWCC- "Carlisle & Co" wallpaper post and the generic
+// "we are open" post — any match whose only signal is a DWCC- SKU string and whose caption is
+// NOT actually about the Novasuede line.
+function isFalsePositive(cap) {
+  const c = String(cap || '');
+  const carlisleOnly = /carlisle/i.test(c) && !NOVA_LINE_RX.test(c);
+  const weAreOpenOnly = /we[\s'’]*re?\s+open/i.test(c) && !NOVA_LINE_RX.test(c);
+  const dwccOnly = /DWCC-/.test(c) && !NOVA_LINE_RX.test(c) && !/novasuede/i.test(c.replace(/#novasuede/ig, ''));
+  return carlisleOnly || weAreOpenOnly || dwccOnly;
+}
+
+function alreadyDeleted() {
+  if (!fs.existsSync(TOMBS)) return new Set();
+  return new Set(fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean)
+    .map((l) => { try { return String(JSON.parse(l).media_id); } catch { return null; } }).filter(Boolean));
+}
+
+function buildCandidates() {
+  const j = JSON.parse(fs.readFileSync(LIVE, 'utf8'));
+  const byId = j.byId || {};
+  const done = alreadyDeleted();
+  const out = [];
+  for (const id of Object.keys(byId)) {
+    const m = byId[id];
+    const cap = String(m.caption || '');
+    if (!NOVA_RX.test(cap)) continue;      // must match the Novasuede filter
+    if (isFalsePositive(cap)) continue;    // drop the named false positives
+    if (done.has(String(id))) continue;    // already deleted (idempotent)
+    out.push({ id: String(id), type: m.type, ts: m.ts, permalink: m.permalink, caption: cap });
+  }
+  return out;
+}
+
+function tombstone(row, permalink) {
+  fs.appendFileSync(TOMBS, JSON.stringify({
+    media_id: row.id,
+    permalink: permalink || row.permalink || null,
+    deleted_at: new Date().toISOString(),
+    mode: 'graph-api',
+    verified: true,
+    reason: REASON,
+  }) + '\n');
+}
+
+function ledger(n) {
+  const rec = {
+    ts: new Date().toISOString(),
+    agent: 'dw-instagram',
+    action: `deleted ${n} novasuede IG posts`,
+    blast_radius: n,
+    undo_cmd: 'NONE — irreversible',
+    verify: 'each GET-confirmed gone + tombstoned',
+  };
+  try {
+    execFileSync('node', [LEDGER], { input: JSON.stringify(rec), stdio: ['pipe', 'inherit', 'inherit'] });
+  } catch (e) {
+    // Fallback: the standard helper REQUIRES a recorded undo and rejects "NONE"; that's fine for
+    // this irreversible action — append the summary directly so Steve still has the post-hoc record.
+    try {
+      const p = path.join(process.env.HOME, '.claude', 'yolo-queue', 'executed-reversible', 'ledger.jsonl');
+      fs.appendFileSync(p, JSON.stringify(rec) + '\n');
+      console.log('ledgered (direct append):', rec.ts, '—', rec.action);
+    } catch (e2) {
+      console.log(`(ledger append failed: ${e2.message})`);
+    }
+  }
+}
+
+// --- Main ------------------------------------------------------------------------------------
+(async () => {
+  if (!TOKEN) { console.error('FATAL: IG_ACCESS_TOKEN not set (dotenv .env in this dir).'); process.exit(2); }
+
+  const candidates = buildCandidates();
+
+  // Live re-check: confirm each candidate is still live on Graph before listing/deleting.
+  console.log(`Re-checking ${candidates.length} Novasuede candidate(s) live on Graph …\n`);
+  const live = [];
+  for (const c of candidates) {
+    const chk = await get(`${c.id}?fields=id,permalink&access_token=${TOKEN}`);
+    if (chk.status === 200 && chk.body && chk.body.id) {
+      live.push({ ...c, permalink: chk.body.permalink || c.permalink });
+    }
+    await sleep(300);
+  }
+
+  console.log('IDX  MEDIA_ID              DATE        TYPE            PERMALINK / CAPTION');
+  console.log('---  -------------------- ----------  --------------  -------------------------------------------');
+  live.forEach((c, i) => {
+    console.log(
+      String(i + 1).padStart(3),
+      c.id.padEnd(20),
+      (c.ts || '').slice(0, 10).padEnd(10),
+      String(c.type || '').padEnd(14),
+      (c.permalink || '(no permalink)'),
+    );
+    console.log('     ', '"' + capHead(c.caption) + '"');
+  });
+
+  console.log(`\nTotal live Novasuede matches: ${live.length}`);
+  if (live.length !== EXPECTED_LIVE) {
+    console.log(`⚠  FLAG: live count ${live.length} != expected ${EXPECTED_LIVE} — investigate before --go.`);
+  } else {
+    console.log(`✓  live count matches expected ${EXPECTED_LIVE}.`);
+  }
+
+  if (!GO) {
+    console.log('\nDRY RUN — nothing deleted. Re-run with --go to delete (paced, verified, tombstoned).');
+    return;
+  }
+
+  // HARD GUARD: only ids from the built (filtered) candidate set can reach the delete loop.
+  const guard = new Set(candidates.map((c) => c.id));
+  const targets = live.filter((c) => guard.has(c.id));
+
+  console.log(`\n--go: deleting ${targets.length} live Novasuede post(s), ${PACE_MS / 1000}s apart …\n`);
+  let ok = 0;
+  const failures = [];
+  for (const t of targets) {
+    if (!guard.has(t.id)) { continue; } // belt-and-suspenders: never touch a non-candidate id
+    process.stdout.write(`DELETE ${t.id} ${t.permalink || ''} ... `);
+    const d = await del(`${t.id}?access_token=${TOKEN}`);
+    await sleep(1500);
+    const chk = await get(`${t.id}?fields=id&access_token=${TOKEN}`);
+    const gone = chk.status !== 200 || (chk.body && chk.body.error);
+    if ((d.status === 200 || (d.body && d.body.success)) && gone) {
+      tombstone(t, t.permalink); ok++;
+      console.log('✓ deleted+verified');
+    } else if (gone) {
+      tombstone(t, t.permalink); ok++;
+      console.log(`✓ gone (delete resp status ${d.status})`);
+    } else {
+      const why = d.body && d.body.error ? d.body.error.message : (d.raw || '').slice(0, 120);
+      failures.push({ media_id: t.id, permalink: t.permalink || null, why });
+      console.log(`✗ FAILED — SKIPPING (del status ${d.status}: ${why})`);
+    }
+    await sleep(PACE_MS);
+  }
+
+  if (ok > 0) ledger(ok);
+
+  console.log('\n================ SUMMARY ================');
+  console.log(`attempted: ${targets.length}   confirmed-deleted: ${ok}   failed: ${failures.length}`);
+  if (failures.length) {
+    console.log('\nFAILURES (manual follow-up):');
+    failures.forEach((f) => console.log(`  ${f.media_id}  ${f.permalink || '(no permalink)'}  — ${f.why}`));
+  }
+})();

← b2e7413 auto-data-snapshot: 2026-08-24T20:06:56 (1 data files) — age  ·  back to Norma Platform  ·  auto-data-snapshot: 2026-08-25T08:55:17 (1 data files) — age 29e2505 →