← back to Homesonspec

collectors/fischer-homes/selftest.mjs

157 lines

// Self-contained data-quality proof for the Fischer Homes adapter.
// Hits the LIVE source with the honest bot UA, applies the same extraction the
// adapter will use, and reports coverage / distinct-address / distinct-sqft / samples.
// $0 (local). No DB, no writes.

const UA = "HomesOnSpecBot/0.1 (+https://homesonspec.com/bot; contact: data@homesonspec.com)";
const BASE = "https://www.fischerhomes.com";
const PAGE_LIMIT = Number(process.env.FISCHER_PAGE_LIMIT ?? 10);
// Region ids proven in recon (subset of /api/region-dropdown covering OH/KY/IN/GA/MO):
// 11 Cincinnati OH, 16 Dayton OH, 22 Columbus OH, 35 Indianapolis IN, 47 Atlanta GA,
// 48 Louisville KY, 13 Northern KY, 51 St Louis MO. Self-test uses a couple to keep it quick.
const REGIONS = (process.env.FISCHER_REGIONS ?? "11,35,47").split(",").map((s) => s.trim()).filter(Boolean);

async function getJson(url) {
  const r = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json,*/*;q=0.8" }, redirect: "follow", signal: AbortSignal.timeout(25000) });
  if (!r.ok) throw new Error(`GET ${url} -> ${r.status}`);
  return r.json();
}
async function getText(url) {
  const r = await fetch(url, { headers: { "User-Agent": UA, Accept: "text/html,*/*;q=0.8" }, redirect: "follow", signal: AbortSignal.timeout(25000) });
  if (!r.ok) throw new Error(`GET ${url} -> ${r.status}`);
  return r.text();
}

const num = (v) => { const n = Number(String(v ?? "").replace(/[^0-9.]/g, "")); return Number.isFinite(n) && n > 0 ? n : null; };
const stripTags = (s) => String(s ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();

// Baths like "3½", "2 + ½ + ½", "3" -> total number (½ = 0.5)
function parseBaths(s) {
  const t = String(s ?? "");
  if (!t.trim()) return null;
  const halves = (t.match(/½/g) || []).length;
  const wholeMatch = t.match(/\d+/g);
  const whole = wholeMatch ? wholeMatch.reduce((a, b) => a + Number(b), 0) : 0;
  const total = whole + halves * 0.5;
  return total > 0 ? total : null;
}
function priceFromFormatted(html) {
  const m = String(html ?? "").match(/\$?\s*([\d,]{4,})/);
  return m ? num(m[1]) : null;
}

// --- List API: all homes for a region (bulk, paginated) ---
async function fetchRegionHomes(regionId) {
  const out = [];
  let page = 1, lastPage = 1;
  while (page <= lastPage && page <= 20) {
    const url = `${BASE}/api/region-revamped/homes/${regionId}?page=${page}`;
    const body = await getJson(url);
    const sec = body["move-in-ready"] || {};
    lastPage = Number(sec.last_page ?? 1);
    for (const h of sec.data ?? []) out.push(h);
    page++;
    await new Promise((r) => setTimeout(r, 300));
  }
  return out;
}

// --- Detail page JSON-LD enrichment: lat/lon + community + confirm sqft/beds/baths + exact price ---
function extractDetail(html) {
  const out = { lat: null, lon: null, community: null, plan: null, price: null, sqft: null, beds: null, bathsFull: null, bathsHalf: null, city: null, state: null, zip: null, street: null };
  const m = html.match(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/i);
  if (m) {
    try {
      const d = JSON.parse(m[1]);
      const addr = d.address || {};
      out.street = addr.streetAddress ?? null;
      out.city = addr.addressLocality ?? null;
      out.state = addr.addressRegion ?? null;
      out.zip = addr.postalCode ?? null;
      const lat = Number(d.latitude ?? d.geo?.latitude);
      const lon = Number(d.longitude ?? d.geo?.longitude);
      out.lat = Number.isFinite(lat) && lat !== 0 ? lat : null;
      out.lon = Number.isFinite(lon) && lon !== 0 ? lon : null;
      out.sqft = num(d.floorSize?.value);
      out.beds = num(d.numberOfBedrooms);
      out.bathsFull = d.numberOfFullBathrooms != null ? Number(d.numberOfFullBathrooms) : null;
      out.bathsHalf = d.numberOfPartialBathrooms != null ? Number(d.numberOfPartialBathrooms) : null;
      out.plan = d.accommodationFloorPlan?.name ?? null;
      // community: JSON-LD description "built in <community> located in"
      const desc = String(d.description ?? "");
      const cm = desc.match(/built in ([^,]+?) located/i);
      if (cm) out.community = cm[1].trim();
    } catch {}
  }
  // community fallback from <title> "... | <community> by Fischer Homes"
  if (!out.community) {
    const tm = html.match(/<title>[^|]*\|\s*(.+?)\s+by Fischer Homes/i);
    if (tm) out.community = tm[1].trim();
  }
  // exact price from sale-price element
  const pm = html.match(/class="[^"]*sale-price-bold[^"]*"[^>]*>\s*\$?\s*([\d,]+)/i);
  if (pm) out.price = num(pm[1]);
  return out;
}

// ---- run ----
console.log(`Fischer self-test | UA=${UA}\nRegions=${REGIONS.join(",")} PAGE_LIMIT(list-pages/region cap)=20\n`);
let summaries = [];
for (const r of REGIONS) {
  try {
    const hs = await fetchRegionHomes(r);
    console.log(`  region ${r}: ${hs.length} homes from list API`);
    summaries = summaries.concat(hs.map((h) => ({ ...h, _region: r })));
  } catch (e) { console.log(`  region ${r}: LIST FAIL ${e.message}`); }
}
console.log(`\nTotal list-API homes: ${summaries.length}`);

// Enrich each home via its detail page (cap for self-test speed via PAGE_LIMIT*3 homes)
const cap = Math.min(summaries.length, Math.max(30, PAGE_LIMIT * 6));
const rows = [];
let enriched = 0;
for (const s of summaries.slice(0, cap)) {
  const detailUrl = BASE + s.url;
  let det = {};
  try { det = extractDetail(await getText(detailUrl)); enriched++; } catch (e) { /* keep list data */ }
  // Facts policy: the LIST API's formatted fields are the customer-facing truth
  // for beds/baths/sqft/price (complete, incl. half-baths). JSON-LD's
  // numberOfPartialBathrooms is unreliable (reports 0 when a half-bath exists),
  // so it's used ONLY as a fallback. Detail page is authoritative for lat/lon +
  // community, which the list API lacks.
  const price = priceFromFormatted(s.formattedPrice) ?? det.price;
  const beds = num(s.formattedBeds) ?? det.beds;
  const baths = parseBaths(s.formattedBaths) ?? ((det.bathsFull != null) ? det.bathsFull + (det.bathsHalf ?? 0) * 0.5 : null);
  const sqft = num(s.formattedSqft) ?? det.sqft;
  rows.push({
    street: det.street ?? (s.formattedAddress || "").split(",")[0]?.trim() ?? null,
    fullAddress: s.formattedAddress ?? null,
    city: det.city, state: det.state, zip: det.zip,
    community: det.community, plan: det.plan ?? s.name ?? null,
    price, beds, baths, sqft, lat: det.lat, lon: det.lon,
    region: s._region,
  });
  await new Promise((r) => setTimeout(r, 250));
}

const N = rows.length;
const pct = (f) => N ? ((rows.filter(f).length / N) * 100).toFixed(1) + "%" : "0%";
const distinct = (f) => new Set(rows.map(f).filter((x) => x != null)).size;

console.log(`\n===== DATA QUALITY (${N} enriched homes; ${enriched} detail pages fetched) =====`);
console.log(`price coverage:      ${pct((r) => r.price != null)}`);
console.log(`beds coverage:       ${pct((r) => r.beds != null)}`);
console.log(`baths coverage:      ${pct((r) => r.baths != null)}`);
console.log(`sqft coverage:       ${pct((r) => r.sqft != null)}`);
console.log(`lat/lon coverage:    ${pct((r) => r.lat != null && r.lon != null)}`);
console.log(`community coverage:  ${pct((r) => r.community != null)}`);
console.log(`plan coverage:       ${pct((r) => r.plan != null)}`);
console.log(`distinct addresses:  ${distinct((r) => r.fullAddress)} / ${N}`);
console.log(`distinct sqft:       ${distinct((r) => r.sqft)}`);
console.log(`distinct prices:     ${distinct((r) => r.price)}`);
console.log(`distinct communities:${distinct((r) => r.community)}`);
console.log(`negative-lon (US):   ${rows.filter((r) => r.lon != null && r.lon < 0).length}/${rows.filter((r) => r.lon != null).length}`);

console.log(`\n===== 6 SAMPLE ROWS =====`);
for (const r of rows.slice(0, 6)) console.log(JSON.stringify(r));