← back to Marketing Command Center

scripts/social-refresh.mjs

88 lines

// Weekly social-refresh orchestrator — keeps the vendor social pull CURRENT.
//   1. IG vendor posts  → POST prod /api/vendors/posts/refresh (official Meta Business Discovery)
//   2. LinkedIn previews → gentle LOCAL public-OG harvest of resolved slugs (protects Kamatera's IP),
//                          then scp the cache to prod so the live panel shows fresh previews
//   3. Vendor contact sheet → local regen (PII stays local)
//   4. Freshness heartbeat → data/latest.json (PASS/WARN/FAIL for the fleet-health rollup)
// Reference/attribution-amplify only; read-only public GETs + official API. No posting, no PII to prod.
//   node scripts/social-refresh.mjs
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';

const ROOT = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
const DATA = path.join(ROOT, 'data');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const env = k => { try { return (fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/^['"]|['"]$/g, ''); } catch { return null; } };
const log = (...a) => console.log(new Date().toISOString(), ...a);
const steps = {};

// ── 1. IG vendor posts — official API, run against PROD so its cache updates ──
async function refreshIG() {
  const U = env('MCC_USER') || 'admin', P = env('MCC_PASS') || '';
  const auth = 'Basic ' + Buffer.from(`${U}:${P}`).toString('base64');
  try {
    const r = await fetch('https://marketing.designerwallcoverings.com/api/vendors/posts/refresh',
      { method: 'POST', headers: { authorization: auth, 'content-type': 'application/json' }, body: '{}' });
    const j = await r.json().catch(() => ({}));
    steps.ig = { ok: r.ok, ...j };
    log('IG refresh:', r.status, JSON.stringify(j).slice(0, 120));
  } catch (e) { steps.ig = { ok: false, error: e.message }; log('IG refresh FAILED', e.message); }
}

// ── 2. LinkedIn previews — gentle LOCAL OG harvest of RESOLVED slugs only ──
async function ogPull(slug) {
  try {
    const r = await fetch(`https://www.linkedin.com/company/${slug}/`, { redirect: 'manual', headers: {
      'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36', accept: 'text/html' } });
    if (r.status === 999 || r.status === 429) return { wall: true };
    if (!r.ok) return { status: r.status };
    const h = await r.text();
    const g = re => (h.match(re) || [])[1] || null;
    return { status: r.status, title: g(/<meta property="og:title" content="([^"]+)"/i),
      thumb: g(/<meta property="og:image" content="([^"]+)"/i), description: g(/<meta property="og:description" content="([^"]+)"/i) };
  } catch (e) { return { error: e.message }; }
}
async function refreshLinkedIn() {
  const readJson = f => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return null; } };
  const vmap = readJson('vendor-linkedin.json'); const follows = readJson('vendor-linkedin-follows.json');
  const slugs = new Set();
  for (const a of (vmap?.accounts || [])) if (a.slug && a.verified !== false) slugs.add(a.slug);
  for (const a of (follows?.accounts || [])) if (a.slug && a.verified === true) slugs.add(a.slug);
  const cacheFile = path.join(DATA, 'vendor-linkedin-cache.json');
  let cache = {}; try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch {}
  let ok = 0, fail = 0, consecWall = 0;
  for (const slug of slugs) {
    const og = await ogPull(slug); await sleep(1600);
    if (og.wall) { if (++consecWall >= 8) { log('LinkedIn rate-walled — stopping early'); break; } continue; }
    consecWall = 0;
    if (og.title) { cache[slug] = { og: { title: og.title, thumb: og.thumb, description: og.description }, status: og.status, fetchedAt: new Date().toISOString(), error: null }; ok++; }
    else { cache[slug] = { ...(cache[slug] || {}), status: og.status || null, fetchedAt: new Date().toISOString(), error: 'no og' }; fail++; }
    if ((ok + fail) % 20 === 0) fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
  }
  fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
  steps.linkedin = { ok, fail, slugs: slugs.size };
  log(`LinkedIn harvest: ${ok} ok / ${fail} fail of ${slugs.size} slugs`);
  // sync cache to prod (non-PII vendor/company previews) so the live panel is fresh
  try { execFileSync('scp', ['-q', cacheFile, 'my-server:/root/DW-Agents/marketing-command-center/data/vendor-linkedin-cache.json'], { timeout: 30000 }); steps.linkedin.syncedToProd = true; log('LinkedIn cache → prod'); }
  catch (e) { steps.linkedin.syncedToProd = false; log('scp cache→prod FAILED', e.message); }
}

// ── 3. Vendor contact sheet (local, PII) ──
function refreshContacts() {
  try { execFileSync('node', [path.join(ROOT, 'scripts', 'build-vendor-contact-sheet.mjs')], { timeout: 60000, stdio: 'ignore' }); steps.contacts = { ok: true }; log('contact sheet regenerated'); }
  catch (e) { steps.contacts = { ok: false, error: e.message }; log('contact sheet FAILED', e.message); }
}

// ── run ──
await refreshIG();
await refreshLinkedIn();
refreshContacts();

// ── 4. freshness heartbeat (fleet-health vocabulary) ──
const igOk = steps.ig?.ok, liOk = (steps.linkedin?.ok || 0) > 0;
const status = (igOk && liOk) ? 'PASS' : (igOk || liOk) ? 'WARN' : 'FAIL';
fs.writeFileSync(path.join(DATA, 'social-refresh-latest.json'),
  JSON.stringify({ skill: 'social-refresh', status, verdict: status, ranAt: new Date().toISOString(), steps }, null, 2));
log('DONE —', status);