← 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) }] };
    }
  },
};