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