← back to Commercialrealestate
scripts/harvest-fd-lee.js
87 lines
// harvest-fd-lee.js — $0 firm-direct harvest for Lee & Associates (TK-10081 Phase 2).
// Lee runs Buildout; the public inventory.json is plain-fetchable (no browser, no key, no bot wall).
// For each of OUR crexi-sourced Lee deals, resolve the firm-direct listing URL + full broker roster
// so the site can link the broker's OWN property page instead of Crexi. Writes data/raw/fd-lee.json.
// READ-ONLY against ranked.json — never mutates it. $0 (plain fetch only).
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const FEED = 'https://buildout.com/plugins/9a64a93980aeae8db347e72cdfa8ca61017acc9a/inventory.json';
// Address normalizer — strip unit/suite, standardize street-type words, collapse to a match key.
const SUF = { street:'st', avenue:'ave', av:'ave', boulevard:'blvd', drive:'dr', road:'rd',
place:'pl', court:'ct', lane:'ln', terrace:'ter', parkway:'pkwy', highway:'hwy', square:'sq' };
function norm(a) {
let s = String(a || '').toLowerCase().split(',')[0]; // drop city/state tail
s = s.replace(/#\s*\S+/g, ' ').replace(/\b(ste|suite|unit|apt|no)\b.*$/,''); // strip only the unit token, not the whole tail
s = s.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
return s.split(' ').map(w => SUF[w] || w).join(' ').trim();
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const priceOf = (item) => {
const pair = (item.index_attributes || []).find(p => /price/i.test(p[0]));
if (!pair) return null;
const n = Number(String(pair[1]).replace(/[^\d.]/g, ''));
return Number.isFinite(n) && n > 0 ? n : null;
};
(async () => {
const ranked = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'ranked.json'), 'utf8'));
const deals = ranked.ranked || ranked;
const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./,''); } catch { return ''; } };
const targets = deals.filter(d => d.broker_url && hostOf(d.broker_url).includes('lee-associates'))
.map(d => ({ id: d.id, address: d.address, city: d.city, key: norm(d.address) }));
console.log(`Lee targets: ${targets.length}`);
// Build normalized-address → firm-direct listing index by paging the Buildout feed.
const index = new Map();
let page = 1, total = Infinity, seen = 0;
while (seen < total && page <= 400) {
let json;
try {
const res = await fetch(`${FEED}?page=${page}`, { headers: { 'accept': 'application/json' } });
if (res.status === 429) { console.log(` 429 at page ${page} — backing off 3s`); await sleep(3000); continue; }
if (!res.ok) { console.log(` page ${page} HTTP ${res.status} — stop`); break; }
json = await res.json();
} catch (e) { console.log(` page ${page} error ${e.message} — stop`); break; }
const inv = json.inventory || [];
total = (json.meta && json.meta.total) || total;
if (!inv.length) break;
for (const it of inv) {
const k = norm(it.address_one_line);
if (k && !index.has(k)) index.set(k, {
listing_url: it.link_target || null,
price: priceOf(it),
brokers: (it.broker_contacts || []).map(b => ({ name: b.name, email: b.email || null }))
.filter(b => b.name) || [],
broker_str: it.broker || null,
sub_type: it.property_sub_type_name || null,
addr: it.address_one_line || null
});
}
seen += inv.length;
if (page % 25 === 0) console.log(` paged ${seen}/${total} (index ${index.size})`);
page++;
await sleep(150);
}
console.log(`Indexed ${index.size} Lee listings from ${seen} feed rows.`);
const rows = targets.map(t => {
const hit = index.get(t.key);
if (!hit) return { deal_id: t.id, address: t.address, matched: false };
return { deal_id: t.id, address: t.address, matched: true,
listing_url: hit.listing_url, price: hit.price,
brokers: hit.brokers.length ? hit.brokers : (hit.broker_str ? [{ name: hit.broker_str, email: null }] : []),
firm_addr: hit.addr, sub_type: hit.sub_type, source_firm: 'Lee & Associates' };
});
const matched = rows.filter(r => r.matched).length;
const out = { firm: 'Lee & Associates', generated: 'PENDING_STAMP', total_targets: targets.length, matched, rows };
fs.mkdirSync(path.join(ROOT, 'data', 'raw'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'data', 'raw', 'fd-lee.json'), JSON.stringify(out, null, 2));
console.log(`\nMATCHED ${matched}/${targets.length} → data/raw/fd-lee.json`);
console.log(JSON.stringify(rows.filter(r => r.matched).slice(0, 3), null, 2));
const misses = rows.filter(r => !r.matched).map(r => r.address);
if (misses.length) console.log(`\nUnmatched (${misses.length}):`, misses.slice(0, 10).join(' | '));
})();