← 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
M apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/kb-home/package.jsonA collectors/kb-home/src/index.tsA collectors/kb-home/tsconfig.jsonM pnpm-lock.yaml
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 →