← back to Norma

agents/instagram-agent/delete-spoonflower.js

119 lines

#!/usr/bin/env node
/**
 * delete-spoonflower.js — delete the discovered Spoonflower posts on Instagram via openclaw
 * (logged-in real Chrome). The Graph API cannot delete a published post, so this drives the
 * ··· → Delete → confirm flow and VERIFIES the post is gone before recording it.
 *
 * Reads  data/spoonflower-hitlist.jsonl (the 4 confirmed targets).
 * Writes data/deleted-posts.jsonl (tombstones) + data/spoonflower-delete-results.jsonl.
 *
 * SAFETY:
 *   - Serialized (one delete at a time) — rapid browser deletes are Meta's #1 ban trigger.
 *   - Per-post it detects the ··· menu; if the logged-in account does NOT own the post,
 *     it SKIPS loudly (never guesses) so you can switch accounts and re-run.
 *   - Only tombstones a post it VERIFIED is gone (permalink 404s afterward).
 *
 * Usage:
 *   node delete-spoonflower.js --dry           # navigate + report reachability, delete NOTHING
 *   node delete-spoonflower.js                 # delete every reachable target
 *   node delete-spoonflower.js grassclothwallpaper   # only this account's posts
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const DATA = path.join(__dirname, 'data');
const HITLIST = path.join(DATA, 'spoonflower-hitlist.jsonl');
const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
const RESULTS = path.join(DATA, 'spoonflower-delete-results.jsonl');

const args = process.argv.slice(2);
const DRY = args.includes('--dry');
const onlyHandle = args.find((a) => !a.startsWith('--')) || null;

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'] }); }
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}`); }

async function loggedIn() {
  ocOpen('https://www.instagram.com/'); await sleep(2500);
  const s = ocSnap();
  if (/Enter the code|Log in|Log In|Phone number, username|Create new account/i.test(s) && !/\bHome\b|\bSearch\b|\bProfile\b/i.test(s)) return false;
  return true;
}

// Delete one post; returns {status, detail}. status ∈ deleted|not-owner|no-menu|still-present|error
async function del(permalink) {
  ocNav(permalink); await sleep(2800);
  let snap = ocSnap();
  if (/isn't available|Sorry, this page|Page Not Found/i.test(snap)) return { status: 'already-gone' };
  const more = ocFindRef(snap, /More options|^\s*-?\s*button "More"|More$/i);
  if (!more) return { status: 'no-menu', detail: 'no ··· menu — Chrome not logged in as the owning account' };
  ocClick(more); await sleep(1400);
  const del1 = ocFindRef(ocSnap(), /\bDelete\b/);
  if (!del1) return { status: 'not-owner', detail: 'no Delete item — logged-in account does not own this post' };
  ocClick(del1); await sleep(1400);
  const confirm = ocFindRef(ocSnap(), /\bDelete\b/);
  if (!confirm) return { status: 'error', detail: 'no confirm Delete button appeared' };
  ocClick(confirm); await sleep(3200);
  ocNav(permalink); await sleep(2800);
  const gone = /isn't available|Sorry, this page|Page Not Found/i.test(ocSnap());
  return gone ? { status: 'deleted' } : { status: 'still-present', detail: 'clicked Delete but post still resolves' };
}

(async () => {
  let targets = fs.readFileSync(HITLIST, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
  if (onlyHandle) targets = targets.filter((t) => t.handle === onlyHandle.replace(/^@/, ''));
  console.log(`${DRY ? 'DRY-RUN — ' : ''}${targets.length} target(s)${onlyHandle ? ` on @${onlyHandle}` : ''}:\n`);

  if (!(await loggedIn())) {
    console.error('✋ Instagram is NOT logged in (login/2FA screen detected). Complete the login in the openclaw Chrome first, then re-run.');
    process.exit(2);
  }
  console.log('✓ Instagram session is logged in.\n');

  const results = [];
  for (const t of targets) {
    process.stdout.write(`@${t.handle}  ${t.permalink} … `);
    if (DRY) {
      // reachability probe only: open, look for ··· menu, DO NOT delete
      ocNav(t.permalink); await sleep(2600);
      const s = ocSnap();
      const gone = /isn't available|Sorry, this page|Page Not Found/i.test(s);
      const reach = !!ocFindRef(s, /More options|More$/i);
      const status = gone ? 'already-gone' : reach ? 'reachable(owner)' : 'not-reachable(switch account)';
      console.log(status);
      results.push({ ...t, dry: true, status });
      continue;
    }
    let r;
    try { r = await del(t.permalink); } catch (e) { r = { status: 'error', detail: e.message.slice(0, 120) }; }
    console.log(r.status + (r.detail ? ` — ${r.detail}` : ''));
    if (r.status === 'deleted' || r.status === 'already-gone') {
      fs.appendFileSync(TOMBS, JSON.stringify({ id: t.permalink, permalink: t.permalink, media_id: t.media_id || '',
        handle: t.handle, ts: new Date().toISOString(), mode: 'live', reason: 'spoonflower-purge', verified: r.status === 'deleted' }) + '\n');
    }
    results.push({ ...t, status: r.status, detail: r.detail || '' });
    await sleep(4000); // pace between deletes — ban avoidance
  }

  fs.appendFileSync(RESULTS, results.map((r) => JSON.stringify({ ...r, at: new Date().toISOString() })).join('\n') + '\n');
  const done = results.filter((r) => r.status === 'deleted' || r.status === 'already-gone').length;
  const stuck = results.filter((r) => ['not-owner', 'no-menu', 'not-reachable(switch account)'].includes(r.status));
  console.log(`\n=== ${DRY ? 'DRY-RUN complete' : `${done}/${targets.length} removed`} ===`);
  if (stuck.length) {
    console.log(`\n⚠ ${stuck.length} post(s) need a different account logged in:`);
    for (const s of stuck) console.log(`   @${s.handle}  ${s.permalink}  → log into/switch to @${s.handle}, then re-run:  node delete-spoonflower.js ${s.handle}`);
  }
})();