← back to Norma
agents/instagram-agent/delete-originals.js
171 lines
#!/usr/bin/env node
/**
* delete-originals.js — remove the last-5-days ORIGINAL IG posts (the ones with the bad
* caption) via openclaw REAL Chrome, after the corrected versions were reposted. TK-10574.
*
* The Instagram Graph API cannot delete a published post, so deletion must be driven through
* the logged-in web UI (··· → Delete → confirm). openclaw drives a real, human-logged-in
* Chrome, so this only works AFTER Steve has logged into Meta/Instagram in openclaw and is
* on (or can switch to) the owning account.
*
* Source of truth = data/redo-results-<date>.jsonl (old_permalink + handle + new_permalink),
* written by redo-last5.js --go. We only ever delete an OLD post that has a confirmed NEW one.
*
* node delete-originals.js # PROBE (default, NON-destructive): for each post,
* # navigate + snapshot, report whether a Delete
* # affordance is present (= logged in as owner).
* node delete-originals.js --only <handle> # restrict to one account
* node delete-originals.js --canary # delete EXACTLY ONE probe-able post, verify, stop
* node delete-originals.js --go # delete all, paced, verify each (destructive)
*
* SAFETY: default is probe-only. --canary/--go actually click Delete. Pacing is deliberately
* long (PACE_MS) because rapid fleet-wide browser deletes are Meta's #1 automation-ban trigger.
* Every deletion is verified (the permalink must go unavailable) and logged; fail-loud on any
* post where the Delete affordance is missing (wrong account) — it is SKIPPED, never guessed.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const DATE = '20260814';
const SRC = path.join(__dirname, 'data', `redo-results-${DATE}.jsonl`);
const OUT = path.join(__dirname, 'data', `delete-results-${DATE}.jsonl`);
const PACE_MS = 60000; // 60s between deletions — stay well under automation-detection thresholds
const args = process.argv.slice(2);
const CANARY = args.includes('--canary');
const GO = args.includes('--go');
const DESTRUCTIVE = CANARY || GO;
const only = (() => { const i = args.indexOf('--only'); return i >= 0 ? args[i + 1] : null; })();
if (!fs.existsSync(SRC)) {
console.error(`No repost mapping at ${SRC}. Run redo-last5.js --go first.`);
process.exit(1);
}
const done = new Set(fs.existsSync(OUT)
? fs.readFileSync(OUT, 'utf8').trim().split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l).old_permalink; } catch { return null; } }).filter(Boolean)
: []);
const rows = fs.readFileSync(SRC, 'utf8').trim().split('\n').filter(Boolean)
.map((l) => JSON.parse(l))
.filter((r) => r.old_permalink && (only ? r.handle === only : true))
.filter((r) => !done.has(r.old_permalink)); // idempotent/resumable
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function oc(cmd) { return execSync(`openclaw browser ${cmd}`, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] }); }
const TAB_DEAD = /tab not found|target.*not found|No target/i;
const STALE_REF = /not found or not visible/i;
let tab = null;
function open(url) { const o = oc(`open ${JSON.stringify(url)} --timeout 30000`); tab = (o.match(/id:\s*([A-F0-9]+)/i) || [])[1] || tab; return tab; }
// Navigate; if the shared openclaw tab died mid-run ("tab not found"), transparently re-open.
function navigate(url) {
if (!tab) return open(url);
try { oc(`navigate ${JSON.stringify(url)} --target-id ${tab}`); }
catch (e) { if (TAB_DEAD.test(e.message)) { tab = null; open(url); } else throw e; }
}
// Snapshot with a couple of retries; a dead tab is reset so the next open() re-grabs one.
function snapshot() {
for (let i = 0; i < 3; i++) {
try { const s = oc(`snapshot --format ai --limit 800 ${tab ? `--target-id ${tab}` : ''}`); if (s && s.trim()) return s; }
catch (e) { if (TAB_DEAD.test(e.message)) tab = null; }
}
return '';
}
// Find the ref id of the first snapshot line whose accessible text matches `re`.
function findRef(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 click(ref) { oc(`click ${ref} --target-id ${tab}`); }
// Snapshot → find `re` → click, retrying to defeat the snapshot→click stale-ref race on IG's
// dynamic DOM (the ref goes stale between the two separate openclaw calls). Returns true on a
// successful click, false only if `re` is genuinely absent after every try.
async function clickByText(re, { tries = 4, settleMs = 900 } = {}) {
for (let i = 0; i < tries; i++) {
const ref = findRef(snapshot(), re);
if (!ref) { await sleep(settleMs); continue; } // not painted yet — wait & re-snap
try { click(ref); return true; }
catch (e) {
if (STALE_REF.test(e.message) || TAB_DEAD.test(e.message)) { await sleep(settleMs); continue; } // re-snap & retry
throw e;
}
}
return false;
}
// Does the current page show `re`? Retries a few times to let the DOM settle.
async function seesText(re, { tries = 3, settleMs = 800 } = {}) {
for (let i = 0; i < tries; i++) { if (re.test(snapshot())) return true; await sleep(settleMs); }
return false;
}
// Is this post owned by the currently-logged-in account? (Delete affordance reachable.)
async function isDeletable() {
const opened = await clickByText(/button "More options"/i);
if (!opened) return { deletable: false, reason: 'no ··· menu (not logged in / not painted)' };
await sleep(1500);
const del = await seesText(/button "Delete"/i);
// close the menu without acting (Escape) when only probing
try { oc(`press Escape --target-id ${tab}`); } catch { /* ignore */ }
return del ? { deletable: true } : { deletable: false, reason: 'no Delete item (not owner of this account)' };
}
async function deleteOne(r) {
navigate(r.old_permalink); await sleep(2800);
// open the ··· menu (retry-tolerant against the snapshot→click stale-ref race)
if (!await clickByText(/button "More options"/i)) throw new Error('no ··· menu (wrong account / not logged in)');
await sleep(1500);
// click the Delete menu item — match the BUTTON, not the "Delete post?" heading that also contains "Delete"
if (!await clickByText(/button "Delete"/i)) throw new Error('no Delete item (not owner)');
await sleep(1500);
// IG's confirm dialog has a heading "Delete post?" AND a "Delete" button — target the BUTTON.
if (!await clickByText(/button "Delete"/i)) throw new Error('no confirm Delete button');
await sleep(3000);
// Verify: the permalink should now be unavailable.
navigate(r.old_permalink); await sleep(2800);
const gone = /isn't available|Sorry, this page|Page Not Found/i.test(snapshot());
return gone;
}
(async () => {
console.log(`${DESTRUCTIVE ? (CANARY ? 'CANARY DELETE (1)' : 'LIVE DELETE') : 'PROBE (non-destructive)'} — ${rows.length} candidate original(s)${only ? ` @${only}` : ''}\n`);
if (!rows.length) { console.log('Nothing to do (all already deleted or none mapped).'); return; }
// Preflight: confirm openclaw browser is up + logged into instagram.
try {
open('https://www.instagram.com/'); await sleep(3500);
// Positive-signal login check, RETRIED — IG's nav chrome can take several seconds to paint,
// and a too-early snapshot was giving false "not logged in" readings. Logged in == we can see
// the app nav; only declare "not logged in" if the login form is still up after settling.
let loggedIn = false;
for (let i = 0; i < 4; i++) {
const s = snapshot();
if (/Home|Search|Profile|Create|Reels|New post/i.test(s)) { loggedIn = true; break; }
if (i >= 2 && /Phone number, username|Log in with|Forgot password/i.test(s)) break; // clearly the login page
await sleep(2500);
}
if (!loggedIn) { console.error('⚠ openclaw Chrome is NOT logged into Instagram (or IG did not paint). Log in / let it settle, then re-run.'); process.exit(2); }
} catch (e) { console.error(`⚠ openclaw browser not reachable: ${e.message}. Start it + log in, then re-run.`); process.exit(2); }
let ok = 0, skip = 0, i = 0;
for (const r of rows) {
i++;
const tag = `[${i}/${rows.length}] @${r.handle} ${r.old_permalink}`;
if (!DESTRUCTIVE) {
navigate(r.old_permalink); await sleep(2000);
const p = await isDeletable();
console.log(`${p.deletable ? '🟢 deletable' : '⚪ skip'} ${tag}${p.reason ? ` — ${p.reason}` : ''}`);
continue;
}
try {
const gone = await deleteOne(r);
fs.appendFileSync(OUT, JSON.stringify({ ...r, deleted: gone, ts: new Date().toISOString() }) + '\n');
console.log(`${gone ? '✓ deleted' : '⚠ clicked but not verified gone'} ${tag}`);
ok++;
} catch (e) { console.log(`✗ SKIP ${tag} — ${e.message}`); skip++; }
if (CANARY) { console.log('\nCanary done — verify manually, then run --go for the rest.'); break; }
if (i < rows.length) await sleep(PACE_MS);
}
if (DESTRUCTIVE) console.log(`\nDeleted ${ok}, skipped ${skip}. Log -> ${OUT}`);
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });