← back to Homesonspec
collectors: add K. Hovnanian adapter (khov.com RSC, 30th builder)
04b557041a428d3600c0bf55191c3b8292163a6a · 2026-07-29 21:06:28 -0700 · Steve Abrams
khov.com serves per-community/plan pages at /new-construction-homes/{state}/{city}/…
that server-render QMI inventory into the Next.js App-Router RSC stream. Tri-Pointe-style
brace-match on objects carrying a real homeSite+street address; reads price
(totalPrice.value), a specifications array (sqft/stories/beds/baths/cars), availability
tags → status+est-date, and an absolute CloudFront image. State-scoped via KHOVNANIAN_STATE.
Verified standalone on live DE pages: homes + price + image all populate, image loads 200,
177 deep plan pages in DE sitemap alone. Not yet registered/ingested (coordinating shared-file
registration with the concurrent session per DM claim M-00067).
Files touched
A collectors/khovnanian/package.jsonA collectors/khovnanian/src/index.tsA collectors/khovnanian/tsconfig.json
Diff
commit 04b557041a428d3600c0bf55191c3b8292163a6a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 29 21:06:28 2026 -0700
collectors: add K. Hovnanian adapter (khov.com RSC, 30th builder)
khov.com serves per-community/plan pages at /new-construction-homes/{state}/{city}/…
that server-render QMI inventory into the Next.js App-Router RSC stream. Tri-Pointe-style
brace-match on objects carrying a real homeSite+street address; reads price
(totalPrice.value), a specifications array (sqft/stories/beds/baths/cars), availability
tags → status+est-date, and an absolute CloudFront image. State-scoped via KHOVNANIAN_STATE.
Verified standalone on live DE pages: homes + price + image all populate, image loads 200,
177 deep plan pages in DE sitemap alone. Not yet registered/ingested (coordinating shared-file
registration with the concurrent session per DM claim M-00067).
---
collectors/khovnanian/package.json | 15 +++
collectors/khovnanian/src/index.ts | 222 ++++++++++++++++++++++++++++++++++++
collectors/khovnanian/tsconfig.json | 1 +
3 files changed, 238 insertions(+)
diff --git a/collectors/khovnanian/package.json b/collectors/khovnanian/package.json
new file mode 100644
index 00000000..8e705462
--- /dev/null
+++ b/collectors/khovnanian/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@homesonspec/collector-khovnanian",
+ "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": {
+ "@homesonspec/collectors-common": "workspace:*",
+ "@homesonspec/schemas": "workspace:*",
+ "@homesonspec/shared": "workspace:*"
+ },
+ "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/collectors/khovnanian/src/index.ts b/collectors/khovnanian/src/index.ts
new file mode 100644
index 00000000..977b46ab
--- /dev/null
+++ b/collectors/khovnanian/src/index.ts
@@ -0,0 +1,222 @@
+import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
+import {
+ fetchFixtures,
+ LiveFetcher,
+ type ExtractionOutput,
+ type FetchContext,
+ type RawPage,
+ type SourceAdapter,
+} from "@homesonspec/collectors-common";
+
+/**
+ * K. Hovnanian (khov.com) adapter — Next.js App-Router RSC source (recon 2026-07-29).
+ *
+ * khov.com serves per-community pages at /new-construction-homes/{state}/{city}/{community}/…
+ * that server-render their quick-move-in inventory into the streamed RSC (`self.__next_f`)
+ * as escaped JSON. We unescape, string-aware brace-match every object carrying a real
+ * `homeSite` + street `address`, and read price + a `specifications` array
+ * (["2,856 Sq ft","3 Stories","4 Beds","3.5 Baths","2 Cars"]) + absolute CloudFront image.
+ * Plain HTTP, no anti-bot, robots-permitted. State-scoped via KHOVNANIAN_STATE (full slug,
+ * e.g. "delaware", "california"). Facts-only; images enabled per Steve's hotlink approval.
+ */
+const SITEMAP = "https://www.khov.com/sitemap.xml";
+const BUILDER_SLUG = "khovnanian";
+const STATE_SEG = (process.env.KHOVNANIAN_STATE ?? "").toLowerCase().trim(); // URL slug, e.g. "delaware"
+const PAGE_LIMIT = Number(process.env.KHOVNANIAN_PAGE_LIMIT ?? 200);
+
+// Full state slug → 2-letter code (URL carries the slug; homes carry a 2-letter `state`,
+// but this is the fallback and lets a home inherit the sweep's state).
+const STATE_CODE: Record<string, string> = {
+ alabama: "AL", arizona: "AZ", california: "CA", colorado: "CO", delaware: "DE",
+ florida: "FL", georgia: "GA", illinois: "IL", maryland: "MD", "new-jersey": "NJ",
+ "north-carolina": "NC", ohio: "OH", pennsylvania: "PA", "south-carolina": "SC",
+ texas: "TX", virginia: "VA", "west-virginia": "WV", minnesota: "MN",
+};
+
+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);
+const titleCase = (slug: string): string =>
+ slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
+
+/** khov's totalPrice is {label,value:"$$704,990",lastValue:"$$724,040"} (the $$ is an RSC
+ * artifact); also accepts a bare number/string. Returns the current total price. */
+function priceOf(v: unknown): number | null {
+ if (typeof v === "number" || typeof v === "string") return num(v);
+ if (v && typeof v === "object") { const o = v as Record<string, unknown>; return num(o.value) ?? num(o.textContent); }
+ return null;
+}
+
+/** Pull beds/baths/sqft/stories/garages out of the specifications string array. */
+function specs(arr: unknown): { beds: number | null; baths: number | null; sqft: number | null; stories: number | null; garages: number | null } {
+ const out = { beds: null as number | null, baths: null as number | null, sqft: null as number | null, stories: null as number | null, garages: null as number | null };
+ if (!Array.isArray(arr)) return out;
+ for (const raw of arr) {
+ const s = str(raw);
+ if (!s) continue;
+ if (/sq\s*\.?\s*ft/i.test(s)) out.sqft = num(s);
+ else if (/stor/i.test(s)) out.stories = num(s);
+ else if (/bed/i.test(s)) out.beds = num(s);
+ else if (/bath/i.test(s)) out.baths = num(s);
+ else if (/car|garage/i.test(s)) out.garages = num(s);
+ }
+ return out;
+}
+
+const MONTHS: Record<string, string> = {
+ january: "01", february: "02", march: "03", april: "04", may: "05", june: "06",
+ july: "07", august: "08", september: "09", october: "10", november: "11", december: "12",
+};
+/** tags like "Available September 2026" or "Available Now" / "Move-in Ready". */
+function statusOf(tags: unknown): { status: "MOVE_IN_READY" | "UNDER_CONSTRUCTION" | "PLANNED"; estDate: string | null } {
+ const t = (Array.isArray(tags) ? tags.map((x) => str(x) ?? "").join(" ") : "").toLowerCase();
+ if (/available now|move.?in|ready to move|quick move/.test(t)) return { status: "MOVE_IN_READY", estDate: null };
+ const m = t.match(/available\s+([a-z]+)\s+(\d{4})/);
+ if (m && MONTHS[m[1]!]) return { status: "UNDER_CONSTRUCTION", estDate: `${m[2]}-${MONTHS[m[1]!]}-01` };
+ if (/coming soon|planned|future/.test(t)) return { status: "PLANNED", estDate: null };
+ return { status: "UNDER_CONSTRUCTION", estDate: null };
+}
+
+function homeTypeOf(name: string | null): "SINGLE_FAMILY" | "TOWNHOME" | "CONDO" {
+ const s = (name ?? "").toLowerCase();
+ if (/condo/.test(s)) return "CONDO";
+ if (/town/.test(s)) return "TOWNHOME";
+ return "SINGLE_FAMILY";
+}
+
+/** 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;
+}
+
+export const khovnanianAdapter: SourceAdapter = {
+ key: "khovnanian-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);
+ // community pages: /new-construction-homes/{state}/{city}/{community}/… ; drop blog + non-state.
+ const urls = [...sm.body.toString("utf8").matchAll(/<loc>([^<]+)<\/loc>/g)]
+ .map((m) => m[1]!)
+ .filter((u) => /\/new-construction-homes\//i.test(u) && !/\/blog\//i.test(u))
+ .filter((u) => !STATE_SEG || u.toLowerCase().includes(`/new-construction-homes/${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, "\\");
+ // community slug from the page URL: /new-construction-homes/{state}/{city}/{community}/…
+ const segs = page.url.split("?")[0]!.split("/").filter(Boolean);
+ const nci = segs.indexOf("new-construction-homes");
+ const communityName = nci >= 0 && segs[nci + 3] ? titleCase(segs[nci + 3]!) : null;
+
+ // QMI homes carry a real `homeSite`; brace-match each enclosing object, dedup by address.
+ const seen = new Set<string>();
+ const homes: Record<string, unknown>[] = [];
+ const re = /"homeSite"\s*:\s*"[^"]+"/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(u))) {
+ const obj = enclosingObject(u, m.index);
+ const address = obj ? str(obj.address) : null;
+ // a real QMI home has a street address starting with a number
+ if (obj && address && /^\d/.test(address) && !seen.has(address)) { seen.add(address); homes.push(obj); }
+ }
+ if (homes.length === 0) return { records: [], errors: [] };
+
+ const first = homes[0]!;
+ const cname = communityName ?? str(first.communityName) ?? "Unknown";
+ const records: ExtractedRecord[] = [];
+ records.push({
+ entityType: "community",
+ canonicalHints: { builderSlug: BUILDER_SLUG, communityName: cname },
+ fields: {
+ name: fv(cname, cname, page.url),
+ street: fv<string>(null, null, page.url),
+ city: fv(str(first.city), str(first.city), page.url),
+ state: fv(str(first.state)?.toUpperCase() ?? STATE_CODE[STATE_SEG] ?? null, str(first.state), page.url),
+ zip: fv(str(first.zipCode), str(first.zipCode), page.url),
+ county: fv<string>(null, null, page.url),
+ metro: fv<string>(null, null, page.url),
+ lat: fv<number>(null, null, page.url),
+ lon: fv<number>(null, 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<string>(null, null, page.url),
+ },
+ });
+
+ for (const h of homes) {
+ const address = str(h.address);
+ if (!address) continue;
+ const sp = specs(h.specifications);
+ const st = statusOf(h.tags);
+ const img = str((h.image as Record<string, unknown> | undefined)?.src);
+ const images = img && img.startsWith("http") ? [img] : [];
+ const invId = str(h.id) ?? str(h.homeSite);
+ const planName = str((h.name as Record<string, unknown> | undefined)?.textContent);
+ records.push({
+ entityType: "inventory_home",
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName: cname,
+ address,
+ builderInventoryId: invId,
+ planName,
+ },
+ fields: {
+ street: fv(address, address, page.url),
+ city: fv(str(h.city), str(h.city), page.url),
+ state: fv(str(h.state)?.toUpperCase() ?? STATE_CODE[STATE_SEG] ?? null, str(h.state), page.url),
+ zip: fv(str(h.zipCode), str(h.zipCode), page.url),
+ price: fv(priceOf(h.totalPrice), null, page.url, h.totalPrice ? `totalPrice ${JSON.stringify(h.totalPrice).slice(0, 30)}` : null),
+ beds: fv(sp.beds, null, page.url),
+ bathsTotal: fv(sp.baths, null, page.url),
+ sqft: fv(sp.sqft, null, page.url),
+ stories: fv(sp.stories, null, page.url),
+ garageSpaces: fv(sp.garages, null, page.url),
+ homeType: fv(homeTypeOf(planName) as never, planName, page.url, "K. Hovnanian inventory home"),
+ constructionStatus: fv(st.status, str((h.tags as unknown[])?.[0]) ?? null, page.url),
+ estCompletionDate: fv(st.estDate, null, page.url),
+ lotNumber: fv(str(h.homeSite), str(h.homeSite), page.url),
+ builderInventoryId: fv(invId, invId, page.url),
+ planName: fv(planName, planName, page.url),
+ availabilityStatus: fv(str((h.tags as unknown[])?.[0]) ?? null, 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) }] };
+ }
+ },
+};
diff --git a/collectors/khovnanian/tsconfig.json b/collectors/khovnanian/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/khovnanian/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
← 9c5e6719 lennar refresh runner: source DB pw from gitignored .env (wa
·
back to Homesonspec
·
auto-save: 2026-07-29T21:12:19 (1 files) — apps/workers/pack b61d7073 →