← back to Commercialrealestate
scripts/refresh-firm-ig.js
113 lines
#!/usr/bin/env node
// Populate data/firm-ig-cache.json with each brokerage firm's last 3 Instagram
// posts via Instagram Business Discovery (the ToS-clean path — reads only public
// business/creator accounts). Reuses the never-expiring Page token from the
// MCC .env or the secrets master. $0 — Graph API is free.
//
// Usage: node scripts/refresh-firm-ig.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const ROOT = path.join(__dirname, '..');
const GRAPH = 'https://graph.facebook.com/v23.0';
const MAP = path.join(ROOT, 'data', 'firm-ig-map.json');
const CACHE = path.join(ROOT, 'data', 'firm-ig-cache.json');
const THUMBS = path.join(ROOT, 'data', 'firm-ig-thumbs');
// Download an IG CDN image to a local same-origin file (IG blocks cross-origin
// hotlinking + the CDN URLs expire, so we cache the bytes at refresh time and
// serve a local path). Returns the web path or null on failure.
async function cacheThumb(url, handle, i) {
if (!url) return null;
try {
const r = await fetch(url);
if (!r.ok) return null;
const buf = Buffer.from(await r.arrayBuffer());
if (!buf.length) return null;
fs.mkdirSync(THUMBS, { recursive: true });
const rel = `data/firm-ig-thumbs/${handle}-${i}.jpg`;
fs.writeFileSync(path.join(ROOT, rel), buf);
return rel;
} catch { return null; }
}
function readEnvVal(file, key) {
try { return (fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')) || [])[1]; }
catch { return null; }
}
function tokenCandidates() {
const files = [
path.join(os.homedir(), 'Projects/marketing-command-center/.env'),
path.join(os.homedir(), 'Projects/secrets-manager/.env'),
path.join(ROOT, '.env'),
];
const out = [];
for (const k of ['IG_ACCESS_TOKEN', 'META_ACCESS_TOKEN']) for (const f of files) {
const v = readEnvVal(f, k);
if (v && !/TOKEN\s*$/i.test(v) && v.startsWith('EAA') && !out.includes(v)) out.push(v);
}
return out;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function resolveIgNode(tok) {
let r = await fetch(`${GRAPH}/me?fields=name,instagram_business_account{id,username}&access_token=${encodeURIComponent(tok)}`);
let j = await r.json();
if (!j.error && j.instagram_business_account) return { id: j.instagram_business_account.id, username: j.instagram_business_account.username };
r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(tok)}`);
j = await r.json();
if (j.error) throw new Error(j.error.message);
const p = (j.data || []).find(p => p.instagram_business_account);
if (!p) throw new Error('no IG business account linked to this token');
return { id: p.instagram_business_account.id, username: p.instagram_business_account.username };
}
async function fetchPosts(igId, tok, username) {
const fields = `business_discovery.username(${username}){username,followers_count,media_count,media.limit(3){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
const r = await fetch(`${GRAPH}/${igId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(tok)}`);
const j = await r.json();
if (j.error) throw new Error(j.error.message);
const bd = j.business_discovery || {};
return {
followers_count: bd.followers_count ?? null,
posts: (bd.media?.data || []).slice(0, 3).map(m => ({
caption: (m.caption || '').slice(0, 200), media_type: m.media_type,
image: m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : m.media_url,
permalink: m.permalink, timestamp: m.timestamp,
likes: m.like_count ?? null, comments: m.comments_count ?? null,
})),
};
}
(async () => {
const cands = tokenCandidates();
if (!cands.length) { console.error('No usable Meta token (IG_ACCESS_TOKEN / META_ACCESS_TOKEN).'); process.exit(1); }
const handles = Object.keys(JSON.parse(fs.readFileSync(MAP, 'utf8')).handles || {});
let tok, ig;
for (const c of cands) { try { ig = await resolveIgNode(c); tok = c; break; } catch (e) { console.log(` (token …${c.slice(-4)} unusable: ${e.message.slice(0,50)})`); } }
if (!tok) { console.error('No candidate token resolved an IG node.'); process.exit(1); }
console.log(`Discovery node: @${ig.username}`);
const cache = (() => { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } })();
let ok = 0, failed = 0;
for (const h of handles) {
try {
const data = await fetchPosts(ig.id, tok, h);
// Cache each thumbnail locally; fall back to the remote URL if download fails.
let thumbed = 0;
for (let i = 0; i < data.posts.length; i++) {
const local = await cacheThumb(data.posts[i].image, h, i);
if (local) { data.posts[i].image = local; thumbed++; }
}
cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
ok++; console.log(` ✓ @${h} — ${data.posts.length} posts (${thumbed} thumbs cached), ${data.followers_count} followers`);
} catch (e) {
cache[h] = { ...(cache[h] || {}), error: e.message, fetchedAt: new Date().toISOString() };
failed++; console.log(` ✗ @${h} — ${e.message}`);
}
fs.writeFileSync(CACHE, JSON.stringify(cache, null, 2));
await sleep(400);
}
console.log(`Done: ${ok} ok, ${failed} failed of ${handles.length}. → ${CACHE}`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });