[object Object]

← 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

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, '&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>`;
+}
+
+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 →