[object Object]

← back to Re Flyer Aggregator

interactive live viz (click color bar -> enter its records) + advertising.crcp viewer (sortable ad-intel table + CSV) + crawler = ALL CRE firms nationally, comprehensive ad detection (Bing/DoubleClick/Criteo/Taboola/etc) + flyer/PDF link extraction; gitignore generated crawl data (TK-10708)

3aabab21459fb5897d5f8efa76bc227a223c0542 · 2026-08-19 16:28:36 -0700 · Steve Abrams

Files touched

Diff

commit 3aabab21459fb5897d5f8efa76bc227a223c0542
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 16:28:36 2026 -0700

    interactive live viz (click color bar -> enter its records) + advertising.crcp viewer (sortable ad-intel table + CSV) + crawler = ALL CRE firms nationally, comprehensive ad detection (Bing/DoubleClick/Criteo/Taboola/etc) + flyer/PDF link extraction; gitignore generated crawl data (TK-10708)
---
 .gitignore                    |   6 ++
 public/advertising/index.html |  73 +++++++++++++++++++++++
 public/flyers/live.html       |  31 +++++++++-
 scripts/broker-ad-signals.mjs | 134 +++++++++++++++++++++++++-----------------
 4 files changed, 188 insertions(+), 56 deletions(-)

diff --git a/.gitignore b/.gitignore
index 519f80f..6ca02e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,9 @@ out/*.html
 data/harvest-*.json
 out/provenance-worklist-*.json
 out/provenance-2*.json
+
+# generated crawl data (contains third-party public tracking IDs; regenerated by the crawler)
+public/advertising/broker-ad-signals.json
+public/advertising/broker-ad-signals.csv
+data/broker-ad-signals.json
+data/ad-scan.log
diff --git a/public/advertising/index.html b/public/advertising/index.html
new file mode 100644
index 0000000..92f2c73
--- /dev/null
+++ b/public/advertising/index.html
@@ -0,0 +1,73 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Broker Advertising Intel — CRCP</title>
+<style>
+ :root{--navy:#0E2350;--blue:#16357A;--gold:#E8A81C;--ink:#141414;--gray:#6B7280}
+ *{box-sizing:border-box} body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#f4f6fb;color:var(--ink)}
+ header{background:var(--navy);color:#fff;padding:18px 26px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
+ header h1{margin:0;font-size:20px} header .s{color:var(--gold);font-size:12px}
+ .dl{background:var(--gold);color:#111;font-weight:700;font-size:13px;padding:8px 14px;border-radius:7px;text-decoration:none}
+ .controls{position:sticky;top:0;z-index:5;background:#fff;border-bottom:1px solid #e6e9f2;padding:12px 26px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;font-size:13px}
+ .controls input,.controls select{padding:6px 9px;border:1px solid #d6dced;border-radius:6px;font-size:13px}
+ .stat{color:var(--gray);margin-left:auto;font-variant-numeric:tabular-nums}
+ table{border-collapse:collapse;width:100%;font-size:13px}
+ thead th{position:sticky;top:52px;background:#eef1f8;text-align:left;padding:9px 12px;color:#445;cursor:pointer;white-space:nowrap;border-bottom:2px solid #dde3f0}
+ td{padding:8px 12px;border-bottom:1px solid #eef0f6;vertical-align:top}
+ tr:hover td{background:#fbfcff}
+ a{color:var(--blue);text-decoration:none} a:hover{text-decoration:underline}
+ .chip{display:inline-block;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;margin:1px 2px 1px 0}
+ .g{background:#0E7C4A} .b{background:#0b6fb8} .m{background:#1877F2} .l{background:#0a66c2} .t{background:#111} .x{background:#8A5A00}
+ .none{color:#c3c9d6} .flyer{color:#8A5A00;font-weight:600}
+ .paid{font-weight:800;color:#0E7C4A;text-align:center} .no{color:#c3c9d6;text-align:center}
+</style></head><body>
+<header>
+ <div><h1>Broker Advertising Intel</h1><div class="s">CRCP · who's running ads + flyers found on their sites · from page code</div></div>
+ <a class="dl" href="broker-ad-signals.csv" download>⬇ Download CSV</a>
+</header>
+<div class="controls">
+ <input id="q" placeholder="search firm / domain…" size="22">
+ <select id="filt">
+  <option value="">All brokers</option>
+  <option value="paid">Runs ANY paid ads</option>
+  <option value="google_ads">Google Ads</option>
+  <option value="bing_ads">Bing Ads</option>
+  <option value="meta">Meta</option>
+  <option value="linkedin">LinkedIn</option>
+  <option value="flyers">Has flyers/PDFs on site</option>
+ </select>
+ <div class="stat" id="stat"></div>
+</div>
+<table><thead><tr>
+ <th data-k="firm">Firm</th><th data-k="domain">Domain</th><th data-k="paid_count">Paid ads</th>
+ <th data-k="_plats">Platforms</th><th data-k="google_analytics">GA</th><th data-k="flyer_count">Flyers/PDFs</th>
+</tr></thead><tbody id="tb"></tbody></table>
+<script>
+const PLAT={google_ads:['g','Google'],bing_ads:['b','Bing'],meta:['m','Meta'],linkedin:['l','LinkedIn'],tiktok:['t','TikTok'],twitter:['t','X'],pinterest:['x','Pinterest'],reddit:['x','Reddit'],snap:['x','Snap'],amazon:['x','Amazon'],criteo:['x','Criteo'],taboola:['x','Taboola'],outbrain:['x','Outbrain'],adroll:['x','AdRoll'],doubleclick:['x','DoubleClick']};
+let DATA=[],sortK='paid_count',sortDir=-1;
+const plats=r=>Object.keys(PLAT).filter(p=>r[p]);
+function chips(r){const on=plats(r);return on.length?on.map(p=>`<span class="chip ${PLAT[p][0]}">${PLAT[p][1]}</span>`).join(''):'<span class="none">—</span>';}
+function flyerCell(r){if(!r.flyer_count)return '<span class="none">—</span>';
+ return `<span class="flyer">${r.flyer_count}</span> ` + (r.flyer_urls||[]).slice(0,3).map((u,i)=>`<a href="${u}" target="_blank" rel="noopener noreferrer">pdf${i+1}↗</a>`).join(' ');}
+function render(){
+ const q=document.getElementById('q').value.toLowerCase(),f=document.getElementById('filt').value;
+ let rows=DATA.filter(r=>!r.error);
+ if(q)rows=rows.filter(r=>((r.firm||'')+' '+r.domain).toLowerCase().includes(q));
+ if(f==='paid')rows=rows.filter(r=>r.paid_count>0);
+ else if(f==='flyers')rows=rows.filter(r=>r.flyer_count>0);
+ else if(f)rows=rows.filter(r=>r[f]);
+ rows.sort((a,b)=>{let x=a[sortK],y=b[sortK];if(sortK==='firm'||sortK==='domain'){return (''+x).localeCompare(''+y)*sortDir;}return ((x||0)-(y||0))*sortDir;});
+ document.getElementById('tb').innerHTML=rows.map(r=>`<tr>
+  <td><b>${(r.firm||'').replace(/</g,'&lt;')||'—'}</b></td>
+  <td><a href="https://${r.domain}" target="_blank" rel="noopener noreferrer">${r.domain}</a></td>
+  <td class="${r.paid_count?'paid':'no'}">${r.paid_count||'·'}</td>
+  <td>${chips(r)}</td>
+  <td style="text-align:center">${r.google_analytics?'✓':'<span class="none">·</span>'}</td>
+  <td>${flyerCell(r)}</td></tr>`).join('');
+ const adv=rows.filter(r=>r.paid_count>0).length,fl=rows.filter(r=>r.flyer_count>0).length;
+ document.getElementById('stat').textContent=`${rows.length} brokers · ${adv} advertising · ${fl} with flyers`;
+}
+document.querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;if(k==='_plats')return;if(sortK===k)sortDir*=-1;else{sortK=k;sortDir=(k==='firm'||k==='domain')?1:-1;}render();});
+document.getElementById('q').oninput=render;document.getElementById('filt').onchange=render;
+fetch('broker-ad-signals.json?t='+Date.now()).then(r=>r.json()).then(j=>{DATA=j;render();}).catch(e=>document.getElementById('stat').textContent='load error: '+e.message);
+setInterval(()=>fetch('broker-ad-signals.json?t='+Date.now()).then(r=>r.json()).then(j=>{DATA=j;render();}).catch(()=>{}),15000);
+</script></body></html>
diff --git a/public/flyers/live.html b/public/flyers/live.html
index 75042c8..3b8e56b 100644
--- a/public/flyers/live.html
+++ b/public/flyers/live.html
@@ -13,7 +13,16 @@
   #ticker .row{margin-top:3px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
   .chip{font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;color:#070a14}
   #mode{position:fixed;top:64px;right:24px;z-index:6;pointer-events:auto;background:#111a30;color:#cfe0ff;border:1px solid #24304f;border-radius:7px;padding:6px 10px;font-size:12px}
+  #drill{position:fixed;top:0;right:0;bottom:0;width:380px;max-width:90vw;z-index:10;background:#0b1120f2;backdrop-filter:blur(6px);border-left:1px solid #24304f;transform:translateX(100%);transition:transform .25s;overflow-y:auto;padding:18px 20px;pointer-events:auto}
+  #drill.open{transform:none}
+  #drill h3{margin:0 0 2px;font-size:16px} #drill .sub{color:#8aa;font-size:12px;margin-bottom:12px}
+  #drill .x{position:absolute;top:14px;right:16px;cursor:pointer;color:#8aa;font-size:20px}
+  .rec{display:block;padding:10px 12px;border:1px solid #1c2540;border-radius:8px;margin-bottom:8px;text-decoration:none;color:#eaf0ff}
+  .rec:hover{border-color:#3a4a78;background:#111a30}
+  .rec .p{font-weight:800;color:#7fd7ff} .rec .t{font-size:13px;margin:3px 0;line-height:1.3}
+  .rec .m{color:#8aa;font-size:11px} .rec .src{color:#E8A81C;font-size:11px}
 </style></head><body>
+<div id="drill"><span class="x" onclick="document.getElementById('drill').classList.remove('open')">✕</span><div id="drillbody"></div></div>
 <canvas id="c"></canvas>
 <div class="hud" id="top"><div id="title">CRE DEALS · LIVE<small>ABRAMS · FEEDS DROPPING INTO CATEGORIES</small></div>
   <div id="stats"><b id="cnt">0</b> deals<br><span id="vol">$0</span> · <span id="mk">0 markets</span></div></div>
@@ -62,6 +71,8 @@ function drop(d){
   const col=TYPE[d.type]||0x8aa0c0;
   const cube=new THREE.Mesh(new THREE.BoxGeometry(BW,BH,BW),new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:.55,transparent:true,opacity:.96}));
   cube.position.set(x, landY+46, 30);   // spawn high + in FRONT (z=30)
+  cube.userData={deal:d,cat};           // clickable -> drill into this record
+  (dealsByCat[cat]=dealsByCat[cat]||[]).push(d);
   colGroup.add(cube); stack[cat].push(cube);
   drops.push({cube,x,landY,t:0});
   // HUD
@@ -70,7 +81,25 @@ function drop(d){
   S.mk.add(d.market||'US'); document.getElementById('mk').textContent=S.mk.size+' markets';
   document.getElementById('last').innerHTML=`<span class="chip" style="background:${hex(col)}">${(d.type||'CRE').toUpperCase()}</span> <b>${d.price_label||money(d.price)}</b> ${d.market?'· '+d.market:''} — ${(d.title||'').slice(0,64)} <span style="color:#E8A81C">◂ ${d.source}</span>`;
 }
-const S={cnt:0,vol:0,mk:new Set()};
+const S={cnt:0,vol:0,mk:new Set()}; const dealsByCat={};
+// ---- click a color bar / block to ENTER its records ----
+const ray=new THREE.Raycaster(), mouse=new THREE.Vector2();
+function onClick(ev){ const r=rnd.domElement.getBoundingClientRect();
+  mouse.x=((ev.clientX-r.left)/r.width)*2-1; mouse.y=-((ev.clientY-r.top)/r.height)*2+1;
+  ray.setFromCamera(mouse,cam); const hit=ray.intersectObjects(colGroup.children.filter(o=>o.userData&&o.userData.deal));
+  if(hit.length){ openDrill(hit[0].object.userData.cat, hit[0].object.userData.deal); }
+}
+rnd.domElement.addEventListener('click',onClick);
+function openDrill(cat,clicked){ const list=(dealsByCat[cat]||[]).slice().sort((a,b)=>(b.price||0)-(a.price||0));
+  const col=TYPE[clicked.type]||0x8aa0c0;
+  document.getElementById('drillbody').innerHTML=`<h3 style="color:${hex(col)}">${cat}</h3>
+   <div class="sub">${list.length} record${list.length!=1?'s':''} · ${money(list.reduce((s,d)=>s+(d.price||0),0))} total · click any to open source ↗</div>`+
+   list.map(d=>`<a class="rec" href="${d.link}" target="_blank" rel="noopener noreferrer" style="border-color:${d===clicked?hex(col):'#1c2540'}">
+     <span class="p">${d.price_label||money(d.price)}</span> <span class="m">· ${d.market||'US'} · ${(d.type||'CRE')}</span>
+     <div class="t">${(d.title||'').replace(/</g,'&lt;')}</div>
+     <span class="src">◂ ${d.source}${d.buyer?' · buyer '+d.buyer:''}${d.seller?' · seller '+d.seller:''}</span></a>`).join('');
+  document.getElementById('drill').classList.add('open'); ctrl.autoRotate=false;
+}
 
 function anim(){ requestAnimationFrame(anim); ctrl.update();
   for(let i=drops.length-1;i>=0;i--){ const f=drops[i]; f.t=Math.min(1,f.t+.035);
diff --git a/scripts/broker-ad-signals.mjs b/scripts/broker-ad-signals.mjs
index 9b243ec..6dfe4db 100644
--- a/scripts/broker-ad-signals.mjs
+++ b/scripts/broker-ad-signals.mjs
@@ -1,77 +1,101 @@
 #!/usr/bin/env node
-// TK-10708  Ad-signal scan for CA/LA commercial brokers (ad-social-tracker Pass 1).
-// Slowly fetches each broker's front page and mines paid-ad tracking pixels (Google Ads,
-// Meta, TikTok, LinkedIn, etc.) via the ad-social-tracker regexes. READ-ONLY public pages,
-// rate-limited ("crawl slowly" — Steve). Competitive intel only (internal, not published).
+// TK-10708  Broker intel crawl: for every CA commercial broker, mine their page code for
+// (a) ANY ad-platform tracking pixel (Google/Bing/Meta/LinkedIn/TikTok/Twitter/Pinterest/
+// Reddit/Snap/Amazon/Criteo/Taboola/Outbrain/AdRoll/DoubleClick) and (b) flyer / OM / PDF
+// links on their site. Slow, polite, READ-ONLY public pages. Outputs JSON + CSV.
 //
-// Usage: node scripts/broker-ad-signals.mjs [--n 20] [--delay 3500] [--state CA]
+// Usage: node scripts/broker-ad-signals.mjs [--n 300] [--delay 2500] [--state CA]
 
 import { execFileSync } from 'node:child_process';
-import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { writeFileSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const args = process.argv.slice(2);
 const getArg = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
-const N = parseInt(getArg('--n', '20'), 10);
-const DELAY = parseInt(getArg('--delay', '3500'), 10);   // slow: ~3.5s between fetches
+const N = parseInt(getArg('--n', '400'), 10);
+const DELAY = parseInt(getArg('--delay', '2500'), 10);
 const STATE = getArg('--state', 'CA');
-const OUT = join(ROOT, 'data', 'broker-ad-signals.json');
+const JSONF = join(ROOT, 'public', 'advertising', 'broker-ad-signals.json');
+const CSVF = join(ROOT, 'public', 'advertising', 'broker-ad-signals.csv');
 const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
 const sleep = ms => new Promise(r => setTimeout(r, ms));
 
-// ad-social-tracker pixel fingerprints (pure regex, no API/LLM)
-const SIG = {
-  google_ads: /googleadservices\.com|AW-\d{6,}|google_conversion_id|gtag\(\s*['"]config['"]\s*,\s*['"]AW-/i,
-  meta_pixel: /connect\.facebook\.net\/[^"']*fbevents\.js|fbq\(\s*['"]init['"]/i,
-  tiktok_pixel: /analytics\.tiktok\.com|ttq\.load/i,
-  pinterest_tag: /s\.pinimg\.com\/ct\.js|pintrk\(\s*['"]load['"]/i,
-  linkedin_insight: /snap\.licdn\.com\/li\.lms-analytics|_linkedin_partner_id/i,
-  twitter_pixel: /static\.ads-twitter\.com\/uwt\.js|twq\(\s*['"]init['"]/i,
-  reddit_pixel: /redditstatic\.com\/ads\/pixel\.js|rdt\(\s*['"]init['"]/i,
-  bing_uet: /bat\.bing\.com|\buetq\b/i,
-  snap_pixel: /sc-static\.net\/scevent|snaptr\(\s*['"]init['"]/i,
-  // measurement / analytics (NOT paid ads) — Steve asked to also flag GA + StatCounter
-  google_analytics: /gtag\(\s*['"]config['"]\s*,\s*['"]G-|GTM-[A-Z0-9]+|google-analytics\.com\/analytics\.js|googletagmanager\.com\/gtag/i,
-  statcounter: /statcounter\.com|sc_project|c\d+\.statcounter\.com|StatCounter/i,
+// ANY ad platform — signature lives in the page code (Steve). Bing included + native/programmatic.
+const AD = {
+  google_ads: /googleadservices\.com|AW-\d{6,}|google_conversion_id|gtag\(\s*['"]config['"]\s*,\s*['"]AW-|googleads\.g\.doubleclick/i,
+  bing_ads: /bat\.bing\.com|\buetq\b|uet_bing|\/bat\.js/i,
+  meta: /connect\.facebook\.net\/[^"']*fbevents\.js|fbq\(\s*['"]init['"]/i,
+  linkedin: /snap\.licdn\.com\/li\.lms-analytics|_linkedin_partner_id/i,
+  tiktok: /analytics\.tiktok\.com|ttq\.load/i,
+  twitter: /static\.ads-twitter\.com\/uwt\.js|twq\(\s*['"]init['"]/i,
+  pinterest: /s\.pinimg\.com\/ct\.js|pintrk\(\s*['"]load['"]/i,
+  reddit: /redditstatic\.com\/ads\/pixel\.js|rdt\(\s*['"]init['"]/i,
+  snap: /sc-static\.net\/scevent|snaptr\(\s*['"]init['"]/i,
+  amazon: /amazon-adsystem\.com|amzn\.to\/ads/i,
+  criteo: /criteo\.(com|net)|Criteo_/i,
+  taboola: /taboola\.com\/libtrc|_tfa\b/i,
+  outbrain: /outbrain\.com\/outbrain\.js|obApi\(/i,
+  adroll: /adroll\.com|__adroll/i,
+  doubleclick: /doubleclick\.net|dv360|fls\.doubleclick/i,
 };
-const PAID = ['google_ads', 'meta_pixel', 'tiktok_pixel', 'pinterest_tag', 'linkedin_insight', 'twitter_pixel', 'reddit_pixel', 'bing_uet', 'snap_pixel'];
-const MEASURE = ['google_analytics', 'statcounter'];
+const google_analytics = /gtag\(\s*['"]config['"]\s*,\s*['"]G-|GTM-[A-Z0-9]+|google-analytics\.com\/analytics\.js|googletagmanager\.com\/gtag/i;
+const statcounter = /statcounter\.com|sc_project|StatCounter/i;
+const PLATS = Object.keys(AD);
 
-// distinct commercial broker domains from the usre firm registry
-// biggest firms first (highest agent_count) — most likely to advertise
-const rows = execFileSync('psql', ['usre', '-t', '-A', '-c',
-  `SELECT website FROM firm WHERE hq_state='${STATE}' AND asset_class='commercial' AND website IS NOT NULL AND website<>'' ORDER BY agent_count DESC NULLS LAST LIMIT ${N * 4}`],
-  { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
-const domainOf = u => { try { return new URL(u.startsWith('http') ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return null; } };
-const domains = [...new Set(rows.map(domainOf).filter(Boolean))].slice(0, N);
+// flyer / OM / PDF links in the page code
+function flyers(html, base) {
+  const out = new Set();
+  const re = /href\s*=\s*["']([^"']+)["']/gi; let m;
+  while ((m = re.exec(html))) {
+    const href = m[1]; const low = href.toLowerCase();
+    const ctx = html.slice(Math.max(0, m.index - 80), m.index + 120).toLowerCase();
+    const isPdf = /\.pdf(\?|#|$)/.test(low);
+    const looksFlyer = /flyer|brochure|offering[-_ ]?mem|\bom\b|marketing[-_ ]?package|e-?brochure|property[-_ ]?package|listing[-_ ]?flyer|tour[-_ ]?book|deal[-_ ]?sheet|for[-_ ]?sale/.test(low + ' ' + ctx);
+    if (isPdf || (looksFlyer && /\.pdf|\/download|\/flyer|\/brochure|\/om/.test(low))) {
+      try { out.add(new URL(href, base).href); } catch {}
+    }
+  }
+  return [...out].slice(0, 8);
+}
 
-const prior = existsSync(OUT) ? JSON.parse(readFileSync(OUT, 'utf8')) : {};
-console.log(`Ad-signal scan: ${domains.length} ${STATE} commercial brokers, ${DELAY}ms apart (slow).`);
+const stateClause = STATE === 'ALL' ? '' : `AND hq_state='${STATE}'`;
+const rows = execFileSync('psql', ['usre', '-t', '-A', '-F', '\t', '-c',
+  `SELECT coalesce(name,''), website FROM firm WHERE asset_class='commercial' AND website IS NOT NULL AND website<>'' ${stateClause} ORDER BY agent_count DESC NULLS LAST LIMIT ${N * 3}`],
+  { encoding: 'utf8' }).trim().split('\n').filter(Boolean).map(l => l.split('\t'));
+const domOf = u => { try { return new URL(u.startsWith('http') ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return null; } };
+const seen = new Set(), targets = [];
+for (const [name, web] of rows) { const d = domOf(web); if (d && !seen.has(d)) { seen.add(d); targets.push({ name, domain: d }); } if (targets.length >= N) break; }
 
-for (const d of domains) {
+console.log(`Broker intel crawl: ${targets.length} ${STATE} commercial brokers, ${DELAY}ms apart (slow).`);
+const results = [];
+for (const t of targets) {
+  const rec = { firm: t.name, domain: t.domain };
   try {
-    const res = await fetch('https://' + d + '/', { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(15000) });
+    const res = await fetch('https://' + t.domain + '/', { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(15000) });
     const html = await res.text();
-    const sig = {}; for (const [k, re] of Object.entries(SIG)) sig[k] = re.test(html);
-    sig.paid_ads_count = PAID.filter(k => sig[k]).length;
-    sig.scanned_at = new Date().toISOString();
-    prior[d] = sig;
-    const on = PAID.filter(k => sig[k]).map(k => k.replace('_pixel', '').replace('_tag', '').replace('_insight', '').replace('_uet', '').replace('_ads', '-ads'));
-    const meas = MEASURE.filter(k => sig[k]).map(k => k === 'google_analytics' ? 'GA' : 'StatCounter');
-    console.log(`  ${sig.paid_ads_count ? '💰' : '  '} ${d.padEnd(34)} ${on.join(', ') || '—'}${meas.length ? '   [' + meas.join(',') + ']' : ''}`);
-  } catch (e) {
-    prior[d] = { error: e.message.slice(0, 40), scanned_at: new Date().toISOString() };
-    console.log(`  ⚠ ${d.padEnd(34)} ${e.message.slice(0, 30)}`);
-  }
-  writeFileSync(OUT, JSON.stringify(prior, null, 1));   // persist after each (resumable)
+    for (const p of PLATS) rec[p] = AD[p].test(html);
+    rec.paid_count = PLATS.filter(p => rec[p]).length;
+    rec.google_analytics = google_analytics.test(html);
+    rec.statcounter = statcounter.test(html);
+    rec.flyer_urls = flyers(html, 'https://' + t.domain + '/');
+    rec.flyer_count = rec.flyer_urls.length;
+    const on = PLATS.filter(p => rec[p]);
+    console.log(`  ${rec.paid_count ? '💰' : '  '}${rec.flyer_count ? '📄' : '  '} ${t.domain.padEnd(32)} ${on.join(',') || '-'}${rec.flyer_count ? '  ('+rec.flyer_count+' flyers)' : ''}`);
+  } catch (e) { rec.error = e.message.slice(0, 40); console.log(`  ⚠  ${t.domain.padEnd(32)} ${e.message.slice(0, 30)}`); }
+  rec.scanned_at = new Date().toISOString();
+  results.push(rec);
+  writeFileSync(JSONF, JSON.stringify(results, null, 1));   // resumable
   await sleep(DELAY);
 }
-const scanned = Object.values(prior).filter(v => !v.error);
-const advertisers = scanned.filter(v => v.paid_ads_count > 0).length;
-const ga = scanned.filter(v => v.google_analytics).length, sc = scanned.filter(v => v.statcounter).length;
-console.log(`\nDone. ${scanned.length} scanned · ${advertisers} run PAID ads · ${ga} Google Analytics · ${sc} StatCounter. -> data/broker-ad-signals.json`);
-// list the advertisers found
-for (const [dom, v] of Object.entries(prior)) if (v.paid_ads_count > 0)
-  console.log(`  💰 ${dom}: ${PAID.filter(k => v[k]).join(', ')}`);
+
+// CSV (easy list)
+const cols = ['firm', 'domain', ...PLATS, 'paid_count', 'google_analytics', 'statcounter', 'flyer_count', 'flyer_urls'];
+const esc = v => { v = v == null ? '' : Array.isArray(v) ? v.join(' ; ') : String(v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
+const csv = [cols.join(','), ...results.map(r => cols.map(c => esc(c === 'google_analytics' || c === 'statcounter' || PLATS.includes(c) ? (r[c] ? 'yes' : '') : r[c])).join(','))].join('\n');
+writeFileSync(CSVF, csv);
+
+const adv = results.filter(r => r.paid_count > 0);
+console.log(`\nDone. ${results.filter(r => !r.error).length} scanned · ${adv.length} run ANY paid ads · ${results.filter(r => r.google_analytics).length} GA · ${results.filter(r => r.flyer_count > 0).length} have flyers/PDFs on site.`);
+console.log(`CSV -> public/flyers/broker-ad-signals.csv`);

← d5a10e2 live viz (reverted odd map -> clean XY bar chart w/ Z-depth)  ·  back to Re Flyer Aggregator  ·  FIX live.html crash: GPU memory leak (undisposed geometries/ 3c044a6 →