[object Object]

← 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

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 →