← 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); });