← back to Norma

agents/instagram-agent/scan-spoonflower.js

68 lines

#!/usr/bin/env node
/**
 * scan-spoonflower.js — READ-ONLY fleet-wide scan for any post whose caption mentions
 * "spoonflower" across all 35 IG accounts. Uses the shared META token + each account's
 * ig_user_id. Paginates up to MAXPAGES per account. Writes data/spoonflower-hitlist.jsonl.
 * No writes to Instagram. $0 (Graph API reads are free).
 */
const fs = require('fs');
const path = require('path');
const https = require('https');
const A = require('./accounts');

const HOST = 'graph.facebook.com';
const VER = 'v21.0';
const MAXPAGES = 6; // ~6*50 = up to 300 recent posts/account
const RE = /spoonflower/i;
const OUT = path.join(__dirname, 'data', 'spoonflower-hitlist.jsonl');

function get(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let d = '';
      res.on('data', (c) => (d += c));
      res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 200))); } });
    }).on('error', reject);
  });
}

async function scanAccount(acct) {
  const hits = [];
  let url = `https://${HOST}/${VER}/${acct.ig_user_id}/media?fields=id,caption,permalink,timestamp,media_type&limit=50&access_token=${encodeURIComponent(acct.access_token)}`;
  let scanned = 0;
  for (let page = 0; page < MAXPAGES && url; page++) {
    const j = await get(url);
    if (j.error) return { error: j.error.message, scanned, hits };
    for (const m of (j.data || [])) {
      scanned++;
      if (m.caption && RE.test(m.caption)) {
        hits.push({ handle: acct.handle, page_name: acct.page_name, ig_user_id: acct.ig_user_id,
          media_id: m.id, permalink: m.permalink, timestamp: m.timestamp, media_type: m.media_type,
          caption: m.caption });
      }
    }
    url = (j.paging && j.paging.next) || null;
  }
  return { scanned, hits };
}

(async () => {
  const accounts = A.list().map((a) => A.resolve(a.handle)).filter((a) => a && a.ig_user_id && a.access_token);
  console.log(`Scanning ${accounts.length} accounts (up to ${MAXPAGES * 50} posts each)…\n`);
  const all = [];
  const errs = [];
  for (const acct of accounts) {
    try {
      const r = await scanAccount(acct);
      if (r.error) { errs.push({ handle: acct.handle, error: r.error }); process.stdout.write(`  @${acct.handle}: ERR ${r.error.slice(0,60)}\n`); continue; }
      if (r.hits.length) { all.push(...r.hits); console.log(`  @${acct.handle}: ${r.hits.length} SPOONFLOWER hit(s) / ${r.scanned} scanned`); }
      else process.stdout.write(`  @${acct.handle}: clean (${r.scanned})\n`);
    } catch (e) { errs.push({ handle: acct.handle, error: e.message }); console.log(`  @${acct.handle}: EXC ${e.message.slice(0,60)}`); }
  }
  fs.writeFileSync(OUT, all.map((h) => JSON.stringify(h)).join('\n') + (all.length ? '\n' : ''));
  console.log(`\n=== TOTAL: ${all.length} Spoonflower posts across ${new Set(all.map(h=>h.handle)).size} accounts ===`);
  console.log(`hitlist → ${OUT}`);
  if (errs.length) console.log(`errors on ${errs.length} accounts:`, errs.map(e=>e.handle).join(', '));
  for (const h of all) console.log(` • @${h.handle}  ${h.permalink}  (${h.timestamp})  "${(h.caption||'').replace(/\n/g,' ').slice(0,70)}…"`);
})();