← back to Norma Platform
TK-10573 GATE2: browser-free Graph-API reconcile + delete tooling (57 eligible, canary-first)
75f9762dd6cf998523e4355c58a8368ae7b4023b · 2026-08-20 15:48:13 -0700 · Steve Abrams
Files touched
A agents/instagram-agent/delete-gate2-graph.jsA agents/instagram-agent/reconcile-gate2.js
Diff
commit 75f9762dd6cf998523e4355c58a8368ae7b4023b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 20 15:48:13 2026 -0700
TK-10573 GATE2: browser-free Graph-API reconcile + delete tooling (57 eligible, canary-first)
---
agents/instagram-agent/delete-gate2-graph.js | 103 ++++++++++++++++++++++++
agents/instagram-agent/reconcile-gate2.js | 112 +++++++++++++++++++++++++++
2 files changed, 215 insertions(+)
diff --git a/agents/instagram-agent/delete-gate2-graph.js b/agents/instagram-agent/delete-gate2-graph.js
new file mode 100644
index 0000000..1e727f4
--- /dev/null
+++ b/agents/instagram-agent/delete-gate2-graph.js
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+/**
+ * delete-gate2-graph.js — BROWSER-FREE Graph-API deletion of the GATE-2 eligible originals.
+ * TK-10573. Successor to delete-originals.js (openclaw browser = ban vector). batch-04
+ * (TK-10612) proved DELETE /{ig-media-id} works with the shared META token, so no browser.
+ *
+ * Source of truth = data/gate2-recon.json (written by reconcile-gate2.js). Only rows with
+ * eligible:true (old still live AND new repost verified live) are ever touched.
+ *
+ * node delete-gate2-graph.js # PROBE (default): list eligible targets, delete nothing
+ * node delete-gate2-graph.js --only <handle> # scope to one account
+ * node delete-gate2-graph.js --canary # delete EXACTLY ONE eligible target, verify gone, STOP
+ * node delete-gate2-graph.js --go # delete all eligible, paced, verify each (destructive)
+ *
+ * SAFETY: default probe-only. Paced PACE_MS between deletes (gentle; Graph, not browser, but still
+ * respectful). Every delete is VERIFIED (GET afterward must 404/return error) before tombstoning.
+ * Idempotent/resumable via data/deleted-posts.jsonl. Fail-loud: a delete that doesn't verify-gone
+ * is recorded as FAILED and does NOT count as done.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const DATA = path.join(__dirname, 'data');
+const RECON = path.join(DATA, 'gate2-recon.json');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = 'graph.facebook.com';
+const PACE_MS = 15000; // 15s between deletes
+const TICKET = 'TK-10573';
+
+const args = process.argv.slice(2);
+const CANARY = args.includes('--canary');
+const GO = args.includes('--go');
+const only = (() => { const i = args.indexOf('--only'); return i >= 0 ? args[i + 1] : null; })();
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const shortOf = (p) => (String(p).match(/\/p\/([^/]+)/) || [])[1] || null;
+
+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);
+
+function alreadyDeleted() {
+ if (!fs.existsSync(TOMBS)) return new Set();
+ return new Set(fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean)
+ .map((l) => { try { const j = JSON.parse(l); return j.media_id; } catch { return null; } }).filter(Boolean));
+}
+function tombstone(row) {
+ fs.appendFileSync(TOMBS, JSON.stringify({
+ ts: new Date().toISOString(), handle: row.handle, shortcode: shortOf(row.old_permalink),
+ media_id: row.old_media_id, permalink: row.old_permalink, method: 'graph-api', ticket: TICKET, verified: true,
+ }) + '\n');
+}
+
+(async () => {
+ const recon = JSON.parse(fs.readFileSync(RECON, 'utf8'));
+ const done = alreadyDeleted();
+ let targets = recon.report.filter((r) => r.eligible && !done.has(r.old_media_id));
+ if (only) targets = targets.filter((r) => r.handle === only);
+
+ console.log(`eligible & not-yet-deleted${only ? ` for @${only}` : ''}: ${targets.length}`);
+ if (!CANARY && !GO) {
+ const byH = {}; targets.forEach((t) => (byH[t.handle] = (byH[t.handle] || 0) + 1));
+ console.log('PROBE (no deletes). By account:', JSON.stringify(byH, null, 2));
+ console.log('Run with --canary (delete 1, verify, stop) or --go (delete all, paced+verified).');
+ return;
+ }
+ if (CANARY) targets = targets.slice(0, 1);
+
+ let ok = 0, fail = 0;
+ for (const t of targets) {
+ process.stdout.write(`DELETE @${t.handle} ${t.old_permalink} (${t.old_media_id}) ... `);
+ const d = await del(`${t.old_media_id}?access_token=${TOKEN}`);
+ await sleep(1500);
+ const chk = await get(`${t.old_media_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); ok++;
+ console.log('✓ deleted+verified');
+ } else if (gone) {
+ // delete response odd but object is gone -> still a success
+ tombstone(t); ok++;
+ console.log(`✓ gone (delete resp status ${d.status})`);
+ } else {
+ fail++;
+ console.log(`✗ FAILED (del status ${d.status}: ${d.body && d.body.error ? d.body.error.message : d.raw.slice(0,120)})`);
+ }
+ if (CANARY) break;
+ await sleep(PACE_MS);
+ }
+ console.log(`\nDONE: deleted+verified ${ok}, failed ${fail}, of ${targets.length} attempted.`);
+})();
diff --git a/agents/instagram-agent/reconcile-gate2.js b/agents/instagram-agent/reconcile-gate2.js
new file mode 100644
index 0000000..b77d66d
--- /dev/null
+++ b/agents/instagram-agent/reconcile-gate2.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+/**
+ * reconcile-gate2.js — READ-ONLY ground-truth reconciliation for TK-10573 GATE-2.
+ *
+ * The ledgers (deleted-posts.jsonl, delete-results-*.jsonl) are sparse/unreliable because the
+ * GATE-2 work happened across many sessions/architectures. The only reliable truth is the live
+ * Graph API. This script, using the ONE shared META token, for each of the 84 corrected
+ * originals:
+ * - GET /{old_media_id} → is the OLD bad-caption post still live? (delete target)
+ * - checks the owning account's live /media → is the NEW corrected repost actually live?
+ * (batch-04 found 3 reposts had silently vanished; deleting an old whose new is gone = data loss)
+ *
+ * Output: an eligibility report. eligible = old still live AND new confirmed live.
+ * Writes NOTHING to Instagram. Pure GETs. Cost $0.
+ *
+ * node reconcile-gate2.js # full report -> data/gate2-recon.json + console summary
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const DATA = path.join(__dirname, 'data');
+const SRC = path.join(DATA, 'redo-results-20260814.jsonl');
+const ACC = require('./accounts.json').accounts;
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = 'graph.facebook.com';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const shortOf = (permalink) => (String(permalink).match(/\/p\/([^/]+)/) || [])[1] || null;
+
+function get(pathq) {
+ return new Promise((resolve) => {
+ https.get(`https://${HOST}/${VER}/${pathq}`, (r) => {
+ let s = '';
+ r.on('data', (d) => (s += d));
+ r.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: r.statusCode, body: j, raw: s }); });
+ }).on('error', (e) => resolve({ status: 0, body: null, raw: String(e.message) }));
+ });
+}
+
+// paginate an account's live media into a set of live shortcodes (read-only)
+async function liveShortcodes(igId, cap = 200) {
+ const set = new Set();
+ let after = '';
+ for (let page = 0; page < 8; page++) {
+ const q = `${igId}/media?fields=id,permalink&limit=50${after ? `&after=${after}` : ''}&access_token=${TOKEN}`;
+ const r = await get(q);
+ if (r.status !== 200 || !r.body || !Array.isArray(r.body.data)) break;
+ r.body.data.forEach((m) => { const sc = shortOf(m.permalink); if (sc) set.add(sc); });
+ const next = r.body.paging && r.body.paging.cursors && r.body.paging.cursors.after;
+ if (!next || set.size >= cap) break;
+ after = next;
+ await sleep(150);
+ }
+ return set;
+}
+
+(async () => {
+ const rows = fs.readFileSync(SRC, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
+ .filter((r) => r.old_permalink && r.old_media_id);
+ const handles = [...new Set(rows.map((r) => r.handle))];
+
+ // per-account live shortcode sets (for NEW-repost liveness)
+ const liveByHandle = {};
+ for (const h of handles) {
+ const a = ACC[h];
+ if (!a || !a.ig_user_id) { liveByHandle[h] = null; continue; }
+ process.stderr.write(` fetching live media: @${h}\n`);
+ liveByHandle[h] = await liveShortcodes(a.ig_user_id);
+ await sleep(150);
+ }
+
+ const report = [];
+ for (const r of rows) {
+ const oldRes = await get(`${r.old_media_id}?fields=id,permalink&access_token=${TOKEN}`);
+ const old_live = oldRes.status === 200 && oldRes.body && oldRes.body.id;
+ const old_err = oldRes.status !== 200 ? (oldRes.body && oldRes.body.error && oldRes.body.error.message) || `HTTP ${oldRes.status}` : null;
+ const newSc = shortOf(r.new_permalink);
+ const liveSet = liveByHandle[r.handle];
+ const new_live = liveSet ? liveSet.has(newSc) : null; // null = couldn't verify (no roster/id)
+ report.push({
+ handle: r.handle, product_handle: r.product_handle,
+ old_media_id: r.old_media_id, old_permalink: r.old_permalink, old_live: !!old_live, old_err,
+ new_permalink: r.new_permalink, new_shortcode: newSc, new_live,
+ eligible: !!old_live && new_live === true,
+ reason: !old_live ? 'old-already-gone' : (new_live === false ? 'NEW-REPOST-MISSING(hold)' : (new_live === null ? 'new-unverifiable(hold)' : 'eligible')),
+ });
+ await sleep(150);
+ }
+
+ const out = path.join(DATA, 'gate2-recon.json');
+ fs.writeFileSync(out, JSON.stringify({ ts: new Date().toISOString(), total: report.length, report }, null, 2));
+
+ // summary
+ const byReason = {};
+ report.forEach((x) => (byReason[x.reason] = (byReason[x.reason] || 0) + 1));
+ console.log('\n=== GATE-2 RECONCILIATION (read-only) ===');
+ console.log('total originals:', report.length);
+ console.log('by reason:', JSON.stringify(byReason, null, 2));
+ const elig = report.filter((x) => x.eligible);
+ console.log('\nELIGIBLE TO DELETE (old live + new confirmed live):', elig.length);
+ const byHandleE = {};
+ elig.forEach((x) => (byHandleE[x.handle] = (byHandleE[x.handle] || 0) + 1));
+ console.log(JSON.stringify(byHandleE, null, 2));
+ const hold = report.filter((x) => !x.eligible && x.old_live);
+ if (hold.length) {
+ console.log('\n⚠️ HOLD — old still live but new NOT confirmed (do NOT delete):');
+ hold.forEach((x) => console.log(` @${x.handle} ${x.old_permalink} -> new ${x.new_permalink} [${x.reason}]`));
+ }
+ console.log('\nwrote', out);
+})();
← 4c96ddd auto-data-snapshot: 2026-08-20T15:43:22 (1 data files) — age
·
back to Norma Platform
·
TK-10758: stop+erase sassy reposts of others' content - disa 404bd15 →