← 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, '<')}</b></td></tr>
<tr><td style="padding:4px 10px 4px 0;color:#666;vertical-align:top">Snippet</td><td>${(m.snippet || '').replace(/</g, '<').slice(0, 400)}</td></tr>
<tr><td style="padding:4px 10px 4px 0;color:#666">From</td><td>${(m.from || '').replace(/</g, '<')}</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); });