← back to Homesonspec
Add Tri Pointe adapter (5th builder) — Next.js RSC (unescape + brace-match display_price objects), CA-scoped, WITH lat/lon; verified 3 live Corona homes + community geo/phone; registered
f66f9ae4a7792e159509d3f62dfaf7667d5161d0 · 2026-07-23 00:52:04 -0700 · Steve
Files touched
M apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/tri-pointe/package.jsonA collectors/tri-pointe/src/index.tsA collectors/tri-pointe/tsconfig.jsonM pnpm-lock.yaml
Diff
commit f66f9ae4a7792e159509d3f62dfaf7667d5161d0
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 00:52:04 2026 -0700
Add Tri Pointe adapter (5th builder) — Next.js RSC (unescape + brace-match display_price objects), CA-scoped, WITH lat/lon; verified 3 live Corona homes + community geo/phone; registered
---
apps/workers/package.json | 3 +-
apps/workers/src/cli.ts | 2 +
collectors/tri-pointe/package.json | 15 ++++
collectors/tri-pointe/src/index.ts | 170 ++++++++++++++++++++++++++++++++++++
collectors/tri-pointe/tsconfig.json | 1 +
pnpm-lock.yaml | 25 ++++++
6 files changed, 215 insertions(+), 1 deletion(-)
diff --git a/apps/workers/package.json b/apps/workers/package.json
index a36e1bb..7bef88b 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -24,7 +24,8 @@
"@spechomes/collector-lennar": "workspace:*",
"@spechomes/collector-dr-horton": "workspace:*",
"@spechomes/collector-kb-home": "workspace:*",
- "@spechomes/collector-pulte": "workspace:*"
+ "@spechomes/collector-pulte": "workspace:*",
+ "@spechomes/collector-tri-pointe": "workspace:*"
},
"devDependencies": {
"tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index c279c65..9def79e 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -5,6 +5,7 @@ import { lennarAdapter } from "@spechomes/collector-lennar";
import { drHortonAdapter } from "@spechomes/collector-dr-horton";
import { kbHomeAdapter } from "@spechomes/collector-kb-home";
import { pulteAdapter } from "@spechomes/collector-pulte";
+import { triPointeAdapter } from "@spechomes/collector-tri-pointe";
import { runPipeline } from "./pipeline";
import { recordSourceRun } from "./verify";
@@ -19,6 +20,7 @@ const ADAPTERS = {
[drHortonAdapter.key]: drHortonAdapter,
[kbHomeAdapter.key]: kbHomeAdapter,
[pulteAdapter.key]: pulteAdapter,
+ [triPointeAdapter.key]: triPointeAdapter,
};
async function main() {
diff --git a/collectors/tri-pointe/package.json b/collectors/tri-pointe/package.json
new file mode 100644
index 0000000..e30fe5f
--- /dev/null
+++ b/collectors/tri-pointe/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@spechomes/collector-tri-pointe",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": { "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" },
+ "dependencies": {
+ "@spechomes/collectors-common": "workspace:*",
+ "@spechomes/schemas": "workspace:*",
+ "@spechomes/shared": "workspace:*"
+ },
+ "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/collectors/tri-pointe/src/index.ts b/collectors/tri-pointe/src/index.ts
new file mode 100644
index 0000000..102649f
--- /dev/null
+++ b/collectors/tri-pointe/src/index.ts
@@ -0,0 +1,170 @@
+import type { ExtractedRecord, FieldValue } from "@spechomes/schemas";
+import {
+ fetchFixtures,
+ LiveFetcher,
+ type ExtractionOutput,
+ type FetchContext,
+ type RawPage,
+ type SourceAdapter,
+} from "@spechomes/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("CA" as const, str(first.state_alpha_2_code), page.url),
+ 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);
+ 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("CA" as const, str(h.state_alpha_2_code), page.url),
+ 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),
+ },
+ });
+ }
+ return { records, errors: [] };
+ } catch (error) {
+ return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+ }
+ },
+};
diff --git a/collectors/tri-pointe/tsconfig.json b/collectors/tri-pointe/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/collectors/tri-pointe/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d37a2d5..4937ca1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -160,6 +160,9 @@ importers:
'@spechomes/collector-toll-brothers':
specifier: workspace:*
version: link:../../collectors/toll-brothers
+ '@spechomes/collector-tri-pointe':
+ specifier: workspace:*
+ version: link:../../collectors/tri-pointe
'@spechomes/collectors-common':
specifier: workspace:*
version: link:../../collectors/common
@@ -374,6 +377,28 @@ importers:
specifier: ^4.0.0
version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+ collectors/tri-pointe:
+ dependencies:
+ '@spechomes/collectors-common':
+ specifier: workspace:*
+ version: link:../common
+ '@spechomes/schemas':
+ specifier: workspace:*
+ version: link:../../packages/schemas
+ '@spechomes/shared':
+ specifier: workspace:*
+ version: link:../../packages/shared
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.5
+ version: 22.20.1
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.0
+ version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+
packages/database:
dependencies:
'@prisma/client':
← 8456a67 Fix new-builder ingest bugs caught on real data: lat/lon opt
·
back to Homesonspec
·
fix(live-fetch): broaden Accept header so D.R. Horton WAF st aa61dfd →