← back to Homesonspec
collectors/meridian-homes/src/index.ts
215 lines
import { parse, type HTMLElement } from "node-html-parser";
import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
import {
fetchFixtures,
type ExtractionOutput,
type RawPage,
type SourceAdapter,
} from "@homesonspec/collectors-common";
/**
* Meridian Homes adapter — the reference fixture adapter. Deterministic
* selector-based extraction; every field carries evidence; anything the
* page doesn't state is null.
*/
const BUILDER_SLUG = "meridian-homes";
function fv<T>(
value: T | null,
raw: string | null,
sourceUrl: string,
evidenceText?: string | null,
): FieldValue<T> {
return {
value,
raw,
evidenceText: evidenceText ?? raw,
sourceUrl,
confidence: value === null ? 0 : 1, // deterministic extraction: found = 1, absent = 0
};
}
function text(root: HTMLElement, selector: string): string | null {
const el = root.querySelector(selector);
const t = el?.textContent?.trim();
return t && t.length > 0 ? t : null;
}
function parseMoney(raw: string | null): number | null {
if (!raw) return null;
const match = raw.replace(/,/g, "").match(/\$?\s*(\d+(?:\.\d+)?)/);
return match?.[1] ? Number(match[1]) : null;
}
function parseLeadingNumber(raw: string | null): number | null {
if (!raw) return null;
const match = raw.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
return match?.[1] ? Number(match[1]) : null;
}
/** "Est. Completion: November 2026" → ISO first-of-month. Null if absent/unparseable. */
function parseCompletion(raw: string | null): string | null {
if (!raw) return null;
const match = raw.match(/([A-Z][a-z]+)\s+(\d{4})/);
if (!match) return null;
const date = new Date(`${match[1]} 1, ${match[2]} 12:00:00 UTC`);
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
}
function parseStatus(raw: string | null): "PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
if (!raw) return null;
const normalized = raw.toLowerCase();
if (normalized.includes("move-in ready") || normalized.includes("move in ready")) return "MOVE_IN_READY";
if (normalized.includes("under construction")) return "UNDER_CONSTRUCTION";
if (normalized.includes("planned") || normalized.includes("coming soon")) return "PLANNED";
return null;
}
function geoAttr(root: HTMLElement, attr: string): number | null {
const el = root.querySelector(".geo");
const raw = el?.getAttribute(attr);
const value = raw ? Number(raw) : NaN;
return Number.isFinite(value) ? value : null;
}
function extractCommunity(root: HTMLElement, url: string): ExtractedRecord {
const name = text(root, ".community-name");
const hoaRaw = text(root, ".hoa");
const ageRaw = text(root, ".age-restriction");
const lat = geoAttr(root, "data-lat");
const lon = geoAttr(root, "data-lon");
return {
entityType: "community",
canonicalHints: {
builderSlug: BUILDER_SLUG,
communityName: name,
lat,
lon,
},
fields: {
name: fv(name, name, url),
street: fv(text(root, ".community-address .street"), text(root, ".community-address .street"), url),
city: fv(text(root, ".city"), text(root, ".city"), url),
state: fv(text(root, ".state"), text(root, ".state"), url),
zip: fv(text(root, ".zip"), text(root, ".zip"), url),
county: fv(text(root, ".county"), text(root, ".county"), url),
metro: fv(text(root, ".metro"), text(root, ".metro"), url),
lat: fv(lat, lat === null ? null : String(lat), url),
lon: fv(lon, lon === null ? null : String(lon), url),
hoaFeeMonthly: fv(parseMoney(hoaRaw), hoaRaw, url),
schoolDistrict: fv(
text(root, ".school-district")?.replace(/^School District:\s*/i, "") ?? null,
text(root, ".school-district"),
url,
),
ageRestricted: fv(
ageRaw === null ? null : /no$/i.test(ageRaw.trim()) ? false : /yes$/i.test(ageRaw.trim()) ? true : null,
ageRaw,
url,
),
},
};
}
function extractHome(root: HTMLElement, url: string): ExtractedRecord {
const address = text(root, ".home-address");
const priceRaw = text(root, ".price"); // absent on contact-for-pricing pages → null, never guessed
const bedsRaw = text(root, ".beds");
const bathsRaw = text(root, ".baths");
const sqftRaw = text(root, ".sqft");
const storiesRaw = text(root, ".stories");
const garageRaw = text(root, ".garage");
const statusRaw = text(root, ".construction-status");
const completionRaw = text(root, ".est-completion");
const planName = text(root, ".plan-name");
const lotRaw = text(root, ".lot-number");
const lotNumber = lotRaw?.replace(/^Lot\s+/i, "") ?? null;
const inventoryId = root.querySelector(".inventory-home")?.getAttribute("data-inventory-id") ?? null;
const lat = geoAttr(root, "data-lat");
const lon = geoAttr(root, "data-lon");
return {
entityType: "inventory_home",
canonicalHints: {
builderSlug: BUILDER_SLUG,
communityName: "Cedar Bend",
address,
lotNumber,
builderInventoryId: inventoryId,
lat,
lon,
planName,
},
fields: {
street: fv(address, address, url),
city: fv(text(root, ".city"), text(root, ".city"), url),
state: fv(text(root, ".state"), text(root, ".state"), url),
zip: fv(text(root, ".zip"), text(root, ".zip"), url),
price: fv(parseMoney(priceRaw), priceRaw, url),
beds: fv(parseLeadingNumber(bedsRaw), bedsRaw, url),
bathsTotal: fv(parseLeadingNumber(bathsRaw), bathsRaw, url),
sqft: fv(parseLeadingNumber(sqftRaw), sqftRaw, url),
stories: fv(parseLeadingNumber(storiesRaw), storiesRaw, url),
garageSpaces: fv(parseLeadingNumber(garageRaw), garageRaw, url),
homeType: fv("SINGLE_FAMILY" as const, null, url, "single-family inventory page layout"),
constructionStatus: fv(parseStatus(statusRaw), statusRaw, url),
estCompletionDate: fv(parseCompletion(completionRaw), completionRaw, url),
lotNumber: fv(lotNumber, lotRaw, url),
builderInventoryId: fv(inventoryId, inventoryId, url),
lat: fv(lat, lat === null ? null : String(lat), url),
lon: fv(lon, lon === null ? null : String(lon), url),
planName: fv(planName, planName, url),
},
};
}
function extractIncentives(root: HTMLElement, url: string): ExtractedRecord[] {
return root.querySelectorAll("section.incentive").map((section, index) => {
const title = section.querySelector(".incentive-title")?.textContent?.trim() ?? null;
const description = section.querySelector(".incentive-description")?.textContent?.trim() ?? null;
const valueRaw = section.querySelector(".incentive-value")?.textContent?.trim() ?? null;
const expiresRaw = section.querySelector(".incentive-expires")?.textContent?.trim() ?? null;
const expiresMatch = expiresRaw?.match(/(\d{4}-\d{2}-\d{2})/) ?? null;
return {
entityType: "incentive" as const,
canonicalHints: {
builderSlug: BUILDER_SLUG,
communityName: "Cedar Bend",
builderInventoryId: `incentive-${index + 1}`,
},
fields: {
title: fv(title, title, url),
description: fv(description, description, url),
valueUsd: fv(parseMoney(valueRaw), valueRaw, url),
expiresAt: fv(expiresMatch?.[1] ?? null, expiresRaw, url),
},
};
});
}
export const meridianHomesAdapter: SourceAdapter = {
key: "meridian-homes-fixtures",
version: "1.0.0",
fetch: fetchFixtures,
extract(page: RawPage): ExtractionOutput {
try {
const root = parse(page.body.toString("utf8"));
if (root.querySelector(".community-page")) {
return { records: [extractCommunity(root, page.url)], errors: [] };
}
if (root.querySelector(".inventory-home")) {
return { records: [extractHome(root, page.url)], errors: [] };
}
if (root.querySelector(".incentives-page")) {
return { records: extractIncentives(root, page.url), errors: [] };
}
return { records: [], errors: [{ url: page.url, reason: "unrecognized page structure" }] };
} catch (error) {
return { records: [], errors: [{ url: page.url, reason: String(error) }] };
}
},
};