← back to Homesonspec

collectors/dream-finders/src/index.ts

218 lines

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

/**
 * Dream Finders adapter — EMBEDDED_JSON source (recon 2026-07-22, difficulty 1).
 * sitemap-specs.xml lists every spec/inventory home with daily lastmod —
 * effectively a native inventory feed. Each spec page carries ld+json
 * Product+Offer blocks (price, availability). robots.txt allows the
 * inventory tree (only login/favorites/sitesearch.json disallowed).
 *
 * URL shape: /new-homes/{state}/{city}/{community}/{plan}/{address-slug}/
 * Batch control: DREAMFINDERS_PAGE_LIMIT (default 10).
 */

const SITEMAP_URL = "https://dreamfindershomes.com/sitemap-specs.xml";
const BUILDER_SLUG = "dream-finders";
const PAGE_LIMIT = Number(process.env.DREAMFINDERS_PAGE_LIMIT ?? 10);

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 };
}

function titleCase(slug: string): string {
  return slug.split("-").map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
}

interface UrlParts {
  state: string | null;
  city: string | null;
  community: string | null;
  plan: string | null;
  address: string | null;
}

/** /new-homes/co/longmont/mountain-brook/ridgeline/2848-bear-springs-cir/ */
function parseSpecUrl(url: string): UrlParts | null {
  const segments = url.replace(/^https?:\/\/[^/]+/, "").split("/").filter(Boolean);
  if (segments[0] !== "new-homes" || segments.length < 6) return null;
  return {
    state: normalizeStateCode(segments[1] ?? null),
    city: segments[2] ? titleCase(segments[2]) : null,
    community: segments[3] ? titleCase(segments[3]) : null,
    plan: segments[4] ? titleCase(segments[4]) : null,
    address: segments[5] ? titleCase(segments[5]) : null,
  };
}

function extractLdJsonBlocks(html: string): unknown[] {
  const blocks: unknown[] = [];
  // NB: Dream Finders emits type='application/ld+json' with SINGLE quotes — the original regex
  // required double quotes and matched ZERO blocks, which was the true root of the hollow-data
  // failure (no ld+json ever parsed). Accept either quote style. [2026-07-29]
  for (const match of html.matchAll(/<script[^>]*type=['"]application\/ld\+json['"][^>]*>([\s\S]*?)<\/script>/g)) {
    try {
      blocks.push(JSON.parse(match[1]!));
    } catch {
      // malformed block — skip, never guess
    }
  }
  return blocks;
}

interface OwnHome {
  price: number | null; priceRaw: string | null;
  beds: number | null; baths: number | null; sqft: number | null;
  lat: number | null; lon: number | null;
}
const posNum = (v: unknown): number | null => {
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
  return Number.isFinite(n) && n > 0 ? n : null;
};

/**
 * Find THIS page's own home in the ld+json. A DF spec page carries a Product/SingleFamilyResidence
 * block PER home (its own + siblings in the community); the page's own home is the block whose `url`
 * ends with the page's address slug. The original findOwnOffer matched offers.url === pageUrl exactly
 * and got 0 (siblings' urls differ, trailing-slash drift), producing hollow 0%-price rows — the fix is
 * a slug match, and the same block also yields beds/baths/sqft + GeoCoordinates ($0 geo). [2026-07-29]
 */
function findOwnHome(blocks: unknown[], addressSlug: string): OwnHome {
  const empty: OwnHome = { price: null, priceRaw: null, beds: null, baths: null, sqft: null, lat: null, lon: null };
  const products: Record<string, unknown>[] = [];
  const walk = (node: unknown) => {
    if (!node || typeof node !== "object") return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    const obj = node as Record<string, unknown>;
    const t = JSON.stringify(obj["@type"] ?? "");
    if (/Product|SingleFamilyResidence|House/.test(t) && (obj.url || obj.offers)) products.push(obj);
    for (const v of Object.values(obj)) walk(v);
  };
  blocks.forEach(walk);
  const slug = addressSlug.replace(/\/$/, "").toLowerCase();
  // own home = the product whose url ends with the page's address slug. CRITICAL: no products[0]
  // fallback — some DF pages carry only SIBLING homes in ld+json (not their own), and blindly taking
  // the first product assigns one home's price/beds to every sibling page = the hollow-data trap that
  // originally sank this adapter. If the page's own home isn't in ld+json, return null (honest) rather
  // than a wrong home's facts. [2026-07-29]
  const own = products.find((p) => String((p.url ?? (p.offers as Record<string, unknown>)?.url) ?? "").replace(/\/$/, "").toLowerCase().endsWith(slug));
  if (!own) return empty;
  const offers = (own.offers as Record<string, unknown>) ?? {};
  const geo = (own.geo as Record<string, unknown>) ?? {};
  const bathsTotal = posNum(own.numberOfBathroomsTotal)
    ?? (posNum(own.numberOfFullBathrooms) !== null
        ? posNum(own.numberOfFullBathrooms)! + (posNum(own.numberOfPartialBathrooms) ?? 0) * 0.5
        : null);
  const sqftNode = own.floorSize as Record<string, unknown> | undefined;
  const lat = posNum(geo.latitude), lonRaw = geo.longitude;
  const lon = lonRaw != null && Number.isFinite(Number(lonRaw)) && Number(lonRaw) !== 0 ? Number(lonRaw) : null;
  return {
    price: posNum(offers.price), priceRaw: offers.price != null ? String(offers.price) : null,
    beds: posNum(own.numberOfBedrooms),
    baths: bathsTotal,
    sqft: posNum(sqftNode?.value ?? own.floorSize),
    lat: lat, // US bbox-gated below via coord()
    lon: lon !== null && lon < 0 && lon > -180 ? lon : null,
  };
}

export const dreamFindersAdapter: SourceAdapter = {
  key: "dream-finders-site",
  version: "1.0.0",

  async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
    if (ctx.mode === "fixture") {
      yield* fetchFixtures(ctx);
      return;
    }
    const fetcher = new LiveFetcher(ctx.registry);
    const sitemap = await fetcher.fetch(SITEMAP_URL);
    const urls = [...sitemap.body.toString("utf8").matchAll(/<loc>([^<]+)<\/loc>/g)]
      .map((m) => m[1]!)
      .filter((url) => parseSpecUrl(url) !== null);
    urls.sort();
    for (const url of urls.slice(0, PAGE_LIMIT)) {
      try {
        yield await fetcher.fetch(url);
      } catch (error) {
        console.warn(`  skip ${url}: ${error instanceof Error ? error.message : String(error)}`);
      }
    }
  },

  extract(page: RawPage): ExtractionOutput {
    try {
      const parts = parseSpecUrl(page.url);
      if (!parts?.community || !parts.address) {
        return { records: [], errors: [{ url: page.url, reason: "unparseable spec URL" }] };
      }
      const html = page.body.toString("utf8");
      const blocks = extractLdJsonBlocks(html);
      const addressSlug = page.url.replace(/^https?:\/\/[^/]+/, "").replace(/\/$/, "").split("/").filter(Boolean).pop() ?? "";
      const own = findOwnHome(blocks, addressSlug);

      const records: ExtractedRecord[] = [
        // Community first — publish creates/refreshes the FK target.
        {
          entityType: "community",
          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: parts.community },
          fields: {
            name: fv(parts.community, parts.community, page.url, "community segment of spec URL"),
            street: fv<string>(null, null, page.url),
            city: fv(parts.city, parts.city, page.url),
            state: fv(parts.state, parts.state, page.url),
            zip: fv<string>(null, null, page.url),
            county: fv<string>(null, null, page.url),
            metro: fv<string>(null, null, page.url),
            lat: fv(own.lat, own.lat === null ? null : String(own.lat), page.url, own.lat === null ? null : "ld+json GeoCoordinates"),
            lon: fv(own.lon, own.lon === null ? null : String(own.lon), page.url, own.lon === null ? null : "ld+json GeoCoordinates"),
            hoaFeeMonthly: fv<number>(null, null, page.url),
            schoolDistrict: fv<string>(null, null, page.url),
            ageRestricted: fv<boolean>(null, null, page.url),
          },
        },
        {
          entityType: "inventory_home",
          canonicalHints: {
            builderSlug: BUILDER_SLUG,
            communityName: parts.community,
            address: parts.address,
            planName: parts.plan,
          },
          fields: {
            street: fv(parts.address, parts.address, page.url, "address segment of spec URL"),
            city: fv(parts.city, parts.city, page.url),
            state: fv(parts.state, parts.state, page.url),
            zip: fv<string>(null, null, page.url),
            price: fv(own.price, own.priceRaw, page.url, own.priceRaw ? `ld+json Offer price ${own.priceRaw}` : null),
            beds: fv(own.beds, own.beds === null ? null : String(own.beds), page.url, own.beds === null ? null : "ld+json numberOfBedrooms"),
            bathsTotal: fv(own.baths, own.baths === null ? null : String(own.baths), page.url, own.baths === null ? null : "ld+json numberOfBathrooms"),
            sqft: fv(own.sqft, own.sqft === null ? null : String(own.sqft), page.url, own.sqft === null ? null : "ld+json floorSize"),
            stories: fv<number>(null, null, page.url),
            garageSpaces: fv<number>(null, null, page.url),
            homeType: fv("SINGLE_FAMILY" as const, null, page.url, "Dream Finders spec home listing"),
            constructionStatus: fv<"PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY">(null, null, page.url),
            estCompletionDate: fv<string>(null, null, page.url),
            lotNumber: fv<string>(null, null, page.url),
            builderInventoryId: fv<string>(null, null, page.url),
            lat: fv(own.lat, own.lat === null ? null : String(own.lat), page.url, own.lat === null ? null : "ld+json GeoCoordinates"),
            lon: fv(own.lon, own.lon === null ? null : String(own.lon), page.url, own.lon === null ? null : "ld+json GeoCoordinates"),
            planName: fv(parts.plan, parts.plan, page.url),
          },
        },
      ];
      return { records, errors: [] };
    } catch (error) {
      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
    }
  },
};