[object Object]

← back to Marketing Command Center

clients panel: FM client DB (43k) + CSV prospect groups, LinkedIn+Instagram discovery

0ed9d950228c81edc80e692605dba835e51e9e24 · 2026-07-16 10:54:48 -0700 · steve

- Clients & Prospects panel (Audience nav): per-group tabs, lazy-load, name
  filter, email + website + LinkedIn(all-search) + IG(google) per contact,
  contacted tracking, 300-row cap. Reads data/clients-manifest.json.
- crawl-csv-emails.py enriches Google-Places CSVs for contact emails locally
  (~70% pilot hit); build-clients-manifest.js scans staged data.
- Fleet-managed via refresh-clients.sh + com.steve.mcc-clients-refresh (weekly).
  Big datasets gitignored (regenerated + rsynced). NOT uploaded to CC (gated).

Files touched

Diff

commit 0ed9d950228c81edc80e692605dba835e51e9e24
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Jul 16 10:54:48 2026 -0700

    clients panel: FM client DB (43k) + CSV prospect groups, LinkedIn+Instagram discovery
    
    - Clients & Prospects panel (Audience nav): per-group tabs, lazy-load, name
      filter, email + website + LinkedIn(all-search) + IG(google) per contact,
      contacted tracking, 300-row cap. Reads data/clients-manifest.json.
    - crawl-csv-emails.py enriches Google-Places CSVs for contact emails locally
      (~70% pilot hit); build-clients-manifest.js scans staged data.
    - Fleet-managed via refresh-clients.sh + com.steve.mcc-clients-refresh (weekly).
      Big datasets gitignored (regenerated + rsynced). NOT uploaded to CC (gated).
---
 .gitignore                                  |   2 +
 public/app.js                               |   2 +-
 public/data/clients-manifest.json           |  12 +++
 public/panels/clients.html                  |  27 ++++++
 public/panels/clients.js                    | 101 ++++++++++++++++++++
 scripts/build-clients-manifest.js           |  24 +++++
 scripts/com.steve.mcc-clients-refresh.plist |  24 +++++
 scripts/crawl-csv-emails.py                 | 139 ++++++++++++++++++++++++++++
 scripts/refresh-clients.sh                  |  44 +++++++++
 9 files changed, 374 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index d8d1b8b..e48dee7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,5 @@ data/meta-token-health*
 # All Calendars — regenerable projection snapshot (13MB) + runtime drag overrides
 data/calendars-activation.json
 data/calendars-overrides.json
+public/data/clients-fm.json
+public/data/prospects-*.json
diff --git a/public/app.js b/public/app.js
index 0875413..517dca5 100644
--- a/public/app.js
+++ b/public/app.js
@@ -12,7 +12,7 @@ const GROUPS = [
   { name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
   { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'sounds', 'templates'] },
   { name: 'Email', ids: ['constant-contact', 'send-times'] },
-  { name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
+  { name: 'Audience', ids: ['clients', 'segments', 'profiles', 'segment-perf'] },
   { name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
   { name: 'Analytics', ids: ['performance', 'follow-counts', 'ab-tests'] },
   { name: 'Planning', ids: ['calendars', 'calendar', 'vendors'] },
diff --git a/public/data/clients-manifest.json b/public/data/clients-manifest.json
new file mode 100644
index 0000000..4731b11
--- /dev/null
+++ b/public/data/clients-manifest.json
@@ -0,0 +1,12 @@
+{
+  "updated": "2026-07-16",
+  "datasets": [
+    {
+      "id": "fm-clients",
+      "label": "FileMaker Clients",
+      "file": "clients-fm.json",
+      "kind": "clients",
+      "count": 43234
+    }
+  ]
+}
\ No newline at end of file
diff --git a/public/panels/clients.html b/public/panels/clients.html
new file mode 100644
index 0000000..2f408ae
--- /dev/null
+++ b/public/panels/clients.html
@@ -0,0 +1,27 @@
+<div id="cl-root" style="max-width:960px;">
+  <div class="muted-banner" id="cl-banner">Loading client & prospect groups…</div>
+
+  <div class="card">
+    <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
+      <h2 style="margin:0;">Clients &amp; Prospects</h2>
+      <span class="pill" id="cl-groupcount">0 groups</span>
+      <span style="flex:1;"></span>
+      <span class="muted" id="cl-note" style="font-size:11.5px;">Staged locally — not uploaded to Constant Contact.</span>
+    </div>
+    <div id="cl-tabs" class="row" style="gap:6px;margin-top:12px;flex-wrap:wrap;"></div>
+  </div>
+
+  <div class="card" id="cl-controls" hidden>
+    <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
+      <b id="cl-grouptitle" style="font-size:14px;"></b>
+      <span class="pill" id="cl-progress">0 / 0 contacted</span>
+      <span class="pill" id="cl-emailcount"></span>
+      <span style="flex:1;"></span>
+      <button class="btn ghost" id="cl-reset" style="font-size:11px;padding:5px 9px;">Reset checks</button>
+    </div>
+    <input id="cl-search" type="text" placeholder="Filter by name / company / city…" style="margin-top:12px;width:100%;">
+    <div class="muted" id="cl-groupnote" style="font-size:11.5px;margin-top:8px;"></div>
+  </div>
+
+  <div id="cl-list"></div>
+</div>
diff --git a/public/panels/clients.js b/public/panels/clients.js
new file mode 100644
index 0000000..293cf0f
--- /dev/null
+++ b/public/panels/clients.js
@@ -0,0 +1,101 @@
+/* global window, document, location */
+// Clients & Prospects panel — browse FileMaker clients + Google-Places prospect
+// groups, each lazy-loaded, with LinkedIn + Instagram discovery links, emails,
+// and per-contact "contacted" tracking (localStorage). Reads a manifest at
+// /data/clients-manifest.json listing the staged datasets. Nothing here uploads
+// to Constant Contact.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['clients'] = {
+  async init(root) {
+    const ORIGIN = location.origin;
+    const $ = s => root.querySelector(s);
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const jget = async u => { const r = await fetch(ORIGIN + u, { credentials: 'same-origin' }); if (!r.ok) throw new Error(u + ' → ' + r.status); return r.json(); };
+    const CAP = 300;
+    const CK = id => 'mcc_cl_' + id;                          // localStorage key per group
+    const done = id => { try { return new Set(JSON.parse(localStorage.getItem(CK(id)) || '[]')); } catch { return new Set(); } };
+    const save = (id, set) => { try { localStorage.setItem(CK(id), JSON.stringify([...set])); } catch {} };
+
+    // Normalize a record from either a "clients" (FM) or "prospects" (CSV) dataset.
+    function norm(rec, kind) {
+      if (kind === 'prospects') {
+        return { id: (rec.website || rec.title || '').toLowerCase(), label: rec.title || '(business)', sub: [rec.city].filter(Boolean).join(' · '),
+                 email: rec.email || '', website: rec.website || '', phone: rec.phone || '', q: [rec.title, rec.city].filter(Boolean).join(' ') };
+      }
+      const label = rec.name || rec.company || '(client)';
+      const sub = rec.name ? [rec.company, [rec.city, rec.state].filter(Boolean).join(', ')].filter(Boolean).join(' · ')
+                           : [rec.city, rec.state].filter(Boolean).join(', ');
+      return { id: rec.account || (rec.company + '|' + rec.name).toLowerCase(), label, sub, email: rec.email || '', website: '', phone: rec.phone || '',
+               q: [rec.name, rec.company, rec.city].filter(Boolean).join(' ') };
+    }
+    const liURL = q => 'https://www.linkedin.com/search/results/all/?keywords=' + encodeURIComponent(q);
+    const igURL = q => 'https://www.google.com/search?q=' + encodeURIComponent(q + ' instagram');
+
+    let MANIFEST = [], current = null, RECS = [];
+
+    try {
+      const m = await jget('/data/clients-manifest.json');
+      MANIFEST = m.datasets || [];
+    } catch { $('#cl-banner').textContent = 'No client datasets staged yet (data/clients-manifest.json missing).'; return; }
+    if (!MANIFEST.length) { $('#cl-banner').textContent = 'No client datasets staged yet.'; return; }
+
+    $('#cl-banner').hidden = true;
+    $('#cl-groupcount').textContent = MANIFEST.length + ' groups';
+    $('#cl-tabs').innerHTML = MANIFEST.map(d =>
+      `<button class="btn ghost" data-id="${esc(d.id)}">${esc(d.label)}${d.count ? ` <span class="pill" style="font-size:9px;">${d.count.toLocaleString()}</span>` : ''}</button>`).join('');
+    $('#cl-tabs').querySelectorAll('button').forEach(b => b.onclick = () => selectGroup(b.dataset.id));
+
+    async function selectGroup(id) {
+      const d = MANIFEST.find(x => x.id === id); if (!d) return;
+      current = d;
+      $('#cl-tabs').querySelectorAll('button').forEach(b => b.className = 'btn ' + (b.dataset.id === id ? 'gold' : 'ghost'));
+      $('#cl-controls').hidden = false;
+      $('#cl-grouptitle').textContent = d.label;
+      $('#cl-list').innerHTML = '<div class="loading">Loading…</div>';
+      let data;
+      try { data = await jget('/data/' + d.file); } catch { $('#cl-list').innerHTML = '<div class="card muted">Could not load ' + esc(d.file) + '.</div>'; return; }
+      const arr = data.clients || data.businesses || [];
+      RECS = arr.map(r => norm(r, d.kind));
+      $('#cl-emailcount').textContent = (data.with_email != null ? data.with_email.toLocaleString() : RECS.filter(r => r.email).length.toLocaleString()) + ' with email';
+      $('#cl-groupnote').textContent = `Source: ${data.source || d.file}${data.updated ? ' · updated ' + data.updated : ''}. LinkedIn opens a people/company search; IG opens a Google “<name> instagram” lookup — open the right match and follow.`;
+      $('#cl-search').value = '';
+      $('#cl-search').oninput = render;
+      $('#cl-reset').onclick = () => { if (confirm('Clear contacted checks for ' + d.label + '?')) { save(d.id, new Set()); render(); } };
+      render();
+    }
+
+    function render() {
+      const d = current; const set = done(d.id);
+      const q = ($('#cl-search').value || '').trim().toLowerCase();
+      const matches = q ? RECS.filter(r => r.q.toLowerCase().includes(q)) : RECS;
+      const shown = matches.slice(0, CAP);
+      const more = matches.length - shown.length;
+      $('#cl-list').innerHTML = `<div class="card"><div>${shown.map(r => {
+        const on = set.has(r.id);
+        const mail = r.email ? `<a href="mailto:${esc(r.email)}" style="font-size:11.5px;" title="${esc(r.email)}">✉︎ ${esc(r.email)}</a>` : '<span class="muted" style="font-size:11px;">no email</span>';
+        const web = r.website ? `<a href="${esc(r.website)}" target="_blank" rel="noopener" style="font-size:11px;">site ↗</a>` : '';
+        const sub = r.sub ? `<span class="muted" style="font-size:11px;">${esc(r.sub)}</span>` : '';
+        return `<label class="cl-row" style="display:flex;align-items:center;gap:9px;padding:7px 0;border-bottom:1px solid var(--line,#eee);${on ? 'opacity:.5;' : ''}">
+          <input type="checkbox" data-id="${esc(r.id)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;">
+          <span style="flex:1;display:flex;flex-direction:column;line-height:1.3;min-width:0;">
+            <b style="font-size:13px;${on ? 'text-decoration:line-through;' : ''}">${esc(r.label)}</b>
+            ${sub}
+            <span style="display:flex;gap:12px;flex-wrap:wrap;margin-top:2px;">${mail} ${web}</span>
+          </span>
+          <a class="btn ghost" href="${liURL(r.q)}" target="_blank" rel="noopener" style="font-size:10.5px;padding:4px 8px;" onclick="event.stopPropagation()">in ↗</a>
+          <a class="btn ghost" href="${igURL(r.q)}" target="_blank" rel="noopener" style="font-size:10.5px;padding:4px 8px;" onclick="event.stopPropagation()">IG ↗</a>
+        </label>`;
+      }).join('')}</div>${more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length.toLocaleString()} — type in the filter to narrow.</div>` : ''}</div>`;
+      $('#cl-progress').textContent = `${set.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
+      $('#cl-list').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
+        const s = done(d.id); cb.checked ? s.add(cb.dataset.id) : s.delete(cb.dataset.id); save(d.id, s);
+        const row = cb.closest('.cl-row'); if (row) { row.style.opacity = cb.checked ? '.5' : ''; const b = row.querySelector('b'); if (b) b.style.textDecoration = cb.checked ? 'line-through' : ''; }
+        $('#cl-progress').textContent = `${s.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
+      });
+    }
+
+    // Auto-open the smallest group so the panel isn't empty on first view.
+    const smallest = [...MANIFEST].sort((a, b) => (a.count || 1e9) - (b.count || 1e9))[0];
+    if (smallest) selectGroup(smallest.id);
+  },
+};
diff --git a/scripts/build-clients-manifest.js b/scripts/build-clients-manifest.js
new file mode 100644
index 0000000..e907bc2
--- /dev/null
+++ b/scripts/build-clients-manifest.js
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+/*
+ * build-clients-manifest.js — scan public/data for staged client/prospect
+ * datasets and (re)write public/data/clients-manifest.json, which the Clients
+ * panel reads. Run after fetch-clients.js or any crawl-csv-emails.py run.
+ */
+const fs = require('fs');
+const path = require('path');
+const DIR = path.join(__dirname, '..', 'public', 'data');
+const pretty = s => s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+
+const datasets = [];
+if (fs.existsSync(path.join(DIR, 'clients-fm.json'))) {
+  const d = JSON.parse(fs.readFileSync(path.join(DIR, 'clients-fm.json'), 'utf8'));
+  datasets.push({ id: 'fm-clients', label: 'FileMaker Clients', file: 'clients-fm.json', kind: 'clients', count: d.total || (d.clients || []).length });
+}
+for (const f of fs.readdirSync(DIR).filter(f => /^prospects-.*\.json$/.test(f)).sort()) {
+  const slug = f.replace(/^prospects-|\.json$/g, '');
+  const d = JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'));
+  datasets.push({ id: slug, label: pretty(slug), file: f, kind: 'prospects', count: d.total || (d.businesses || []).length });
+}
+fs.writeFileSync(path.join(DIR, 'clients-manifest.json'), JSON.stringify({ updated: new Date().toISOString().slice(0, 10), datasets }, null, 2));
+console.log(`clients-manifest.json — ${datasets.length} groups:`);
+datasets.forEach(d => console.log(`   ${d.id} · ${d.label} · ${d.count}`));
diff --git a/scripts/com.steve.mcc-clients-refresh.plist b/scripts/com.steve.mcc-clients-refresh.plist
new file mode 100644
index 0000000..2894653
--- /dev/null
+++ b/scripts/com.steve.mcc-clients-refresh.plist
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>com.steve.mcc-clients-refresh</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>/Users/macstudio3/Projects/marketing-command-center/scripts/refresh-clients.sh</string>
+  </array>
+  <!-- Weekly: Sunday 03:15. Kickstart on demand to run immediately. -->
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Weekday</key><integer>0</integer>
+    <key>Hour</key><integer>3</integer>
+    <key>Minute</key><integer>15</integer>
+  </dict>
+  <key>StandardOutPath</key><string>/tmp/mcc-clients-refresh.out</string>
+  <key>StandardErrorPath</key><string>/tmp/mcc-clients-refresh.err</string>
+  <key>RunAtLoad</key><false/>
+  <key>ProcessType</key><string>Background</string>
+  <key>LowPriorityIO</key><true/>
+</dict>
+</plist>
diff --git a/scripts/crawl-csv-emails.py b/scripts/crawl-csv-emails.py
new file mode 100644
index 0000000..e1754d3
--- /dev/null
+++ b/scripts/crawl-csv-emails.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""
+crawl-csv-emails.py — LOCAL, free email enrichment for a Google-Places CSV.
+
+For each business row with a website: fetch the homepage, discover a
+contact/about page, and scrape the best contact email. Writes a staged
+"prospect group" JSON that the MCC Clients panel reads. Nothing is uploaded
+to Constant Contact — this only stages a local dataset.
+
+  python3 scripts/crawl-csv-emails.py <input.csv> --slug la-architects [--limit 40] [--workers 12]
+
+Output: public/data/prospects-<slug>.json
+"""
+import csv, sys, os, re, json, argparse, urllib.request, urllib.parse, datetime
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+csv.field_size_limit(10_000_000)
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
+EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
+JUNK_DOMAINS = ("sentry", "example.", "wix.com", "wixpress", "godaddy", "squarespace",
+                "schema.org", "w3.org", "googleapis", "gstatic", "jquery", "cloudflare",
+                "fontawesome", "sentry.io", "@2x", "@sentry", "core.min", "webpack",
+                "domain.com", "yourdomain", "yoursite", "email.com", "sentry-next",
+                "test.com", "mydomain")
+JUNK_LOCAL = ("no-reply", "noreply", "donotreply", "user", "name", "email", "yourname",
+              "firstname", "lastname", "your-email", "youremail", "example", "sentry")
+ROLE_PREFIXES = ("info", "contact", "hello", "sales", "studio", "design", "office", "admin")
+CONTACT_HINTS = ("contact", "about", "team", "connect", "reach", "hello")
+
+def norm_url(u):
+    u = (u or "").strip()
+    if not u: return None
+    if not u.startswith(("http://", "https://")): u = "http://" + u
+    return u
+
+def host_of(u):
+    try: return urllib.parse.urlparse(u).netloc.lower().replace("www.", "")
+    except Exception: return ""
+
+def fetch(url, timeout=8):
+    try:
+        req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html"})
+        with urllib.request.urlopen(req, timeout=timeout) as r:
+            ct = r.headers.get("Content-Type", "")
+            if "html" not in ct and "text" not in ct: return "", r.geturl()
+            return r.read(600_000).decode("utf-8", "replace"), r.geturl()
+    except Exception:
+        return "", url
+
+def valid_email(e, site_host):
+    el = e.lower()
+    if el.count("@") != 1: return False
+    if any(j in el for j in JUNK_DOMAINS): return False
+    if el.split("@")[0] in JUNK_LOCAL: return False
+    if re.search(r"\.(png|jpg|jpeg|gif|svg|webp|css|js)$", el): return False
+    if len(el) > 60: return False
+    return True
+
+def score_email(e, site_host):
+    el = e.lower(); dom = el.split("@")[1]; local = el.split("@")[0]
+    s = 0
+    if site_host and (dom == site_host or dom.endswith("." + site_host) or site_host.endswith("." + dom)): s += 10
+    if any(local.startswith(p) for p in ROLE_PREFIXES): s += 3
+    if dom.endswith((".com", ".net", ".co", ".studio", ".design")): s += 1
+    return s
+
+def find_contact_links(html, base):
+    links = set()
+    for m in re.finditer(r'href=["\']([^"\']+)["\']', html, re.I):
+        href = m.group(1)
+        if any(h in href.lower() for h in CONTACT_HINTS):
+            links.add(urllib.parse.urljoin(base, href))
+    return list(links)[:3]
+
+def emails_from(html, site_host):
+    found = set()
+    for m in re.finditer(r'mailto:([^"\'?>]+)', html, re.I):
+        found.add(m.group(1).strip())
+    for m in EMAIL_RE.finditer(html):
+        found.add(m.group(0))
+    return [e for e in found if valid_email(e, site_host)]
+
+def enrich(row):
+    website = norm_url(row.get("website"))
+    biz = {"title": (row.get("title") or "").strip(),
+           "city": (row.get("city") or "").strip(),
+           "phone": (row.get("phone") or "").strip(),
+           "website": website, "email": "", "emails": []}
+    if not website: return biz
+    site_host = host_of(website)
+    html, final = fetch(website)
+    site_host = host_of(final) or site_host
+    emails = set(emails_from(html, site_host))
+    if len(emails) == 0 and html:
+        for link in find_contact_links(html, final):
+            chtml, _ = fetch(link)
+            emails |= set(emails_from(chtml, site_host))
+            if emails: break
+    ranked = sorted(emails, key=lambda e: score_email(e, site_host), reverse=True)
+    biz["emails"] = ranked[:5]
+    biz["email"] = ranked[0] if ranked else ""
+    return biz
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("csv"); ap.add_argument("--slug", required=True)
+    ap.add_argument("--limit", type=int, default=0); ap.add_argument("--workers", type=int, default=12)
+    ap.add_argument("--out-dir", default=os.path.join(os.path.dirname(__file__), "..", "public", "data"))
+    a = ap.parse_args()
+
+    rows = []
+    with open(a.csv, newline="", encoding="utf-8", errors="replace") as fh:
+        for row in csv.DictReader(fh):
+            if (row.get("website") or "").strip():
+                rows.append(row)
+    if a.limit: rows = rows[:a.limit]
+    print(f"[{a.slug}] {len(rows)} businesses with a website to crawl · workers={a.workers}", flush=True)
+
+    out, done, hits = [], 0, 0
+    with ThreadPoolExecutor(max_workers=a.workers) as ex:
+        futs = {ex.submit(enrich, r): r for r in rows}
+        for f in as_completed(futs):
+            b = f.result(); out.append(b); done += 1
+            if b["email"]: hits += 1
+            if done % 25 == 0 or done == len(rows):
+                print(f"\r  {done}/{len(rows)} crawled · {hits} emails found", end="", flush=True)
+    print()
+
+    os.makedirs(a.out_dir, exist_ok=True)
+    path = os.path.join(a.out_dir, f"prospects-{a.slug}.json")
+    payload = {"source": os.path.basename(a.csv), "group": a.slug,
+               "updated": datetime.date.today().isoformat(),
+               "total": len(out), "with_email": hits, "businesses": out}
+    with open(path, "w") as fh:
+        json.dump(payload, fh)
+    print(f"✓ wrote {path} — {len(out)} businesses, {hits} with an email ({100*hits//max(1,len(out))}%)  [$0 local]")
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/refresh-clients.sh b/scripts/refresh-clients.sh
new file mode 100644
index 0000000..7836a0c
--- /dev/null
+++ b/scripts/refresh-clients.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# refresh-clients.sh — fleet-managed refresh of the Clients & Prospects datasets
+# that back the MCC "Clients" panel. Re-pulls FileMaker clients, re-crawls the
+# Google-Places CSVs for contact emails (local, free), rebuilds the manifest,
+# and pushes the refreshed data to the live MCC on Kamatera.
+#
+# Run by launchd (com.steve.mcc-clients-refresh) weekly + on-demand kickstart.
+# Idempotent + single-instance (flock). Nothing here uploads to Constant Contact.
+set -uo pipefail
+PROJ="$HOME/Projects/marketing-command-center"
+LOG=/tmp/mcc-clients-refresh.log
+LOCK=/tmp/mcc-clients-refresh.lock
+DL="$HOME/Downloads"
+exec >>"$LOG" 2>&1
+
+# single instance
+exec 9>"$LOCK"; flock -n 9 || { echo "$(date '+%F %T') already running — skip"; exit 0; }
+echo "===== $(date '+%F %T') mcc-clients-refresh START ====="
+cd "$PROJ" || exit 1
+
+# 1. FileMaker clients (43k) → public/data/clients-fm.json
+echo "-- FM clients"
+node "$HOME/Projects/filemaker-mcp/scripts/fetch-clients.js" || echo "!! FM extract failed (keeping prior clients-fm.json)"
+
+# 2. Google-Places CSVs → contact-email enrichment (skips any source not present)
+crawl(){ local src; src=$(ls $1 2>/dev/null | head -1); [ -n "$src" ] && python3 scripts/crawl-csv-emails.py "$src" --slug "$2" --workers 14 || echo "-- skip $2 (no source csv)"; }
+crawl "$DL/*2000LAArchitects*dataset_crawler-google-places*"            la-architects
+crawl "$DL/*2000_arch.id.plus.LosAngeles*dataset_crawler-google-places*" la-arch-interior-design
+crawl "$DL/*1138architects-SF*dataset_crawler-google-places*"           sf-architects
+crawl "$DL/*nyc*dataset_crawler-google-places*"                         nyc-architects
+crawl "$DL/*Santa_Barbara*dataset_crawler-google-places*"              santa-barbara
+crawl "$DL/dataset_crawler-google-places_2026-07-10_07-01-32-993*"      places-batch-1
+
+# 3. Manifest the panel reads
+echo "-- manifest"
+node scripts/build-clients-manifest.js
+
+# 4. Push refreshed data to the live MCC (behind Basic auth; not customer-facing)
+echo "-- rsync data → Kamatera"
+RP="root@45.61.58.125:/root/DW-Agents/marketing-command-center/public/data/"
+rsync -az public/data/clients-fm.json public/data/prospects-*.json public/data/clients-manifest.json "$RP" \
+  && echo "-- data pushed" || echo "!! rsync to Kamatera failed"
+
+echo "===== $(date '+%F %T') mcc-clients-refresh DONE ====="

← bfbe222 followlist: add build:followlist refresh script + CRCP broke  ·  back to Marketing Command Center  ·  register clients module in registry so the panel mounts in t 2ba021c →