← back to Majilite Jewelry Cases

scripts/tk11086-ig-enumerate.mjs

120 lines

#!/usr/bin/env node
// TK-11086 STEP 1 — READ-ONLY Instagram inventory.
// Enumerate last-20-day media across every DW brand IG account (ig-accounts.json
// entries that carry an ig_user_id) on the ONE shared durable Meta PAGE token.
// Flag each post as a DELETE TARGET if it is (a) the jewelry-display-case render
// or (b) Novasuede content (DWCC-* / novasuede branding/captions).
//
// Writes an inventory JSONL. Deletes NOTHING. This is the scope-gate input.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const REELS = path.join(homedir(), 'Projects/dw-marketing-reels/data/ig-accounts.json');
const V = 'https://graph.facebook.com/v21.0';

// Token: env → Norma agent .env
function token() {
  if (process.env.IG_ACCESS_TOKEN) return process.env.IG_ACCESS_TOKEN;
  const p = path.join(homedir(), 'Projects/Norma/agents/instagram-agent/.env');
  const m = fs.readFileSync(p, 'utf8').match(/^IG_ACCESS_TOKEN=(.+)$/m);
  return m ? m[1].trim() : null;
}
const TOK = token();
if (!TOK) { console.error('no IG_ACCESS_TOKEN'); process.exit(1); }

const WINDOW_DAYS = 20;
const now = Date.now();
const cutoff = now - WINDOW_DAYS * 24 * 60 * 60 * 1000;

const accounts = JSON.parse(fs.readFileSync(REELS, 'utf8'))
  .filter(a => a.ig_user_id && (a.status === 'ready' || a.source === 'canonical-postable'));

const j = async (u) => {
  const r = await fetch(u);
  const t = await r.text();
  let d; try { d = JSON.parse(t); } catch { d = { raw: t }; }
  return { ok: r.ok, status: r.status, d };
};
const sleep = ms => new Promise(r => setTimeout(r, ms));

// --- Classifier -----------------------------------------------------------
// NOVASUEDE: caption mentions Novasuede or carries a DWCC- SKU (Novasuede prefix).
// JEWELRY:  caption/branding references the jewelry display case / glass counter,
//           OR a Majilite DWMJ- SKU (the render was attached to Majilite pages too).
// We CANNOT see the child image pixels, but the render was pushed to Novasuede
// (DWCC-*) + Majilite (DWMJ-*) product pages; a post whose image IS that render
// would carry that product's SKU/caption. We flag by caption signal and record
// child media so an ambiguous post is visible for manual review.
function classify(caption) {
  const c = (caption || '').toLowerCase();
  const reasons = [];
  let novasuede = false, jewelry = false;
  if (/novasuede/.test(c)) { novasuede = true; reasons.push('caption:novasuede'); }
  if (/\bdwcc-?\d/.test(c)) { novasuede = true; reasons.push('caption:DWCC-sku'); }
  if (/jewelry|jewellery|glass counter|display case|display-case/.test(c)) { jewelry = true; reasons.push('caption:jewelry-terms'); }
  if (/\bdwmj-?\d/.test(c)) { jewelry = true; reasons.push('caption:DWMJ-majilite-sku'); }
  if (/majilite/.test(c)) { jewelry = true; reasons.push('caption:majilite'); }
  return { flagged: novasuede || jewelry, novasuede, jewelry, reasons };
}

const OUT = path.join(ROOT, 'data/tk11086-ig-inventory.jsonl');
fs.writeFileSync(OUT, '');
const perAccount = {};
let totalScanned = 0, totalInWindow = 0, totalFlagged = 0;

for (const a of accounts) {
  const uid = a.ig_user_id;
  const handle = a.handle || a.id;
  let url = `${V}/${uid}/media?fields=id,timestamp,media_type,caption,permalink,children{id,media_type,media_url}&limit=50&access_token=${encodeURIComponent(TOK)}`;
  let scanned = 0, inWindow = 0, flagged = 0, err = null;
  let stop = false, pages = 0;
  while (url && !stop && pages < 20) {
    const { ok, status, d } = await j(url);
    pages++;
    if (!ok) { err = `HTTP ${status} ${JSON.stringify(d).slice(0,180)}`; break; }
    const items = d.data || [];
    if (items.length === 0) break;
    for (const it of items) {
      scanned++;
      const ts = Date.parse(it.timestamp);
      if (ts < cutoff) { stop = true; break; } // media is newest-first; older → stop paging
      inWindow++;
      const cls = classify(it.caption);
      const rec = {
        account: handle,
        ig_user_id: uid,
        ig_media_id: it.id,
        posted_date: it.timestamp,
        media_type: it.media_type,
        caption_snippet: (it.caption || '').replace(/\s+/g, ' ').slice(0, 160),
        permalink: it.permalink,
        children: (it.children && it.children.data) ? it.children.data.map(ch => ({ id: ch.id, type: ch.media_type })) : null,
        flagged: cls.flagged,
        why_flagged: cls.reasons,
        novasuede: cls.novasuede,
        jewelry: cls.jewelry,
      };
      fs.appendFileSync(OUT, JSON.stringify(rec) + '\n');
      if (cls.flagged) flagged++;
    }
    url = (d.paging && d.paging.next) ? d.paging.next : null;
    await sleep(120);
  }
  perAccount[handle] = { scanned, inWindow, flagged, err };
  totalScanned += scanned; totalInWindow += inWindow; totalFlagged += flagged;
  const tag = err ? `ERR ${err}` : `in20d=${inWindow} flagged=${flagged}`;
  console.log(`  ${handle.padEnd(28)} ${tag}`);
  await sleep(150);
}

console.log('\n=== TOTALS ===');
console.log(`accounts scanned: ${accounts.length}`);
console.log(`posts in 20d window: ${totalInWindow}`);
console.log(`FLAGGED (jewelry/novasuede): ${totalFlagged}`);
console.log(`inventory: ${OUT}`);
fs.writeFileSync(path.join(ROOT, 'data/tk11086-ig-per-account.json'), JSON.stringify(perAccount, null, 2));