← back to Norma

agents/instagram-agent/reconcile-gate2.js

113 lines

#!/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);
})();