← back to Re Flyer Aggregator
aggregation store: local reflyers staging DB + ingest + deal_assets_v read view + consumer index; 15 real assets across 5 Miami deals; null-address filter (TK-10708)
7c6e855584f2c54c03118d73abd228356ff53f1d · 2026-08-19 09:43:08 -0700 · Steve Abrams
Files touched
M README.mdA data/findings-2026-08-19.jsonA db/002_deal_assets_view.sqlM scripts/generate-spotlight.mjsA scripts/ingest-assets.mjsA scripts/render-asset-index.mjs
Diff
commit 7c6e855584f2c54c03118d73abd228356ff53f1d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 19 09:43:08 2026 -0700
aggregation store: local reflyers staging DB + ingest + deal_assets_v read view + consumer index; 15 real assets across 5 Miami deals; null-address filter (TK-10708)
---
README.md | 23 +++++++++---
data/findings-2026-08-19.json | 31 ++++++++++++++++
db/002_deal_assets_view.sql | 22 +++++++++++
scripts/generate-spotlight.mjs | 4 +-
scripts/ingest-assets.mjs | 84 ++++++++++++++++++++++++++++++++++++++++++
scripts/render-asset-index.mjs | 72 ++++++++++++++++++++++++++++++++++++
6 files changed, 229 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 023fd04..47df31e 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,22 @@ governed by each source's ToS + robots.txt, independent of human viewability.
with a per-deal broker-scoped discovery query + confidence columns. **DRY** (fetches nothing).
`node scripts/provenance-probe.mjs --n 25 --county 06037`.
+## Staging store (built + populated — local `reflyers` Postgres)
+`createdb reflyers` + `db/001` + `db/002`. Populated from live discovery on the top Miami closed deals:
+**15 assets / 5 deals** — 7 first-party (owner/dev/press), 2 broker-owned (Blanca, CBRE), 5 self-generated
+recaps, 1 GATED (LoopNet, hidden from the consumer view). Reversible: `dropdb reflyers` (ledgered).
+- `scripts/ingest-assets.mjs <findings.json> | --recaps | --report` — load classified findings + register recaps.
+- `db/002_deal_assets_view.sql` — `deal_assets_v`, the read view CRCP/RENTV consume (GATED tier-3 excluded).
+- `scripts/render-asset-index.mjs` — static consumer preview → `out/deal-assets.html`.
+- `data/findings-2026-08-19.json` — the real classified hits · `out/provenance-findings.md` — the verdict.
+
## Roadmap
-1. **Now (done):** schema draft, tiered source registry, Tier-2 generator (works), provenance probe (works, dry).
-2. **Next (proposed):** join CRCP `firm_site`/`broker_website` to resolve each deal's listing-broker
- domain; run the probe's discovery queries via a rate-limited, robots-respecting step on **broker-owned
- pages only**; fill the provenance report; measure match quality on ~25 LA deals.
-3. **Then (gated):** apply the migration to usre; expose a read view; wire CRCP deal rows + RENTV
- articles to show "📄 recap / broker OM link" when present.
+1. **Done:** schema, tiered source registry, Tier-2 generator, provenance probe w/ real firm-domain join,
+ live discovery on top Miami deals, staging DB + ingest + read view + consumer index.
+2. **Next (safe, local):** widen discovery to the rest of the Miami-Dade feed; add a scheduled recap batch
+ for RENTV; add a small sort+density viewer over `deal_assets_v`.
+3. **Gated (needs Steve):** promote validated rows into **usre**; create `deal_assets_v` there; wire CRCP
+ deal rows + RENTV articles to show the recap/first-party links; any Kamatera deploy.
4. **Never without Steve:** marketplace automated access, any PDF re-host, prod deploy, DNS, send-to-list.
+5. **Upgrade path:** populate `broker_of_record_history.county_fips/ain` → state-level firm-domain join
+ becomes exact per-property broker resolution.
diff --git a/data/findings-2026-08-19.json b/data/findings-2026-08-19.json
new file mode 100644
index 0000000..b9565e1
--- /dev/null
+++ b/data/findings-2026-08-19.json
@@ -0,0 +1,31 @@
+[
+ { "asset": { "asset_type":"property_brief","tier":1,"title":"1111 Brickell — property brochure (owner site)","source_name":"1111 Brickell (owner brand site)","source_landing_url":"https://1111brickell.com/overview/","discovery_method":"websearch","rights_basis":"first_party","access_status":"public","robots_ok":null },
+ "subjects":[{"county_fips":"12086","doc_number":"34974-3025","match_confidence":0.85}] },
+
+ { "asset": { "asset_type":"deal_recap","tier":1,"title":"Miami office tower sale closes for $274M","source_name":"CRE-Sources (trade press)","source_landing_url":"https://cre-sources.com/miami-office-tower-sale-closes-for-274m/","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34974-3025","match_confidence":0.9}] },
+
+ { "asset": { "asset_type":"marketing_flyer","tier":3,"title":"1111 Brickell — LoopNet listing (marketplace, GATED)","source_name":"LoopNet","source_landing_url":"https://www.loopnet.com/Listing/1111-Brickell-Ave-Miami-FL/20980545/","discovery_method":"websearch","rights_basis":"GATED_needs_review","access_status":"gated","robots_ok":false },
+ "subjects":[{"county_fips":"12086","doc_number":"34974-3025","match_confidence":0.7}] },
+
+ { "asset": { "asset_type":"deal_recap","tier":1,"title":"Collins Ave hotel redevelopment (1751 Collins)","source_name":"Florida YIMBY (trade press)","source_landing_url":"https://floridayimby.com/2021/08/collins-avenue-hotel-redevelopment-project-delayed-planned-for-1751-collins-avenue-miami-beach-fl-33139.html","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34991-2690","match_confidence":0.55}] },
+
+ { "asset": { "asset_type":"deal_recap","tier":1,"title":"701/700 Brickell sale — Elliott $443M-450M","source_name":"The Real Deal (trade press)","source_landing_url":"https://therealdeal.com/miami/2025/12/29/inside-south-floridas-top-office-sales-of-2025/","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34776-4336","match_confidence":0.8}] },
+
+ { "asset": { "asset_type":"deal_recap","tier":1,"title":"CBRE named as seller broker for 700 Brickell (no public OM post-close)","source_name":"CBRE","source_landing_url":"https://www.cbre.com","discovery_method":"websearch","rights_basis":"broker_owned","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34776-4336","match_confidence":0.3}] },
+
+ { "asset": { "asset_type":"property_brief","tier":1,"title":"Terra/Fortune buy Silver Sands (301 Ocean Dr) — buyer newsroom","source_name":"Terra Group (principal newsroom)","source_landing_url":"https://terragroup.com/news-and-media/terra-fortune-pay-record-205m-for-waterfront-key-biscayne-development-site","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34712-2655","match_confidence":0.9}] },
+
+ { "asset": { "asset_type":"deal_recap","tier":1,"title":"Terra, Fortune pay record $205M for Key Biscayne site","source_name":"The Real Deal (trade press)","source_landing_url":"https://therealdeal.com/miami/2025/04/10/terra-fortune-pay-205m-for-silver-sands-key-biscayne-site/","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"34712-2655","match_confidence":0.9}] },
+
+ { "asset": { "asset_type":"property_brief","tier":1,"title":"545wyn — developer property page (Sterling Bay)","source_name":"Sterling Bay (developer)","source_landing_url":"https://sterlingbay.com/properties/545wyn/","discovery_method":"websearch","rights_basis":"first_party","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"35124-0927","match_confidence":0.85}] },
+
+ { "asset": { "asset_type":"marketing_flyer","tier":1,"title":"545wyn — Blanca Commercial RE listing (broker-owned, in firm set)","source_name":"Blanca Commercial Real Estate","source_landing_url":"https://www.blancacre.com/listings/545wyn","discovery_method":"firm_domain_match","rights_basis":"broker_owned","access_status":"public" },
+ "subjects":[{"county_fips":"12086","doc_number":"35124-0927","match_confidence":0.9}] }
+]
diff --git a/db/002_deal_assets_view.sql b/db/002_deal_assets_view.sql
new file mode 100644
index 0000000..f563624
--- /dev/null
+++ b/db/002_deal_assets_view.sql
@@ -0,0 +1,22 @@
+-- TK-10708 Read view CRCP/RENTV consume (per codex: consumers read a VIEW, not scraper tables).
+-- Lives in the reflyers staging DB. On promotion, the same view is created in usre over the
+-- promoted tables. Excludes GATED (tier 3) assets from the consumer surface by default.
+
+CREATE OR REPLACE VIEW deal_assets_v AS
+SELECT
+ l.county_fips,
+ l.doc_number,
+ a.asset_type,
+ a.tier,
+ a.rights_basis,
+ a.title,
+ a.source_name,
+ a.source_landing_url,
+ a.local_path,
+ l.match_confidence,
+ a.last_verified_at
+FROM asset_subject_link l
+JOIN external_marketing_asset a ON a.id = l.asset_id
+WHERE l.subject_type = 'deal'
+ AND a.tier < 3 -- hide GATED marketplace assets from the consumer surface
+ORDER BY l.doc_number, a.tier, l.match_confidence DESC NULLS LAST;
diff --git a/scripts/generate-spotlight.mjs b/scripts/generate-spotlight.mjs
index 9ced4de..c6a7fbb 100644
--- a/scripts/generate-spotlight.mjs
+++ b/scripts/generate-spotlight.mjs
@@ -27,7 +27,9 @@ const N = parseInt(getArg('--n', '3'), 10);
const DOC = getArg('--doc', null);
const COLS = 'sale_date,sale_price,ctype,address,city,county_name,sqft,year_built,doc_number';
-const where = DOC ? `WHERE doc_number = '${DOC.replace(/'/g, "''")}'` : '';
+const where = DOC
+ ? `WHERE doc_number = '${DOC.replace(/'/g, "''")}' AND address IS NOT NULL`
+ : `WHERE address IS NOT NULL`; // skip data-gap rows with no address
// row_to_json => one JSON object per line = unambiguous parsing (no delimiter guessing).
const sql = `SELECT row_to_json(t) FROM (SELECT ${COLS} FROM recent_commercial_deals ${where} ORDER BY sale_price DESC LIMIT ${DOC ? 1 : N}) t`;
diff --git a/scripts/ingest-assets.mjs b/scripts/ingest-assets.mjs
new file mode 100644
index 0000000..9b8a245
--- /dev/null
+++ b/scripts/ingest-assets.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+// TK-10708 Ingest classified marketing-asset findings into the LOCAL reflyers staging
+// DB (external_marketing_asset + asset_subject_link). Idempotent per (source_landing_url,
+// doc_number). Also registers the Tier-2 self-generated recaps as assets (--recaps).
+//
+// Usage:
+// node scripts/ingest-assets.mjs data/findings-2026-08-19.json
+// node scripts/ingest-assets.mjs --recaps # register generated out/spotlight-*.html
+// node scripts/ingest-assets.mjs --report # print aggregated counts
+
+import { execFileSync } from 'node:child_process';
+import { readFileSync, readdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const DB = 'reflyers';
+const psql = sql => execFileSync('psql', [DB, '-t', '-A', '-c', sql], { encoding: 'utf8' }).trim();
+const lit = v => (v === null || v === undefined || v === '') ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`;
+const litB = v => (v === null || v === undefined) ? 'NULL' : (v ? 'true' : 'false');
+
+const args = process.argv.slice(2);
+const STATS = { inserted: 0, skipped: 0 };
+
+function insertAsset(a) {
+ // idempotency: skip if same landing_url already present
+ if (a.source_landing_url) {
+ const dup = psql(`SELECT id FROM external_marketing_asset WHERE source_landing_url=${lit(a.source_landing_url)} LIMIT 1`);
+ if (dup) { STATS.skipped++; return +String(dup).split('\n')[0].trim(); }
+ }
+ STATS.inserted++;
+ const id = psql(`INSERT INTO external_marketing_asset
+ (asset_type,tier,title,description,source_name,source_landing_url,document_url,discovery_method,rights_basis,access_status,robots_ok,local_path,file_format,last_verified_at)
+ VALUES (${lit(a.asset_type)},${a.tier|0},${lit(a.title)},${lit(a.description)},${lit(a.source_name)},${lit(a.source_landing_url)},${lit(a.document_url)},${lit(a.discovery_method)},${lit(a.rights_basis)},${lit(a.access_status)},${litB(a.robots_ok)},${lit(a.local_path)},${lit(a.file_format)},now())
+ RETURNING id`);
+ // -t -A still prints the "INSERT 0 1" status after the RETURNING value -> take line 1.
+ return +String(id).split('\n')[0].trim();
+}
+function linkSubject(assetId, s) {
+ const dup = psql(`SELECT id FROM asset_subject_link WHERE asset_id=${assetId} AND county_fips=${lit(s.county_fips)} AND coalesce(doc_number,'')=${lit(s.doc_number||'')} LIMIT 1`);
+ if (dup) return;
+ psql(`INSERT INTO asset_subject_link (asset_id,subject_type,county_fips,ain,doc_number,match_confidence)
+ VALUES (${assetId},'deal',${lit(s.county_fips)},NULL,${lit(s.doc_number)},${s.match_confidence ?? 'NULL'})`);
+}
+
+if (args.includes('--report')) {
+ console.log('=== assets by tier x rights_basis ===');
+ console.log(psql(`SELECT tier, rights_basis, count(*) FROM external_marketing_asset GROUP BY 1,2 ORDER BY 1,2`));
+ console.log('\n=== assets per deal (top) ===');
+ console.log(psql(`SELECT l.doc_number, count(*) AS assets, round(avg(l.match_confidence),2) AS avg_conf
+ FROM asset_subject_link l GROUP BY 1 ORDER BY 2 DESC`));
+ console.log('\n=== total ===', psql(`SELECT count(*) FROM external_marketing_asset`), 'assets,',
+ psql(`SELECT count(*) FROM asset_subject_link`), 'links');
+ process.exit(0);
+}
+
+if (args.includes('--recaps')) {
+ // Register generated recaps. Each out/spotlight-<doc>.html is a Tier-2 asset for that deal.
+ const outDir = join(ROOT, 'out');
+ const files = readdirSync(outDir).filter(f => /^spotlight-.*\.html$/.test(f));
+ let n = 0;
+ for (const f of files) {
+ const m = f.match(/^spotlight-([0-9-]+)\.html$/); // per-doc files only (skip top-N aggregates)
+ if (!m) continue;
+ const doc = m[1];
+ const id = insertAsset({ asset_type:'deal_recap', tier:2, title:`Self-generated closed-sale recap (${doc})`,
+ source_name:'self-generated', discovery_method:'generated', rights_basis:'self_generated',
+ access_status:'internal', local_path:`out/${f}`, file_format:'html' });
+ linkSubject(id, { county_fips:'12086', doc_number:doc, match_confidence:1.0 });
+ n++;
+ }
+ console.log(`Registered ${n} per-deal recap asset(s).`);
+ process.exit(0);
+}
+
+const file = args[0];
+if (!file) { console.error('Usage: ingest-assets.mjs <findings.json> | --recaps | --report'); process.exit(1); }
+const findings = JSON.parse(readFileSync(join(ROOT, file), 'utf8'));
+let assets = 0, links = 0;
+for (const rec of findings) {
+ const id = insertAsset(rec.asset); assets++;
+ for (const s of (rec.subjects || [])) { linkSubject(id, s); links++; }
+}
+console.log(`Ingested from ${file}: ${STATS.inserted} new asset(s), ${STATS.skipped} already-present (idempotent), ${links} subject-link upserts.`);
diff --git a/scripts/render-asset-index.mjs b/scripts/render-asset-index.mjs
new file mode 100644
index 0000000..7c12aeb
--- /dev/null
+++ b/scripts/render-asset-index.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+// TK-10708 Consumer demo: renders the aggregated marketing assets per deal as a static
+// HTML index (what CRCP/RENTV would show next to each deal). Joins deal facts from usre
+// with the deal_assets_v view in the reflyers staging DB. No server; open the HTML.
+//
+// Usage: node scripts/render-asset-index.mjs
+
+import { execFileSync } from 'node:child_process';
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const OUT = join(ROOT, 'out'); mkdirSync(OUT, { recursive: true });
+const jrows = (db, sql) => execFileSync('psql', [db, '-t', '-A', '-c', `SELECT row_to_json(t) FROM (${sql}) t`], { encoding: 'utf8' })
+ .trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+
+// deal facts (usre) for the deals that have assets
+const docs = jrows('reflyers', `SELECT DISTINCT doc_number FROM deal_assets_v`).map(r => r.doc_number);
+if (!docs.length) { console.error('No aggregated assets yet.'); process.exit(1); }
+const inList = docs.map(d => `'${d.replace(/'/g, "''")}'`).join(',');
+const deals = jrows('usre', `SELECT doc_number,address,city,county_name,sale_price::bigint AS sale_price,sale_date,ctype FROM recent_commercial_deals WHERE doc_number IN (${inList})`);
+const assets = jrows('reflyers', `SELECT doc_number,asset_type,tier,rights_basis,title,source_name,source_landing_url,local_path,match_confidence FROM deal_assets_v`);
+
+const byDoc = {};
+for (const a of assets) (byDoc[a.doc_number] ||= []).push(a);
+const dealList = [...new Map(deals.map(d => [d.doc_number, d])).values()].sort((a, b) => b.sale_price - a.sale_price);
+
+const money = n => '$' + (+n).toLocaleString('en-US');
+const tc = s => (s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+const badge = a => {
+ const map = { self_generated: ['#16357A', 'RECAP (ours)'], broker_owned: ['#0E7C4A', 'BROKER'], first_party: ['#8A5A00', 'FIRST-PARTY'] };
+ const [bg, lbl] = map[a.rights_basis] || ['#6B7280', (a.rights_basis || '').toUpperCase()];
+ return `<span class="b" style="background:${bg}">${lbl}</span>`;
+};
+const assetRow = a => {
+ const href = a.source_landing_url || (a.local_path ? a.local_path.replace(/^out\//, '') : '#');
+ return `<li>${badge(a)} <a href="${href}" target="_blank">${a.title || a.source_name}</a>
+ <span class="meta">· ${a.asset_type} · ${a.source_name}${a.match_confidence != null ? ' · conf ' + a.match_confidence : ''}</span></li>`;
+};
+
+const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Deal marketing assets (aggregated)</title>
+<style>
+ body{font-family:Inter,Helvetica,Arial,sans-serif;margin:0;background:#f4f6fb;color:#141414}
+ header{background:#0E2350;color:#fff;padding:22px 32px}
+ header h1{margin:0;font-size:22px} header .s{color:#E8A81C;font-size:13px;margin-top:4px}
+ .wrap{max-width:1000px;margin:24px auto;padding:0 20px}
+ .card{background:#fff;border-radius:10px;padding:18px 22px;margin-bottom:16px;box-shadow:0 2px 10px rgba(0,0,0,.06)}
+ .card h2{margin:0 0 2px;font-size:19px;color:#16357A}
+ .card .sub{color:#6B7280;font-size:13px;margin-bottom:12px}
+ .price{float:right;font-weight:800;color:#0E7C4A;font-size:20px}
+ ul{list-style:none;padding:0;margin:0} li{padding:6px 0;border-top:1px solid #eef0f6;font-size:14px}
+ a{color:#16357A;text-decoration:none} a:hover{text-decoration:underline}
+ .b{color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;letter-spacing:.5px;margin-right:6px}
+ .meta{color:#6B7280;font-size:12px}
+ .note{color:#6B7280;font-size:12px;margin:6px 0 20px}
+</style></head><body>
+<header><h1>Deal marketing assets — aggregated (TK-10708)</h1>
+<div class="s">reflyers staging · ${dealList.length} deals · ${assets.length} consumer-visible assets (GATED marketplace assets hidden)</div></header>
+<div class="wrap">
+<div class="note">Consumer preview of what CRCP/RENTV would render beside each deal. RECAP = our own Tier-2 one-sheet; FIRST-PARTY = owner/developer/press link; BROKER = broker-owned page.</div>
+${dealList.map(d => `<div class="card">
+ <span class="price">${money(d.sale_price)}</span>
+ <h2>${tc(d.address)}</h2>
+ <div class="sub">${tc(d.city)}${d.county_name ? ', ' + tc(d.county_name) + ' County' : ''} · ${tc(d.ctype || '')} · sold ${d.sale_date} · doc ${d.doc_number}</div>
+ <ul>${(byDoc[d.doc_number] || []).map(assetRow).join('')}</ul>
+</div>`).join('\n')}
+</div></body></html>`;
+
+const path = join(OUT, 'deal-assets.html');
+writeFileSync(path, html);
+console.log(`Wrote ${path} — ${dealList.length} deals, ${assets.length} consumer assets.`);
← f0a5cd1 probe v2: real firm-domain join + live discovery findings on
·
back to Re Flyer Aggregator
·
consumable surface: read-only deal-assets viewer/API over us 24fe2f4 →