[object Object]

← back to Homesonspec

Add KB Home adapter (3rd builder) — single-feed (var allMIRs double-decode), CA-scoped, verified 58 communities + 143 live CA homes from one fetch; registered in pipeline

65775ba2520948cb637a70100c135cd08e17783f · 2026-07-22 23:36:15 -0700 · Steve

Files touched

Diff

commit 65775ba2520948cb637a70100c135cd08e17783f
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 23:36:15 2026 -0700

    Add KB Home adapter (3rd builder) — single-feed (var allMIRs double-decode), CA-scoped, verified 58 communities + 143 live CA homes from one fetch; registered in pipeline
---
 apps/workers/package.json        |   3 +-
 apps/workers/src/cli.ts          |   2 +
 collectors/kb-home/package.json  |  15 ++++
 collectors/kb-home/src/index.ts  | 157 +++++++++++++++++++++++++++++++++++++++
 collectors/kb-home/tsconfig.json |   1 +
 pnpm-lock.yaml                   |  25 +++++++
 6 files changed, 202 insertions(+), 1 deletion(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index 0d5d5c4..eb05aaa 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -22,7 +22,8 @@
     "@spechomes/publisher": "workspace:*",
     "@spechomes/collector-toll-brothers": "workspace:*",
     "@spechomes/collector-lennar": "workspace:*",
-    "@spechomes/collector-dr-horton": "workspace:*"
+    "@spechomes/collector-dr-horton": "workspace:*",
+    "@spechomes/collector-kb-home": "workspace:*"
   },
   "devDependencies": {
     "tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index 1537e87..b3ce9a6 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -3,6 +3,7 @@ import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
 import { tollBrothersAdapter } from "@spechomes/collector-toll-brothers";
 import { lennarAdapter } from "@spechomes/collector-lennar";
 import { drHortonAdapter } from "@spechomes/collector-dr-horton";
+import { kbHomeAdapter } from "@spechomes/collector-kb-home";
 import { runPipeline } from "./pipeline";
 import { recordSourceRun } from "./verify";
 
@@ -15,6 +16,7 @@ const ADAPTERS = {
   [tollBrothersAdapter.key]: tollBrothersAdapter,
   [lennarAdapter.key]: lennarAdapter,
   [drHortonAdapter.key]: drHortonAdapter,
+  [kbHomeAdapter.key]: kbHomeAdapter,
 };
 
 async function main() {
diff --git a/collectors/kb-home/package.json b/collectors/kb-home/package.json
new file mode 100644
index 0000000..1a18246
--- /dev/null
+++ b/collectors/kb-home/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@spechomes/collector-kb-home",
+  "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/kb-home/src/index.ts b/collectors/kb-home/src/index.ts
new file mode 100644
index 0000000..77c9dc7
--- /dev/null
+++ b/collectors/kb-home/src/index.ts
@@ -0,0 +1,157 @@
+import type { ExtractedRecord, FieldValue } from "@spechomes/schemas";
+import {
+  fetchFixtures,
+  LiveFetcher,
+  type ExtractionOutput,
+  type FetchContext,
+  type RawPage,
+  type SourceAdapter,
+} from "@spechomes/collectors-common";
+
+/**
+ * KB Home adapter — SINGLE_FEED source (recon 2026-07-23).
+ * AEM/Handlebars, server-rendered. The whole national move-in-ready inventory
+ * ships in ONE page (/move-in-ready) as `var allMIRs = JSON.parse("...")` — a
+ * double-encoded JSON string. One fetch yields every home; we filter to the
+ * target state (KBHOME_STATE, default CA). Plain HTTP, no anti-bot.
+ */
+const FEED_URL = "https://www.kbhome.com/move-in-ready";
+const ORIGIN = "https://www.kbhome.com";
+const BUILDER_SLUG = "kb-home";
+const STATE = (process.env.KBHOME_STATE ?? "CA").toUpperCase();
+
+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 str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null);
+
+/** Grab the double-encoded JSON string argument of `var allMIRs = JSON.parse("…")`. */
+function extractQuotedArg(html: string, marker: string): string | null {
+  const at = html.indexOf(marker);
+  if (at < 0) return null;
+  const start = html.indexOf('"', at);
+  if (start < 0) return null;
+  let esc = false;
+  for (let i = start + 1; i < html.length; i++) {
+    const c = html[i]!;
+    if (esc) esc = false;
+    else if (c === "\\") esc = true;
+    else if (c === '"') return html.slice(start, i + 1);
+  }
+  return null;
+}
+
+function statusOf(moveInCopy: string | null, moveInDate: string | null): "MOVE_IN_READY" | "UNDER_CONSTRUCTION" | "PLANNED" {
+  const s = (moveInCopy ?? "").toLowerCase();
+  if (/available now|move.?in ready|ready now|complete/.test(s)) return "MOVE_IN_READY";
+  if (/coming soon|planned/.test(s)) return "PLANNED";
+  if (moveInDate && new Date(moveInDate) <= new Date()) return "MOVE_IN_READY";
+  return "UNDER_CONSTRUCTION";
+}
+
+export const kbHomeAdapter: SourceAdapter = {
+  key: "kb-home-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);
+    yield await fetcher.fetch(FEED_URL); // one 5.8MB page carries all inventory
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    const errors: { url: string; reason: string }[] = [];
+    try {
+      const html = page.body.toString("utf8");
+      const quoted = extractQuotedArg(html, "var allMIRs = JSON.parse(");
+      let all: Record<string, unknown>[] = [];
+      if (quoted) {
+        try { all = JSON.parse(JSON.parse(quoted)); } catch { /* fall through */ }
+      }
+      if (!Array.isArray(all) || all.length === 0) {
+        return { records: [], errors: [{ url: page.url, reason: "allMIRs not found/empty" }] };
+      }
+      const rows = all.filter((r) => str(r.State)?.toUpperCase() === STATE);
+
+      const records: ExtractedRecord[] = [];
+      // Emit each unique community first (publish needs the FK target before homes).
+      const seen = new Set<string>();
+      for (const r of rows) {
+        const communityName = str(r.CommunityName);
+        if (!communityName || seen.has(communityName)) continue;
+        seen.add(communityName);
+        const phone = str(r.CommunityOfficePhone);
+        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(str(r.City), str(r.City), page.url),
+            state: fv(STATE, str(r.State), page.url),
+            zip: fv(str(r.ZipCode), str(r.ZipCode), page.url),
+            county: fv<string>(null, null, page.url),
+            metro: fv<string>(null, null, page.url),
+            lat: fv(num(r.Latitude), null, page.url),
+            lon: fv(num(r.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.replace(/[^0-9+]/g, "") : null, phone, page.url, "community sales phone"),
+          },
+        });
+      }
+
+      for (const r of rows) {
+        const address = str(r.Address);
+        const communityName = str(r.CommunityName);
+        if (!address || !communityName) continue;
+        const thumb = str(r.ThumbnailImage) ?? str(r.GalleryPhotos)?.split(",")[0]?.trim() ?? null;
+        const image = thumb ? (thumb.startsWith("http") ? thumb : ORIGIN + thumb) : null;
+        const lot = str(r.Homesite) ?? str(r.MLSNumber);
+        const homeType = /town|condo|attached/i.test(str(r.HomeType) ?? "") ? "TOWNHOUSE" : "SINGLE_FAMILY";
+        records.push({
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName,
+            address,
+            builderInventoryId: lot,
+            planName: str(r.FloorPlanName),
+          },
+          fields: {
+            street: fv(address, address, page.url),
+            city: fv(str(r.City), str(r.City), page.url),
+            state: fv(STATE, str(r.State), page.url),
+            zip: fv(str(r.ZipCode), str(r.ZipCode), page.url),
+            price: fv(num(r.Price), str(r.Price), page.url, r.Price ? `price ${String(r.Price)}` : null),
+            beds: fv(num(r.Bedrooms), str(r.Bedrooms), page.url),
+            bathsTotal: fv(num(r.Bathrooms), str(r.Bathrooms), page.url),
+            sqft: fv(num(r.Size), str(r.Size), page.url),
+            stories: fv(num(r.Stories), null, page.url),
+            garageSpaces: fv(num(r.Garages), null, page.url),
+            homeType: fv(homeType as never, str(r.HomeType), page.url, "KB Home inventory home"),
+            constructionStatus: fv(statusOf(str(r.MoveInDateCopy), str(r.MoveInDate)), str(r.MoveInDateCopy), page.url),
+            estCompletionDate: fv(str(r.MoveInDate), str(r.MoveInDate), page.url),
+            lotNumber: fv(str(r.Homesite), str(r.Homesite), page.url),
+            builderInventoryId: fv(lot, lot, page.url),
+            planName: fv(str(r.FloorPlanName), str(r.FloorPlanName), page.url),
+            availabilityStatus: fv(str(r.MoveInDateCopy), str(r.MoveInDateCopy), page.url),
+            images: fv<string[]>(image ? [image] : [], null, page.url, image ? "builder listing photo" : null),
+          },
+        });
+      }
+
+      return { records, errors };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/kb-home/tsconfig.json b/collectors/kb-home/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/collectors/kb-home/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 2ac6061..bd8b2e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -145,6 +145,9 @@ importers:
       '@spechomes/collector-dr-horton':
         specifier: workspace:*
         version: link:../../collectors/dr-horton
+      '@spechomes/collector-kb-home':
+        specifier: workspace:*
+        version: link:../../collectors/kb-home
       '@spechomes/collector-lennar':
         specifier: workspace:*
         version: link:../../collectors/lennar
@@ -255,6 +258,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/kb-home:
+    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))
+
   collectors/lennar:
     dependencies:
       '@spechomes/collectors-common':

← d27cab5 Add D.R. Horton adapter (2nd builder) — Sitecore var-model b  ·  back to Homesonspec  ·  CA builders: recon spec doc (collectors/BUILDERS.md) — turnk ca4ba1d →