[object Object]

← 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

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 →