← back to Homesonspec

collectors/tri-pointe/src/index.ts

177 lines

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

/**
 * Tri Pointe adapter — Next.js RSC source (recon 2026-07-23).
 * Community pages embed inventory in the streamed RSC (`self.__next_f`) as
 * escaped JSON. We unescape, string-aware brace-match every object carrying
 * "display_price", and read price/beds/baths/sqft/address/status/lat/lon.
 * Per-community phone via JSON-LD telephone. Plain HTTP, no anti-bot.
 */
const SITEMAP = "https://www.tripointehomes.com/sitemap-communities.xml";
const BUILDER_SLUG = "tri-pointe";
const STATE_SEG = (process.env.TRIPOINTE_STATE ?? "ca").toLowerCase(); // URL segment /ca/
const PAGE_LIMIT = Number(process.env.TRIPOINTE_PAGE_LIMIT ?? 200);

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 geo = (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 str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null);

/** String-aware brace match: the enclosing {...} object around index i. */
function enclosingObject(s: string, i: number): Record<string, unknown> | null {
  let start = -1, depth = 0;
  for (let j = i; j >= 0; j--) {
    const c = s[j]!;
    if (c === "}") depth++;
    else if (c === "{") { if (depth === 0) { start = j; break; } depth--; }
  }
  if (start < 0) return null;
  depth = 0; let inStr = false, esc = false;
  for (let j = start; j < s.length; j++) {
    const c = s[j]!;
    if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; }
    else if (c === '"') inStr = true;
    else if (c === "{") depth++;
    else if (c === "}") { depth--; if (depth === 0) { try { return JSON.parse(s.slice(start, j + 1)); } catch { return null; } } }
  }
  return null;
}

function homeTypeOf(planType: string | null): "SINGLE_FAMILY" | "TOWNHOME" | "CONDO" {
  const s = (planType ?? "").toLowerCase();
  if (/condo/.test(s)) return "CONDO";
  if (/town/.test(s)) return "TOWNHOME";
  return "SINGLE_FAMILY";
}
function statusOf(h: Record<string, unknown>): "MOVE_IN_READY" | "UNDER_CONSTRUCTION" | "PLANNED" {
  const s = `${str(h.home_status) ?? ""} ${str(h.availability_status) ?? ""} ${str(h.tpg_status) ?? ""}`.toLowerCase();
  if (/move.?in ready|ready_to_move_in|ready now|complete/.test(s)) return "MOVE_IN_READY";
  if (/coming soon|planned|future/.test(s)) return "PLANNED";
  return "UNDER_CONSTRUCTION";
}

export const triPointeAdapter: SourceAdapter = {
  key: "tri-pointe-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 sm = await fetcher.fetch(SITEMAP);
    const urls = [...sm.body.toString("utf8").matchAll(/<loc>([^<]+)<\/loc>/g)]
      .map((m) => m[1]!)
      .filter((u) => u.toLowerCase().includes(`/${STATE_SEG}/`));
    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 html = page.body.toString("utf8");
      const u = html.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
      const seen = new Set<string>();
      const homes: Record<string, unknown>[] = [];
      const re = /"display_price"\s*:\s*\d+/g;
      let m: RegExpExecArray | null;
      while ((m = re.exec(u))) {
        const obj = enclosingObject(u, m.index);
        const address = obj ? str(obj.address) : null;
        if (obj && address && !seen.has(address)) { seen.add(address); homes.push(obj); }
      }
      if (homes.length === 0) return { records: [], errors: [] }; // community with no listed homes

      const first = homes[0]!;
      const communityName = str(first.community) ?? str(first.neighborhood);
      if (!communityName) return { records: [], errors: [{ url: page.url, reason: "no community name" }] };
      const phoneRaw = html.match(/"telephone":\s*"([^"]+)"/)?.[1] ?? html.match(/"phone":\s*"([\d.\-() ]{7,})"/)?.[1] ?? null;
      const phone = phoneRaw ? phoneRaw.replace(/[^0-9+]/g, "") : null;
      const city0 = (v: unknown) => (Array.isArray(v) ? str(v[0]) : str(v));

      const records: ExtractedRecord[] = [];
      records.push({
        entityType: "community",
        canonicalHints: { builderSlug: BUILDER_SLUG, communityName },
        fields: {
          name: fv(communityName, communityName, page.url),
          street: fv<string>(null, null, page.url),
          city: fv(city0(first.cities), null, page.url),
          state: fv(str(first.state_alpha_2_code)?.toUpperCase() ?? null, str(first.state_alpha_2_code), page.url), // real state, not hardcoded CA
          zip: fv(str(first.zip_code), str(first.zip_code), page.url),
          county: fv(str(first.submarket), str(first.submarket), page.url),
          metro: fv<string>(null, null, page.url),
          lat: fv(geo(first.latitude), null, page.url),
          lon: fv(geo(first.longitude), null, 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.length >= 10 ? phone : null, phoneRaw, page.url, "community sales phone"),
        },
      });

      for (const h of homes) {
        const address = str(h.address);
        if (!address) continue;
        const lat = geo(h.latitude), lon = geo(h.longitude);
        // per-home photo: h.image is {raw,thumbnail,…}; raw is the full Cloudinary URL
        // (already absolute). [images enabled 2026-07-28 per Steve's hotlink approval]
        const imgObj = h.image as Record<string, unknown> | undefined;
        const imgUrl = str(imgObj?.raw) ?? str(imgObj?.thumbnail);
        const images = imgUrl && imgUrl.startsWith("http") ? [imgUrl] : [];
        records.push({
          entityType: "inventory_home",
          canonicalHints: {
            builderSlug: BUILDER_SLUG, communityName, address,
            builderInventoryId: str(h.homesite),
            lat: lat ?? undefined, lon: lon ?? undefined,
            planName: str(h.floor_plan),
          },
          fields: {
            street: fv(address, address, page.url),
            city: fv(city0(h.cities), null, page.url),
            state: fv(str(h.state_alpha_2_code)?.toUpperCase() ?? null, str(h.state_alpha_2_code), page.url), // real state, not hardcoded CA
            zip: fv(str(h.zip_code), str(h.zip_code), page.url),
            price: fv(num(h.display_price) ?? num(h.min_price), null, page.url, h.display_price ? `display_price ${String(h.display_price)}` : null),
            beds: fv(num(h.min_bedrooms), null, page.url),
            bathsTotal: fv(num(h.min_bathrooms), null, page.url),
            sqft: fv(num(h.min_sq_feet), null, page.url),
            stories: fv(num(h.min_stories), null, page.url),
            garageSpaces: fv(num(h.min_garage), null, page.url),
            homeType: fv(homeTypeOf(str(h.plan_type)) as never, str(h.plan_type), page.url, "Tri Pointe inventory home"),
            constructionStatus: fv(statusOf(h), str(h.home_status), page.url),
            estCompletionDate: fv(str(h.move_in_date), str(h.move_in_date), page.url),
            lotNumber: fv(str(h.homesite), str(h.homesite), page.url),
            builderInventoryId: fv(str(h.homesite), str(h.homesite), page.url),
            planName: fv(str(h.floor_plan), str(h.floor_plan), page.url),
            availabilityStatus: fv(str(h.availability_status), str(h.availability_status), page.url),
            lat: fv(lat, null, page.url),
            lon: fv(lon, null, page.url),
            images: fv<string[]>(images, null, page.url, images.length ? "builder listing photo" : null),
          },
        });
      }
      return { records, errors: [] };
    } catch (error) {
      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
    }
  },
};