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