← back to Beverlyhillsvideos
Add BHV AdSense approval watcher (daily launchd, emails on approve/reject decision)
ed4aa5787247e9b6af00a7ea23febbf732b10418 · 2026-08-06 16:08:33 -0700 · Steve Abrams
Files touched
A scripts/adsense-approval-watch.mjs
Diff
commit ed4aa5787247e9b6af00a7ea23febbf732b10418
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 6 16:08:33 2026 -0700
Add BHV AdSense approval watcher (daily launchd, emails on approve/reject decision)
---
scripts/adsense-approval-watch.mjs | 161 +++++++++++++++++++++++++++++++++++++
1 file changed, 161 insertions(+)
diff --git a/scripts/adsense-approval-watch.mjs b/scripts/adsense-approval-watch.mjs
new file mode 100644
index 0000000..04b3ad8
--- /dev/null
+++ b/scripts/adsense-approval-watch.mjs
@@ -0,0 +1,161 @@
+#!/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');
+
+const GEORGE_URL = (process.env.GEORGE_URL || 'https://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 */ }
+ 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)/;
+ const positive = /(ready to show ads|now showing ads|now serving ads|is approved|been approved|approved for adsense|your site is ready|now ready to|is now live|start showing ads)/;
+ 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>`;
+}
+
+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>")'); 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();
+ const fresh = msgs.filter((m) => {
+ const text = `${m.subject || ''} ${m.snippet || ''}`;
+ return /beverlyhillsvideos/i.test(text) && !state.alerted.includes(m.id);
+ });
+ if (!fresh.length) { log('no new decision — staying quiet'); 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);
+}
+
+main().catch((e) => { log(`ERROR: ${e.message}`); process.exit(1); });
← 8e01c07 stores: add script-gen tooling; reset store ledger for clean
·
back to Beverlyhillsvideos
·
stores: build out 7 verified-open stores → 92 total; mark 5 814469e →