← back to Beverlyhillsvideos

scripts/adsense-approval-watch.mjs

205 lines

#!/usr/bin/env node
/**
 * adsense-approval-watch.mjs — daily watcher that emails Steve when
 * beverlyhillsvideos.com gets an AdSense review DECISION (approved OR rejected).
 *
 * WHY: BHV was submitted to Google AdSense review 2026-08-06 (TK-10316). Ads render
 * blank until Google approves. Steve asked to be emailed when it goes live. Google
 * emails a per-site decision from adsense-noreply@google.com naming the domain — for
 * BOTH outcomes ("...ready to show ads" vs "...need to fix some issues...") — so we
 * watch the steve-personal inbox (steveabramsdesigns@gmail.com = the AdSense account)
 * and alert on the first decision, classifying it APPROVED / ISSUES / DECISION.
 *
 * AUTH (mirrors ~/Projects/george-mcp/index.js exactly, no secret is hardcoded):
 *   Basic auth = GEORGE_BASIC_AUTH env, else macOS keychain
 *   `security find-generic-password -s dw-agents -a admin -w` -> base64("admin:"+pw).
 *   Resolved AT RUNTIME in Steve's login session (launchd GUI agent), never by Claude.
 *   Base URL = GEORGE_URL env, else the same Kamatera-tailnet George the MCP uses.
 *
 * SEND is DW->DW internal (steve-office -> info@ + steve@ designerwallcoverings.com),
 * so it needs NO external-send token. Idempotent: alerted message-ids are recorded in
 * a state file so each decision emails exactly once. Quiet (exit 0, no email) until a
 * decision arrives.
 *
 * Usage:
 *   node scripts/adsense-approval-watch.mjs            # normal (send on new decision)
 *   node scripts/adsense-approval-watch.mjs --dry-run  # detect + log, never send
 *   node scripts/adsense-approval-watch.mjs --self-test # prove George auth+search only
 */
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJ = resolve(__dirname, '..');

// ---- config ----
const SITE = 'beverlyhillsvideos.com';
const SITE_TOKEN = 'beverlyhillsvideos';                 // gmail-search token (matches the .com)
const PUB = 'pub-5278231299883833';
const ACCOUNT = 'steve-personal';                        // steveabramsdesigns@gmail.com = AdSense owner
const SUBMIT_AFTER = '2026/08/06';                       // review requested this date (TK-10316)
const ADSENSE_SENDER = 'adsense-noreply@google.com';
const NOTIFY_TO = 'info@designerwallcoverings.com';
const NOTIFY_CC = 'steve@designerwallcoverings.com';
const CONSOLE_URL = `https://adsense.google.com/adsense/u/0/${PUB}/sites/list`;

const STATE_FILE = resolve(PROJ, 'data/adsense-watch-state.json');
const LOG_FILE = resolve(PROJ, 'data/adsense-watch.log');

const args = process.argv.slice(2);
const DRY = args.includes('--dry-run');
const SELF_TEST = args.includes('--self-test');

// Tailnet default is HTTP: traffic is already WireGuard-encrypted, and George's TLS cert
// does NOT cover the kamatera.tail79cb8e.ts.net hostname (https → cert-name mismatch →
// Node `fetch failed`). Verified 2026-08-15: http://kamatera.tail79cb8e.ts.net/health → 200.
const GEORGE_URL = (process.env.GEORGE_URL || 'http://kamatera.tail79cb8e.ts.net').replace(/\/$/, '');

function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  try { mkdirSync(dirname(LOG_FILE), { recursive: true }); appendFileSync(LOG_FILE, line + '\n'); } catch { /* */ }
}

function loadAuth() {
  // env first, then the macOS keychain (the sanctioned headless source, same item the
  // george MCP falls back to). Populated once by Steve; never read by Claude.
  if (process.env.GEORGE_BASIC_AUTH) return process.env.GEORGE_BASIC_AUTH;
  try {
    const pw = execSync('security find-generic-password -s dw-agents -a admin -w', { encoding: 'utf-8' }).trim();
    if (pw) return Buffer.from(`admin:${pw}`).toString('base64');
  } catch { /* fall through */ }
  // Third source (TK-10570): the value the george MCP itself uses, already in ~/.claude.json —
  // so a launchd job resolves auth with no keychain item and no secret in the plist.
  try {
    const raw = execSync('cat "$HOME/.claude.json"', { encoding: 'utf-8' });
    const v = JSON.parse(raw)?.mcpServers?.george?.env?.GEORGE_BASIC_AUTH;
    if (v) return v;
  } catch { /* fall through */ }
  return null;
}

async function george(path, { method = 'GET', query, body, auth } = {}) {
  const url = new URL(GEORGE_URL + path);
  if (query) for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
  const res = await fetch(url, {
    method,
    headers: { Authorization: `Basic ${auth}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json; try { json = JSON.parse(text); } catch { json = null; }
  if (!res.ok) throw new Error(`George ${method} ${path} -> HTTP ${res.status}: ${text.slice(0, 200)}`);
  return json;
}

function loadState() {
  try { return JSON.parse(readFileSync(STATE_FILE, 'utf-8')); } catch { return { alerted: [] }; }
}
function saveState(s) { mkdirSync(dirname(STATE_FILE), { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); }

function classify(text) {
  const t = (text || '').toLowerCase();
  const negative = /(not approved|isn.?t ready|is not ready|not ready to show|need to fix|fix some issues|unfortunately|has not been approved|not yet ready|couldn.?t approve|can.?t show ads)/;
  const positive = /(ready to show ads|showing ads|serving ads|is approved|been approved|approved for|your site is ready|now ready|is now live|start showing ads|you.?re all set|site is live|congratulations|successfully (approved|added)|ads are (now )?(showing|live|serving))/;
  if (negative.test(t)) return 'ISSUES';
  if (positive.test(t)) return 'APPROVED';
  return 'DECISION';
}

function alertHtml(outcome, m) {
  const badge = outcome === 'APPROVED' ? '✅ APPROVED — ads can serve'
    : outcome === 'ISSUES' ? '⚠️ NEEDS FIXES — not approved yet'
    : 'ℹ️ AdSense decision received';
  return `<div style="font-family:system-ui,Arial,sans-serif;max-width:620px">
    <h2 style="margin:0 0 6px">${badge}</h2>
    <p style="margin:0 0 12px;color:#444"><b>${SITE}</b> · AdSense <code>${PUB}</code></p>
    <table style="border-collapse:collapse;font-size:14px">
      <tr><td style="padding:4px 10px 4px 0;color:#666">Google subject</td><td><b>${(m.subject || '').replace(/</g, '&lt;')}</b></td></tr>
      <tr><td style="padding:4px 10px 4px 0;color:#666;vertical-align:top">Snippet</td><td>${(m.snippet || '').replace(/</g, '&lt;').slice(0, 400)}</td></tr>
      <tr><td style="padding:4px 10px 4px 0;color:#666">From</td><td>${(m.from || '').replace(/</g, '&lt;')}</td></tr>
      <tr><td style="padding:4px 10px 4px 0;color:#666">Google sent</td><td>${m.date || ''}</td></tr>
    </table>
    <p style="margin:14px 0 4px"><a href="${CONSOLE_URL}" style="background:#1a73e8;color:#fff;padding:9px 16px;border-radius:6px;text-decoration:none">Open AdSense console</a></p>
    <p style="margin:16px 0 0;color:#999;font-size:12px">Automated by bhv-adsense-approval-watch (TK-10317). Alerts once per decision. To silence: unload com.steve.bhv-adsense-approval-watch.</p>
  </div>`;
}

// Heartbeat for dw-canary-meta-watchdog (reads data/latest.json mtime) + a status record
// so a broken run is VISIBLE, not silent (Cody's cycle-1 gap). Best-effort; never throws.
function writeHeartbeat(status, detail) {
  try {
    mkdirSync(dirname(STATE_FILE), { recursive: true });
    writeFileSync(resolve(PROJ, 'data/latest.json'),
      JSON.stringify({ ts: new Date().toISOString(), site: SITE, status, detail: detail || '' }, null, 2));
  } catch { /* */ }
}

// Fail-loud on a broken run: post ONE CNCP parking-lot card (deduped via state), so a
// permanently-unwired watcher surfaces even though it can't email (no George auth). Local, best-effort.
async function cncpAlertOnce(state, key, title, body) {
  if (state.cncpAlerted?.includes(key)) return;
  try {
    const res = await fetch('http://127.0.0.1:3333/api/parking-lot', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ project: 'beverlyhillsvideos', title, note: body, source: 'bhv-adsense-approval-watch' }),
    });
    if (res.ok) { (state.cncpAlerted ||= []).push(key); saveState(state); }
  } catch { /* CNCP down — heartbeat already recorded the failure */ }
}

async function main() {
  const auth = loadAuth();
  if (!auth) {
    log('FATAL: no George auth (set GEORGE_BASIC_AUTH or add keychain item: security add-generic-password -s dw-agents -a admin -w "<pw>")');
    writeHeartbeat('AUTH_UNWIRED', 'George keychain/env auth missing — watcher cannot run until wired');
    await cncpAlertOnce(loadState(), 'auth_unwired',
      'BHV AdSense watcher is UNWIRED (silent)',
      'scripts/adsense-approval-watch.mjs exits 2 — no George auth in keychain/env. It will NEVER email the approval until Steve wires it (TK-10317). Fix: security add-generic-password -s dw-agents -a admin -w "<pw>".');
    process.exit(2);
  }

  if (SELF_TEST) {
    const r = await george('/api/search', { auth, query: { account: ACCOUNT, query: `from:${ADSENSE_SENDER} newer_than:2y`, maxResults: 3 } });
    const n = (r?.messages || r || []).length;
    log(`SELF-TEST ok: George reachable, ${ACCOUNT} search returned ${n} AdSense msgs. Auth + search path healthy.`);
    return;
  }

  const q = `from:${ADSENSE_SENDER} ${SITE_TOKEN} after:${SUBMIT_AFTER}`;
  const r = await george('/api/search', { auth, query: { account: ACCOUNT, query: q, maxResults: 10 } });
  const msgs = r?.messages || (Array.isArray(r) ? r : []);
  log(`checked: ${msgs.length} AdSense msg(s) for ${SITE} since ${SUBMIT_AFTER}`);

  const state = loadState();
  // Trust the search scoping (from:adsense-noreply@google.com + the domain token +
  // after:submit-date). Do NOT re-filter on subject/snippet — the snippet is only a
  // ~200-char body preview, so an approval that names the domain later in the body would
  // pass the search but be wrongly dropped here (a silent false-negative — the worst
  // failure for a "tell me when it's live" watcher). Dedup on message-id only.
  const fresh = msgs.filter((m) => m.id && !state.alerted.includes(m.id));
  if (!fresh.length) { log('no new decision — staying quiet'); writeHeartbeat('OK', `no decision yet (${msgs.length} msgs scanned)`); return; }

  for (const m of fresh) {
    const outcome = classify(`${m.subject || ''} ${m.snippet || ''}`);
    log(`DECISION found (${outcome}) id=${m.id} subject="${m.subject}"`);
    if (DRY) { log('  --dry-run: not sending'); continue; }
    await george('/api/send', {
      method: 'POST', auth,
      body: {
        account: 'steve-office', to: NOTIFY_TO, cc: NOTIFY_CC,
        subject: `[BHV AdSense] ${outcome}: ${SITE} — ${m.subject || 'decision received'}`,
        body: alertHtml(outcome, m),
      },
    });
    log(`  emailed ${NOTIFY_TO} (cc ${NOTIFY_CC})`);
    state.alerted.push(m.id);
  }
  if (!DRY) saveState(state);
  writeHeartbeat('DECISION_SENT', `${fresh.length} decision(s) alerted`);
}

main().catch((e) => { log(`ERROR: ${e.message}`); writeHeartbeat('ERROR', e.message); process.exit(1); });