← back to Rentv
RENTV closings: merge article corpus into /api/closings (Sale-only, listings filtered, deduped vs deals feed) — closes reporting gaps, self-heals as crawl grows; surface buyer/seller/broker on cards — TK-10254
f44b4fff4d4e5147f09985ae42411859e98236f7 · 2026-08-05 13:53:28 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/closings.htmlM server.js
Diff
commit f44b4fff4d4e5147f09985ae42411859e98236f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:53:28 2026 -0700
RENTV closings: merge article corpus into /api/closings (Sale-only, listings filtered, deduped vs deals feed) — closes reporting gaps, self-heals as crawl grows; surface buyer/seller/broker on cards — TK-10254
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/closings.html | 5 ++++-
server.js | 43 ++++++++++++++++++++++++++++++++++++-------
2 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/public/closings.html b/public/closings.html
index 8db2e493..f0c794d8 100644
--- a/public/closings.html
+++ b/public/closings.html
@@ -61,6 +61,8 @@
.card h3{font-size:13px;color:var(--sub);font-weight:600;line-height:1.35}
.card .meta{font-size:12px;color:var(--sub);font-weight:600;display:flex;flex-wrap:wrap;gap:4px 10px}
.card .meta b{color:var(--ink)}
+ .card .meta.parties{font-size:11px;gap:3px 8px}
+ .card .meta.parties span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block}
.card .cardfoot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:10px;border-top:1px solid var(--line)}
.card .more{font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
.card .acts{display:flex;gap:6px}
@@ -233,6 +235,7 @@ function render(){
<div class="body">
<div class="addr">${esc(d.address||d.title)}</div>
${loc?`<div class="meta"><span>📍 <b>${esc(loc)}</b></span>${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${esc(d.year_built)}</span>`:''}</div>`:''}
+ ${(d.buyer||d.seller||d.broker)?`<div class="meta parties">${d.buyer?`<span title="Buyer">🟢 <b>${esc(d.buyer)}</b></span>`:''}${d.seller?`<span title="Seller">🔴 ${esc(d.seller)}</span>`:''}${d.broker?`<span title="Broker">🤝 ${esc(d.broker)}</span>`:''}</div>`:''}
${(d.address&&d.title)?`<h3>${esc(d.title)}</h3>`:''}
<div class="cardfoot">
${story}
@@ -317,7 +320,7 @@ async function load(){
const data=await r.json();
ALL=data.deals||[];
const dated=ALL.map(dateOf).filter(Boolean).sort((a,b)=>b-a);
- $('#fresh').textContent=`${ALL.length} closings${dated[0]?' · latest '+fmtDate(dated[0]):''} · ${data.manual||0} manual · ${data.hidden||0} hidden · base feed ${data.fetched_at?('updated '+fmtDate(new Date(data.fetched_at))):'live'}`;
+ $('#fresh').textContent=`${ALL.length} closings${dated[0]?' · latest '+fmtDate(dated[0]):''} · ${data.from_feed||0} live feed · ${data.from_corpus||0} article archive · ${data.manual||0} manual · ${data.hidden||0} hidden`;
render();
}catch(e){$('#grid').innerHTML='<div class="empty">Failed to load closings: '+esc(e.message)+'</div>';}
}
diff --git a/server.js b/server.js
index 5330250a..e0327154 100644
--- a/server.js
+++ b/server.js
@@ -293,15 +293,38 @@ app.get('/api/unified-search', adminOnly, (req, r) => {
r.json({ total, offset, count: Math.min(limit, Math.max(0, total - offset)), by_build: byBuild, by_type: byType, index_built_at: meta.built_at, results: out.slice(offset, offset + limit).map(({ _t, text, ...keep }) => keep) });
});
-// ── PROPERTY CLOSINGS: editable override layer over the live Sale feed (TK-10251, Steve
-// 2026-08-05). BACKEND-ONLY (all adminOnly). The base list is deals.json filtered to
-// txn_type=Sale (the auto-pull keeps running). On top of it we persist a manual layer in
-// data/closings-overrides.json: { adds:[…full deal-shaped rows…], edits:{ id:{fields} },
-// hides:[ id… ] }. GET merges them; the auto-pull NEVER clobbers a manual edit/add/hide. ──
+// ── PROPERTY CLOSINGS: editable override layer over the FULL reported-Sale set (TK-10251 +
+// gap-backfill TK-10254, Steve 2026-08-05). BACKEND-ONLY (all adminOnly). The base list is
+// the union of two Sale sources, deduped by rentv article id:
+// 1. deals.json (the shallow homepage auto-pull — has images/addresses), and
+// 2. the deep article corpus (articles-corpus.jsonl, LLM-extracted — buyer/seller/broker,
+// self-heals as the crawler grows) filtered to real closings (listings/"offered for
+// sale"/pending excluded, absurd sub-$1M price extractions blanked).
+// On top of the base we persist a manual layer in data/closings-overrides.json:
+// { adds:[…], edits:{ id:{fields} }, hides:[ id… ] }. GET merges all of it; neither pull
+// NOR the crawl ever clobbers a manual edit/add/hide. ──
const CLOSINGS_OV = 'closings-overrides.json';
const CLOSE_FIELDS = ['title', 'address', 'city', 'state', 'property_type', 'amount_label', 'size_label', 'year_built', 'close_date', 'image', 'summary'];
const readCloseOv = () => readJSON(CLOSINGS_OV, { adds: [], edits: {}, hides: [], updated_at: null });
const writeCloseOv = (o) => fs.writeFileSync(path.join(DATA, CLOSINGS_OV), JSON.stringify(o, null, 1));
+// A corpus Sale article whose title says it is being marketed, not closed → NOT a closing.
+const CLOSINGS_NONCLOSED = /(offered for sale|being offered|up for sale|may have a buyer|ordered to be sold|hits the market|listed for sale|seeking buyer|goes on the market|put up for sale)/i;
+// Map corpus Sale articles → deal-shaped closing rows (real article id → working /news/:id link).
+function corpusClosings() {
+ return corpus()
+ .filter((a) => String(a.txn_type || '').toLowerCase() === 'sale' && !CLOSINGS_NONCLOSED.test(a.title || ''))
+ .map((a) => {
+ const amt = Number(a.amount) || 0;
+ return {
+ id: String(a.id), title: a.title || '', url: a.url || '', image: a.image || '', txn_type: 'Sale',
+ property_type: a.property_type || '', city: a.city || '', state: a.state || '',
+ amount: a.amount || null, amount_label: (amt > 0 && amt < 1_000_000) ? '' : (a.amount_label || ''),
+ size_label: a.size_label || '', units: a.units || null, sqft: a.sqft || null,
+ address: null, close_date: a.date || '', year_built: null,
+ buyer: a.buyer || '', seller: a.seller || '', broker: a.broker || '', summary: a.summary || '',
+ };
+ });
+}
// Merged closings feed. ?include_hidden=1 keeps hidden feed rows (flagged _hidden) so the
// editor can offer Restore. Manual adds are flagged _manual; edited feed rows _edited.
@@ -311,16 +334,22 @@ app.get('/api/closings', adminOnly, (req, r) => {
const hides = new Set((ov.hides || []).map(String));
const edits = ov.edits || {};
const includeHidden = req.query.include_hidden === '1' || req.query.include_hidden === 'true';
- const base = (feed.deals || []).filter((d) => String(d.txn_type || '').toLowerCase() === 'sale');
+ // Base = deals-pull Sales ∪ corpus Sales, deduped by article id (deals-pull wins — it has images).
+ const feedSales = (feed.deals || []).filter((d) => String(d.txn_type || '').toLowerCase() === 'sale');
+ const feedIds = new Set(feedSales.map((d) => String(d.id)));
+ const corpusExtra = corpusClosings().filter((a) => !feedIds.has(String(a.id)));
+ const base = [...feedSales, ...corpusExtra];
const feedRows = base.map((d) => {
const e = edits[String(d.id)];
const row = e ? { ...d, ...e, _edited: true } : { ...d };
row._hidden = hides.has(String(d.id));
+ row._corpus = feedIds.has(String(d.id)) ? undefined : true;
return row;
}).filter((d) => includeHidden || !d._hidden);
const adds = (ov.adds || []).map((a) => ({ ...a, _manual: true }));
const deals = [...adds, ...feedRows];
- r.json({ source: 'feed+overrides', fetched_at: feed.fetched_at, updated_at: ov.updated_at,
+ r.json({ source: 'feed+corpus+overrides', fetched_at: feed.fetched_at, updated_at: ov.updated_at,
+ from_feed: feedSales.length, from_corpus: corpusExtra.length,
manual: adds.length, hidden: (ov.hides || []).length, count: deals.length, deals });
});
← 80e38cfe ops: persistent (launchd) RENTV build loop — sandboxed claud
·
back to Rentv
·
Audience perf: precompute per-contact search index once on l fc57490b →