← back to Norma
agents/instagram-agent/posts-api.js
187 lines
/**
* posts-api.js — the auto-posting viewer + inline delete for norma-instagram (:9810).
*
* Surfaces every post Norma auto-published (data/post-ledger.jsonl, ~275 rows across
* 29 accounts) as a browsable, per-account grid at GET /posts, and supports deleting
* any post INLINE from that grid in two honest modes:
*
* mode="tombstone" (default, safe): removes the post from Norma's viewer + ledger by
* appending it to data/deleted-posts.jsonl. Instagram is NOT touched. Zero Meta
* automation-ban risk. Use to curate what the viewer shows / what Norma tracks.
*
* mode="live" (destructive): actually deletes the post ON Instagram. The Graph API
* cannot delete a published IG post (see delete-originals.js), so this drives the
* logged-in real Chrome via openclaw (··· → Delete → confirm → verify gone), then
* tombstones it. Requires openclaw Chrome logged in as the OWNING account; if the
* Delete affordance isn't reachable it fails loudly and does NOT tombstone. Live
* deletes are SERIALIZED (one at a time) — rapid fleet-wide browser deletes are
* Meta's #1 automation-ban trigger.
*
* Routes registered on the caller's express app (before start()'s 404 catch-all):
* GET /posts → the viewer HTML (public/posts-viewer.html)
* GET /api/posts → { accounts:[...], posts:[...] } (tombstones filtered out)
* GET /api/posts/oc-status → { ok, loggedIn } openclaw/Instagram login probe (for Live mode)
* POST /api/posts/delete → { permalink, media_id, live } → delete inline
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const DIR = __dirname;
const DATA = path.join(DIR, 'data');
const LEDGER = path.join(DATA, 'post-ledger.jsonl');
const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
const VIEWER = path.join(DIR, 'public', 'posts-viewer.html');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Live (real Instagram) delete is OFF by default — the viewer is tombstone-only until
// Steve explicitly enables it with IG_LIVE_DELETE=1 in this agent's .env. Everything for
// live delete is built and tested; this flag is the safety gate on the irreversible path.
const liveEnabled = () => /^(1|true|yes|on)$/i.test(process.env.IG_LIVE_DELETE || '');
// ── ledger + tombstone helpers ───────────────────────────────────────────────
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);
}
// Stable identity for a post: permalink is unique per IG post; fall back to media_id or a
// handle+ts synthetic so rows with neither can still be tombstoned.
function postId(p) { return p.permalink || p.media_id || `${p.handle || '?'}::${p.ts || ''}`; }
function readTombstones() {
const s = new Set();
for (const t of readJsonl(TOMBS)) { if (t.id) s.add(t.id); }
return s;
}
function getPosts() {
const tombs = readTombstones();
const seen = new Set();
const posts = [];
// newest first; dedupe by id (a permalink can only meaningfully appear once)
for (const p of readJsonl(LEDGER).reverse()) {
const id = postId(p);
if (tombs.has(id) || seen.has(id)) continue;
seen.add(id);
posts.push({
id,
ts: p.ts || null,
handle: p.handle || '?',
page_name: p.page_name || p.handle || '?',
title: (p.product_title || '').split('|')[0].trim() || p.product_handle || '(untitled)',
image: p.image_url || (typeof p.images === 'string' ? p.images.split(',')[0] : '') || '',
permalink: p.permalink || '',
media_id: p.media_id || '',
kind: p.kind || 'IMAGE',
product_handle: p.product_handle || '',
});
}
const byAcct = {};
for (const p of posts) {
(byAcct[p.handle] ||= { handle: p.handle, page_name: p.page_name, count: 0 });
byAcct[p.handle].count++;
}
const accounts = Object.values(byAcct).sort((a, b) => a.handle.localeCompare(b.handle));
return { accounts, posts, total: posts.length };
}
function tombstone(entry, extra = {}) {
fs.appendFileSync(TOMBS, JSON.stringify({
id: entry.id, permalink: entry.permalink || '', media_id: entry.media_id || '',
handle: entry.handle || '', ts: new Date().toISOString(), ...extra,
}) + '\n');
}
// ── openclaw live-delete (mirrors delete-originals.js; the only way to delete on IG) ──
function oc(cmd) {
return execSync(`openclaw browser ${cmd}`, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
}
let TAB = null;
function ocOpen(url) { const o = oc(`open ${JSON.stringify(url)} --timeout 30000`); TAB = (o.match(/id:\s*([A-F0-9]+)/i) || [])[1] || TAB; return TAB; }
function ocNav(url) { if (!TAB) return ocOpen(url); oc(`navigate ${JSON.stringify(url)} --target-id ${TAB}`); }
function ocSnap() { try { return oc(`snapshot --format ai --limit 800 ${TAB ? `--target-id ${TAB}` : ''}`); } catch { return ''; } }
function ocFindRef(snap, re) {
for (const line of String(snap).split('\n')) {
if (re.test(line)) { const m = line.match(/\[ref=([A-Za-z0-9_]+)\]/); if (m) return m[1]; }
}
return null;
}
function ocClick(ref) { oc(`click ${ref} --target-id ${TAB}`); }
let LIVE_LOCK = false; // serialize live deletes — never run two browser deletes at once
async function ocLoggedIn() {
try {
ocOpen('https://www.instagram.com/'); await sleep(2500);
const s = ocSnap();
if (/Log in|Log In|Phone number, username/i.test(s) && !/Home|Search|Profile/i.test(s)) return false;
return true;
} catch { return false; }
}
async function liveDelete(permalink) {
if (!permalink) throw new Error('no permalink to delete');
ocNav(permalink); await sleep(2500);
const more = ocFindRef(ocSnap(), /More options|More$/i);
if (!more) throw new Error('no ··· menu — Chrome is not logged in as this account');
ocClick(more); await sleep(1200);
const del = ocFindRef(ocSnap(), /\bDelete\b/);
if (!del) throw new Error('no Delete item — logged-in account does not own this post');
ocClick(del); await sleep(1200);
const confirm = ocFindRef(ocSnap(), /\bDelete\b/);
if (!confirm) throw new Error('no confirm Delete button appeared');
ocClick(confirm); await sleep(3000);
ocNav(permalink); await sleep(2500);
return /isn't available|Sorry, this page|Page Not Found/i.test(ocSnap());
}
// ── route registration ───────────────────────────────────────────────────────
function registerPostRoutes(app, agentName = 'instagram-agent') {
app.get('/posts', (req, res) => {
if (!fs.existsSync(VIEWER)) return res.status(404).send('posts-viewer.html missing');
res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
});
app.get('/api/posts', (req, res) => {
try { res.json({ ...getPosts(), liveEnabled: liveEnabled() }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/posts/oc-status', async (req, res) => {
try { res.json({ ok: true, loggedIn: await ocLoggedIn() }); }
catch (e) { res.json({ ok: false, loggedIn: false, error: e.message }); }
});
app.post('/api/posts/delete', async (req, res) => {
const { permalink = '', media_id = '', live = false } = req.body || {};
const id = permalink || media_id;
if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
const entry = { id, permalink, media_id, handle: (req.body && req.body.handle) || '' };
if (!live) {
// tombstone only — remove from viewer/ledger, never touch Instagram
try { tombstone(entry, { mode: 'tombstone' }); return res.json({ ok: true, mode: 'tombstone' }); }
catch (e) { return res.status(500).json({ ok: false, error: e.message }); }
}
// live delete — gated OFF by default until IG_LIVE_DELETE=1
if (!liveEnabled()) return res.status(403).json({ ok: false, error: 'Live delete is disabled (tombstone-only). Set IG_LIVE_DELETE=1 to enable.' });
// serialized, verified, tombstone ONLY on confirmed removal
if (LIVE_LOCK) return res.status(409).json({ ok: false, error: 'a live delete is already running — one at a time' });
LIVE_LOCK = true;
try {
const gone = await liveDelete(permalink);
if (gone) { tombstone(entry, { mode: 'live', verified: true }); return res.json({ ok: true, mode: 'live', verified: true }); }
return res.json({ ok: false, mode: 'live', verified: false, error: 'clicked Delete but post still resolves — not tombstoned' });
} catch (e) {
console.error(`[${agentName}] live delete failed: ${e.message}`);
return res.status(502).json({ ok: false, mode: 'live', error: e.message });
} finally { LIVE_LOCK = false; }
});
}
module.exports = { registerPostRoutes, getPosts };