← back to Commercialrealestate
CRCP email-alerts: opt-in gov-agent segment (DTD verdict C) — per-metro new-licensee diff, default stays low-noise
0a6e0fcfb9977beb50953bbcdbe6cd69fc051cfe · 2026-07-12 11:58:37 -0700 · Steve
Files touched
M scripts/email-alerts.js
Diff
commit 0a6e0fcfb9977beb50953bbcdbe6cd69fc051cfe
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 12 11:58:37 2026 -0700
CRCP email-alerts: opt-in gov-agent segment (DTD verdict C) — per-metro new-licensee diff, default stays low-noise
---
scripts/email-alerts.js | 58 ++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 50 insertions(+), 8 deletions(-)
diff --git a/scripts/email-alerts.js b/scripts/email-alerts.js
index 1716b81..ffef581 100644
--- a/scripts/email-alerts.js
+++ b/scripts/email-alerts.js
@@ -104,19 +104,38 @@ function loadConfig() {
console.log(`seeded default config → ${CONFIG_PATH}`);
return seed;
}
-function loadState() { return loadJSON(STATE_PATH, { last_run: null, seen: { listings: [], brokers: [] } }); }
+function loadState() { const s = loadJSON(STATE_PATH, { last_run: null, seen: { listings: [], brokers: [] } }); s.seen = s.seen || {}; s.seen.listings = s.seen.listings || []; s.seen.brokers = s.seen.brokers || []; s.seen.gov = s.seen.gov || {}; return s; }
+
+// Gov-agent segment (DTD verdict C, opt-in): read data/gov/<metro>.json for the
+// metros any opt-in subscriber wants, return the NEW licensees vs seen.gov[metro].
+function loadGovForMetros(metros) {
+ const out = {}; // metro -> [{name, firm, license_number, license_type, city}]
+ for (const m of metros) {
+ const p = path.join(DATA, 'gov', `${m}.json`);
+ if (!fs.existsSync(p)) continue;
+ const arr = loadJSON(p, []);
+ if (Array.isArray(arr)) out[m] = arr.filter(a => a && a.license_number).map(a => ({
+ name: a.name, firm: a.firm, license_number: String(a.license_number), license_type: a.license_type, city: a.city,
+ }));
+ }
+ return out;
+}
// ---- digest --------------------------------------------------------------
function money(n) { const v = Number(n); return isFinite(v) && v > 0 ? '$' + v.toLocaleString() : '—'; }
function metroLabel(m) { return (m || 'other').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); }
-function buildDigestHTML(sub, newListings, newBrokers) {
+function buildDigestHTML(sub, newListings, newBrokers, govNewByMetro) {
const wants = new Set(sub.metros);
const all = wants.has('*');
const fL = newListings.filter(l => all || wants.has(l.metro));
const fB = newBrokers.filter(b => all || wants.has(b.metro));
const showListings = sub.segments.includes('listings') ? fL : [];
const showBrokers = sub.segments.includes('brokers') ? fB : [];
+ // opt-in gov-agent segment (DTD verdict C)
+ const showGov = sub.segments.includes('gov-agents')
+ ? Object.entries(govNewByMetro || {}).filter(([m]) => all || wants.has(m)).flatMap(([m, arr]) => arr.map(a => ({ ...a, metro: m })))
+ : [];
const byMetro = arr => arr.reduce((m, x) => { (m[x.metro || 'other'] ||= []).push(x); return m; }, {});
const lMetros = byMetro(showListings);
@@ -129,8 +148,8 @@ function buildDigestHTML(sub, newListings, newBrokers) {
</div>
<div style="border:1px solid #e6e8ec;border-top:none;border-radius:0 0 12px 12px;padding:24px 26px">`;
- if (!showListings.length && !showBrokers.length) {
- body += `<p style="color:#6b7280">No new listings or brokers in your markets since the last update.</p>`;
+ if (!showListings.length && !showBrokers.length && !showGov.length) {
+ body += `<p style="color:#6b7280">No new activity in your markets since the last update.</p>`;
}
if (showListings.length) {
@@ -155,11 +174,21 @@ function buildDigestHTML(sub, newListings, newBrokers) {
}
}
+ if (showGov.length) {
+ const govByMetro = showGov.reduce((m, x) => { (m[x.metro || 'other'] ||= []).push(x); return m; }, {});
+ body += `<h2 style="font-size:16px;margin:20px 0 12px">🪪 ${showGov.length} newly-licensed agent${showGov.length>1?'s':''}</h2>`;
+ for (const [metro, items] of Object.entries(govByMetro)) {
+ body += `<div style="font-size:12px;font-weight:700;color:#C8A24B;text-transform:uppercase;letter-spacing:1px;margin:14px 0 6px">${metroLabel(metro)}</div>`;
+ for (const g of items.slice(0, 40)) {
+ body += `<div style="font-size:13px;padding:5px 0;border-bottom:1px solid #f2f4f6"><b>${g.name || 'Unknown'}</b>${g.firm?` · ${g.firm}`:''}${g.license_type?` <span style="color:#8A93A2">(${g.license_type})</span>`:''}${g.city?` <span style="color:#8A93A2">— ${g.city}</span>`:''}</div>`;
+ }
+ }
+ }
body += `<div style="margin-top:22px;padding-top:16px;border-top:1px solid #e6e8ec;font-size:12px;color:#8A93A2">
You're getting the <b>${sub.cadence}</b> digest for ${all?'all markets':sub.metros.map(metroLabel).join(', ')}.
Open <a href="https://crcp.agentabrams.com" style="color:#C8A24B">CRCP</a> to work these leads.
</div></div></div>`;
- return { html: body, listingCount: showListings.length, brokerCount: showBrokers.length };
+ return { html: body, listingCount: showListings.length, brokerCount: showBrokers.length, govCount: showGov.length };
}
// ---- George send (GATED) -------------------------------------------------
@@ -193,6 +222,18 @@ function georgeSend({ to, subject, html, from }) {
const newListings = listings.filter(l => !seenL.has(l.id));
const newBrokers = brokers.filter(b => !seenB.has(b.id));
+ // Gov-agents segment (opt-in, DTD verdict C): only diff metros some subscriber wants.
+ const govSubs = cfg.subscribers.filter(s => (s.segments || []).includes('gov-agents'));
+ const govMetros = new Set();
+ for (const s of govSubs) { if (s.metros.includes('*')) { Object.keys(state.seen.gov).forEach(m => govMetros.add(m)); ['los-angeles','miami','texas','nyc','las-vegas'].forEach(m => govMetros.add(m)); } else s.metros.forEach(m => govMetros.add(m)); }
+ const govAll = govSubs.length ? loadGovForMetros([...govMetros]) : {};
+ const govNewByMetro = {};
+ for (const [m, arr] of Object.entries(govAll)) {
+ const seenG = new Set(state.seen.gov[m] || []);
+ const fresh = arr.filter(a => !seenG.has(a.license_number));
+ if (fresh.length) govNewByMetro[m] = fresh;
+ }
+
console.log(`snapshot: ${listings.length} listings (${newListings.length} new), ${brokers.length} brokers (${newBrokers.length} new)`);
console.log(`mode: ${SEND ? 'SEND (gated)' : 'DRY-RUN (no send, state unchanged unless --commit-state)'}`);
@@ -200,9 +241,9 @@ function georgeSend({ to, subject, html, from }) {
const results = [];
let sendFailures = 0;
for (const sub of cfg.subscribers) {
- const { html, listingCount, brokerCount } = buildDigestHTML(sub, newListings, newBrokers);
- if (!listingCount && !brokerCount) { console.log(` ${sub.email}: nothing new — skip`); continue; }
- const subject = `CRCP: ${listingCount} new listing${listingCount!==1?'s':''}${brokerCount?` · ${brokerCount} new broker${brokerCount!==1?'s':''}`:''}`;
+ const { html, listingCount, brokerCount, govCount } = buildDigestHTML(sub, newListings, newBrokers, govNewByMetro);
+ if (!listingCount && !brokerCount && !govCount) { console.log(` ${sub.email}: nothing new — skip`); continue; }
+ const subject = `CRCP: ${listingCount} new listing${listingCount!==1?'s':''}${brokerCount?` · ${brokerCount} new broker${brokerCount!==1?'s':''}`:''}${govCount?` · ${govCount} new agent${govCount!==1?'s':''}`:''}`;
const file = path.join(OUT, `${date}-${sub.email.replace(/[^a-z0-9]/gi,'_')}.html`);
fs.writeFileSync(file, html);
results.push({ email: sub.email, subject, listingCount, brokerCount, file });
@@ -221,6 +262,7 @@ function georgeSend({ to, subject, html, from }) {
if (advance) {
state.seen.listings = listings.map(l => l.id);
state.seen.brokers = brokers.map(b => b.id);
+ for (const [m, arr] of Object.entries(govAll)) state.seen.gov[m] = arr.map(a => a.license_number);
state.last_run = new Date().toISOString();
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
console.log(`state advanced → ${path.relative(ROOT, STATE_PATH)} (${SEND ? 'post-send' : 'baseline seed'})`);
← 9500317 broker-email: contrarian-driven hardening — exact last-name-
·
back to Commercialrealestate
·
CRCP listings: daily rotating refresh (DTD verdict C) — ~3 m cd428e0 →