← back to Homesonspec

collectors/discovery/src/index.ts

207 lines

import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
import {
  fetchFixtures,
  LiveFetcher,
  sha256,
  type ExtractionOutput,
  type FetchContext,
  type RawPage,
  type SourceAdapter,
} from "@homesonspec/collectors-common";

/**
 * Discovery Homes adapter — first REGIONAL builder (recon 2026-07-27).
 * discoveryhomes.com hosts several Seeno-family brands on one JSON API:
 *   GET /api/communities  → JSON array (name/city/state/zip/lat/lon/builder/seo_name/url)
 *   GET /api/homes        → {renderedList:"<qmi-card html>"} — a SINGLE site-wide feed
 *                           (the community_id param is IGNORED by the server).
 * So we fetch /api/homes ONCE and join each card back to its community by the
 * href slug (/ST/city/communities/<slug>/...), which yields the home's true
 * builder + location. The join happens in fetch(); extract() stays pure by
 * reading a normalized JSON page we synthesize. Plain HTTP, robots allows /api/.
 * Facts-only (images intentionally omitted). Sold/unpriced homes are skipped.
 */
const ORIGIN = "https://www.discoveryhomes.com";
const COMMUNITIES_URL = `${ORIGIN}/api/communities`;
const HOMES_URL = `${ORIGIN}/api/homes`;
const NORMALIZED_URL = `${HOMES_URL}#normalized`;
// feed builder code -> our Builder.slug (all must exist as Builder rows)
const BUILDER_MAP: Record<string, string> = {
  discovery: "discovery-homes",
  jmscc: "jmc-homes",
  seeno: "seeno-homes",
  sterling: "sterling-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 };
}
const num = (v: unknown): number | null => {
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.replace(/[^0-9.]/g, "")) : NaN;
  return Number.isFinite(n) && n > 0 ? n : null;
};
const str = (v: unknown): string | null => (v == null ? null : String(v).trim() ? String(v).trim() : null);
// lat/lon: preserve sign (US longitudes are negative) — never strip "-" or reject <0.
const coord = (v: unknown): number | null => {
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
  return Number.isFinite(n) && n !== 0 ? n : null;
};
const slugify = (s: string) => (s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

interface Community {
  id: number; name?: string; builder?: string; city?: string; state?: string; zip?: unknown;
  county?: string; latitude?: unknown; longitude?: unknown; phone?: string; seo_name?: string; url?: string;
}
interface NormHome {
  builderSlug: string; communityName: string; street: string; city: string | null; state: string | null;
  zip: string | null; price: number; priceRaw: string; beds: number | null; baths: number | null;
  sqft: number | null; plan: string | null; homeId: string;
}

/** index communities by every slug a home href might reference */
function buildIndex(comms: Community[]): Map<string, Community> {
  const idx = new Map<string, Community>();
  for (const c of comms) {
    const keys = [c.seo_name, typeof c.url === "string" ? c.url.replace(/\/+$/, "").split("/").pop() : null, slugify(c.name ?? "")];
    for (const k of keys) if (k) idx.set(String(k).toLowerCase(), c);
  }
  return idx;
}

function parseHomes(html: string, idx: Map<string, Community>): NormHome[] {
  const out: NormHome[] = [];
  for (const card of html.split("qmi-card__container").slice(1)) {
    const href = card.match(/href="\/([A-Z]{2})\/([^/]+)\/communities\/([^/]+)\/[^"]*?-(\d{5,})/i);
    if (!href) continue;
    const hrefState = href[1]!.toUpperCase();
    const commSlug = href[3]!.toLowerCase();
    const homeId = href[4]!;
    const community = idx.get(commSlug);
    if (!community) continue;
    const builderSlug = BUILDER_MAP[(community.builder ?? "").toLowerCase()];
    if (!builderSlug) continue; // builder not registered / out of scope

    const priceRaw = (card.match(/qmi-card__price[^>]*>\s*([^<]+)/i)?.[1] ?? "").trim();
    const price = /\$[\d,]/.test(priceRaw) ? num(priceRaw) : null;
    if (!price) continue; // sold / unpriced → not current for-sale inventory

    const beds = num(card.match(/class="beds"[^>]*>\s*([\d.]+)/i)?.[1] ?? null);
    const baths = num(card.match(/class="baths"[^>]*>\s*([\d.]+)/i)?.[1] ?? null);
    const sqft = num(card.match(/class="sqft"[^>]*>\s*([\d,]+)/i)?.[1] ?? null);
    const plan = card.match(/qmi-card__name[^>]*>[\s\S]*?<a[^>]*>\s*([^<]+?)\s*</i)?.[1]?.trim() ?? null;
    const addrBlock = (card.match(/qmi-card__address[^>]*>\s*([^<]+)/i)?.[1] ?? "").replace(/\s+/g, " ").trim();

    // location: state from href (authoritative), zip from address block, city from the matched community
    const zip = addrBlock.match(/\b(\d{5})(?:-\d{4})?\b/)?.[1] ?? str(community.zip);
    const city = str(community.city);
    const state = hrefState || (str(community.state) ?? "")?.toUpperCase() || null;
    let street = addrBlock.replace(/,?\s*[A-Z]{2}\s+\d{5}(?:-\d{4})?\s*$/, "").trim();
    if (city) street = street.replace(new RegExp("\\s*" + city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*$", "i"), "").trim();
    if (!street) continue;

    out.push({
      builderSlug, communityName: str(community.name) ?? commSlug, street, city, state, zip,
      price, priceRaw, beds, baths, sqft, plan, homeId,
    });
  }
  return out;
}

export const discoveryAdapter: SourceAdapter = {
  key: "discovery-homes-site",
  version: "1.1.0",

  async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
    if (ctx.mode === "fixture") {
      yield* fetchFixtures(ctx);
      return;
    }
    const fetcher = new LiveFetcher(ctx.registry);
    const commPage = await fetcher.fetch(COMMUNITIES_URL);
    yield commPage; // → community records

    let comms: Community[] = [];
    try { comms = JSON.parse(commPage.body.toString("utf8")); } catch { comms = []; }
    const idx = buildIndex(comms);
    const homesPage = await fetcher.fetch(HOMES_URL);
    let html = "";
    try { html = JSON.parse(homesPage.body.toString("utf8")).renderedList ?? ""; } catch { html = ""; }
    const normalized = parseHomes(html, idx);
    const body = Buffer.from(JSON.stringify(normalized), "utf8");
    yield { url: NORMALIZED_URL, retrievedAt: new Date().toISOString(), contentType: "application/json", body, contentHash: sha256(body) };
  },

  extract(page: RawPage): ExtractionOutput {
    try {
      if (page.url.includes("#normalized")) return extractNormalizedHomes(page);
      if (page.url.includes("/api/communities")) return extractCommunities(page);
      return { records: [], errors: [] };
    } catch (error) {
      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
    }
  },
};

function extractCommunities(page: RawPage): ExtractionOutput {
  let all: Community[] = [];
  try { all = JSON.parse(page.body.toString("utf8")); } catch { return { records: [], errors: [{ url: page.url, reason: "communities JSON parse failed" }] }; }
  const records: ExtractedRecord[] = [];
  for (const c of all) {
    const builderSlug = BUILDER_MAP[(c.builder ?? "").toLowerCase()];
    if (!builderSlug) continue;
    const name = str(c.name);
    if (!name) continue;
    const phone = str(c.phone);
    records.push({
      entityType: "community",
      canonicalHints: { builderSlug, communityName: name },
      fields: {
        name: fv(name, name, page.url),
        street: fv<string>(null, null, page.url),
        city: fv(str(c.city), str(c.city), page.url),
        state: fv((str(c.state) ?? "").toUpperCase() || null, str(c.state), page.url),
        zip: fv(str(c.zip), str(c.zip), page.url),
        county: fv(str(c.county), str(c.county), page.url),
        metro: fv<string>(null, null, page.url),
        lat: fv(coord(c.latitude), str(c.latitude), page.url),
        lon: fv(coord(c.longitude), str(c.longitude), page.url),
        hoaFeeMonthly: fv<number>(null, null, page.url),
        schoolDistrict: fv<string>(null, null, page.url),
        ageRestricted: fv<boolean>(null, null, page.url),
        salesPhone: fv(phone ? phone.replace(/[^0-9+]/g, "") : null, phone, page.url, "community sales phone"),
      },
    });
  }
  return { records, errors: [] };
}

function extractNormalizedHomes(page: RawPage): ExtractionOutput {
  let homes: NormHome[] = [];
  try { homes = JSON.parse(page.body.toString("utf8")); } catch { return { records: [], errors: [{ url: page.url, reason: "normalized homes parse failed" }] }; }
  const records: ExtractedRecord[] = homes.map((h) => ({
    entityType: "inventory_home" as const,
    canonicalHints: { builderSlug: h.builderSlug, communityName: h.communityName, address: h.street, builderInventoryId: h.homeId, planName: h.plan },
    fields: {
      street: fv(h.street, h.street, page.url),
      city: fv(h.city, h.city, page.url),
      state: fv(h.state, h.state, page.url),
      zip: fv(h.zip, h.zip, page.url),
      price: fv(h.price, h.priceRaw, page.url, `price ${h.priceRaw}`),
      beds: fv(h.beds, null, page.url),
      bathsTotal: fv(h.baths, null, page.url),
      sqft: fv(h.sqft, null, page.url),
      stories: fv<number>(null, null, page.url),
      garageSpaces: fv<number>(null, null, page.url),
      homeType: fv("SINGLE_FAMILY" as never, null, page.url, "Discovery Homes quick move-in"),
      constructionStatus: fv("MOVE_IN_READY" as never, h.priceRaw, page.url),
      estCompletionDate: fv<string>(null, null, page.url),
      lotNumber: fv<string>(null, null, page.url),
      builderInventoryId: fv(h.homeId, h.homeId, page.url),
      planName: fv(h.plan, h.plan, page.url),
      availabilityStatus: fv("Quick Move-In", null, page.url),
      images: fv<string[]>([], null, page.url), // facts-only — mediaRights=NONE
    },
  }));
  return { records, errors: [] };
}