[object Object]

← back to Homesonspec

oakwood-homes: facts-only inventory adapter (Nuxt SSR + KOVA ERP payload)

e10bc5b76aa0bd45aade17a485ed4571afb0c790 · 2026-08-12 09:01:38 -0700 · Steve

Clayton Properties Group brand (CO/UT/AZ/NC site-built spec homes).
Plain-fetch: available-homes-sitemaps.xml (~535 URLs) -> per-home /available-homes/{slug}
pages, each embedding a Nuxt3 devalue state payload with a KOVA sales-order object.
Extracts address/geo/price/beds/baths(full+half)/sqft/garage/plan/community/lot/
est-completion/builderInventoryId. Images omitted, mediaRights=NONE. robots.txt open.
Strided self-test across full sitemap: 12 resolved homes (AZ/CO/UT), 100% coverage on
price/beds/baths/sqft/address/geo/invId/completion/lot, 92% plan. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e10bc5b76aa0bd45aade17a485ed4571afb0c790
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 09:01:38 2026 -0700

    oakwood-homes: facts-only inventory adapter (Nuxt SSR + KOVA ERP payload)
    
    Clayton Properties Group brand (CO/UT/AZ/NC site-built spec homes).
    Plain-fetch: available-homes-sitemaps.xml (~535 URLs) -> per-home /available-homes/{slug}
    pages, each embedding a Nuxt3 devalue state payload with a KOVA sales-order object.
    Extracts address/geo/price/beds/baths(full+half)/sqft/garage/plan/community/lot/
    est-completion/builderInventoryId. Images omitted, mediaRights=NONE. robots.txt open.
    Strided self-test across full sitemap: 12 resolved homes (AZ/CO/UT), 100% coverage on
    price/beds/baths/sqft/address/geo/invId/completion/lot, 92% plan. tsc --noEmit clean.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 collectors/oakwood-homes/package.json  |  15 ++
 collectors/oakwood-homes/src/index.ts  | 366 +++++++++++++++++++++++++++++++++
 collectors/oakwood-homes/tsconfig.json |   1 +
 3 files changed, 382 insertions(+)

diff --git a/collectors/oakwood-homes/package.json b/collectors/oakwood-homes/package.json
new file mode 100644
index 00000000..50443969
--- /dev/null
+++ b/collectors/oakwood-homes/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-oakwood-homes",
+  "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/oakwood-homes/src/index.ts b/collectors/oakwood-homes/src/index.ts
new file mode 100644
index 00000000..81098fb9
--- /dev/null
+++ b/collectors/oakwood-homes/src/index.ts
@@ -0,0 +1,366 @@
+import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
+import { normalizeStateCode } from "@homesonspec/shared";
+import {
+  fetchFixtures,
+  LiveFetcher,
+  type ExtractionOutput,
+  type FetchContext,
+  type RawPage,
+  type SourceAdapter,
+} from "@homesonspec/collectors-common";
+
+/**
+ * Oakwood Homes adapter — CO/UT/AZ/NC site-built spec-home builder, a Clayton
+ * Properties Group brand (slug "oakwood-homes"; recon + built 2026-08-12,
+ * TK-10487). oakwoodhomesco.com is a Nuxt 3 SSR site (WordPress CMS +
+ * "bhav" API backend feeding a KOVA homebuilder ERP). Every per-home page is
+ * FULLY server-rendered — the facts we keep are embedded in the delivered HTML
+ * as a Nuxt "devalue" state payload — so this is a plain-fetch adapter (no
+ * browser, no bot-wall). Verified across CO + UT homes for address + geo +
+ * price + beds + baths + sqft + garage + plan + community + est-completion.
+ *
+ * robots.txt (oakwoodhomesco.com) = `User-agent: *  Allow: /  Disallow:` (fully
+ * open) and declares the sitemap index we crawl. The bhav API host is likewise
+ * `Disallow:` (open) — but we never need it; the SSR page carries everything.
+ *
+ *   Sitemap index  https://oakwoodhomesco.com/sitemap.xml
+ *     -> the per-home inventory feed is the child sitemap
+ *        https://oakwoodhomesco.com/available-homes-sitemaps.xml  (~535 <loc>s)
+ *        of per-home detail URLs of the form
+ *        /available-homes/{street-slug}-{zip}
+ *        (a handful 404 — homes recently sold/removed but still stale in the
+ *         sitemap; those are skipped, exactly like every other adapter tolerates
+ *         a dead sitemap URL). Community / floor-plan / collection sitemaps are
+ *         NOT read — they carry line-level aggregates, not per-home spec homes.
+ *
+ * Each per-home page embeds a Nuxt 3 flat-array (devalue) state payload in a
+ * `<script>` block starting `[[` and containing `"kova"` + `"sales_order_rid"`.
+ * We JSON.parse that array and resolve it (values are indices into the array;
+ * we walk with a cycle guard). The available-home object is the dict carrying
+ * `sales_order_rid` + `kova` + `slug`. Its authoritative facts live in the
+ * `kova` sub-object (a KOVA ERP sales-order dump):
+ *
+ *   kova.Lot.{StreetAddress, City, State, ZipCode, LotNumber, GeoLocLat, GeoLocLng}
+ *   kova.{NumBedrooms, NumBaths, Sqf, NumGarages, Price, EstComplDate, Status}
+ *   kova.Baths.{full, half}   (bathsTotal = full + half*0.5; NumBaths agrees)
+ *   floorplan_name (plan)      collection_name (community)
+ *   sales_order_rid (stable per-home builderInventoryId)
+ *
+ * Per-home geo IS captured (homes map). NO `stories` field is published as a
+ * discrete datum (the marketing description says "2-story" in prose, but that is
+ * NOT a structured fact) -> stories null. NO per-home sales phone in the sales
+ * order (the sales-office phone is community-level, elsewhere in the payload) ->
+ * salesPhone null on the community, like richmond-american / smith-douglas.
+ *
+ * Price: kova.Price is the home's BASE list price (kova.PriceTotal adds the lot
+ * premium) — we keep Price as the advertised price, matching the "$NNN,NNN" the
+ * page shows. A price of 0 / missing -> null (never guessed). Status: all feed
+ * homes are for-sale spec ("Market" homes, IsMarketHomeSO); EstComplDate in the
+ * future -> UNDER_CONSTRUCTION, else MOVE_IN_READY. Facts-only: images OMITTED
+ * (the payload DOES carry kovadatafeed S3 photo URLs, but v1 stays images-off /
+ * mediaRights=NONE per HomesOnSpec policy). Plain HTTP; one page == one
+ * inventory_home.
+ *
+ * Batch control: OAKWOOD_PAGE_LIMIT caps per-home fetches per run (default 40).
+ * Optional OAKWOOD_STATE filters the resolved homes by 2-letter state (e.g.
+ * "CO", "UT") — applied post-fetch since the sitemap URL doesn't carry state.
+ */
+const BUILDER_SLUG = "oakwood-homes";
+const ORIGIN = "https://oakwoodhomesco.com";
+const INVENTORY_SITEMAP = `${ORIGIN}/available-homes-sitemaps.xml`;
+const STATE_FILTER = normalizeStateCode(process.env.OAKWOOD_STATE ?? null);
+const PAGE_LIMIT = Number(process.env.OAKWOOD_PAGE_LIMIT ?? "40");
+
+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 };
+}
+
+// A positive finite number, or null. 0 / negative / NaN -> null (never guesses).
+const posNum = (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;
+};
+// A non-negative integer (garages / half-baths may legitimately be 0), or null.
+const nonNegInt = (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 ? Math.trunc(n) : null;
+};
+// A signed decimal (geo lat/lon may be negative — must NOT strip the minus).
+const coord = (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 clean = (v: unknown): string | null => {
+  if (v == null) return null;
+  const s = String(v).replace(/\s+/g, " ").trim();
+  return s || null;
+};
+
+/**
+ * Resolve a Nuxt 3 devalue flat-array payload. In this format the payload is a
+ * single JSON array; every value inside a dict/list is an INDEX into that array
+ * (or a `["Ref", n]`-style wrapper). We walk from a starting index, dereferencing
+ * indices to their stored value, with a per-path visited set as a cycle guard.
+ * Out-of-range / already-visited indices resolve to null (defensive — a broken
+ * reference must never throw or loop). Returns plain JSON (objects/arrays/scalars).
+ */
+const REF_WRAPPERS = new Set(["Ref", "Reactive", "ShallowReactive", "EmptyRef", "skipHydrate"]);
+function devalueResolve(arr: unknown[], index: number, seen: ReadonlySet<number>): unknown {
+  if (index < 0 || index >= arr.length || seen.has(index)) return null;
+  const next = new Set(seen).add(index);
+  const v = arr[index];
+  if (v !== null && typeof v === "object") {
+    if (Array.isArray(v)) {
+      // ["Ref", n] & friends: unwrap to the referenced value.
+      if (v.length === 2 && typeof v[0] === "string" && REF_WRAPPERS.has(v[0]) && typeof v[1] === "number") {
+        return devalueResolve(arr, v[1], next);
+      }
+      return v.map((x) => (typeof x === "number" ? devalueResolve(arr, x, next) : x));
+    }
+    const out: Record<string, unknown> = {};
+    for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
+      out[k] = typeof val === "number" ? devalueResolve(arr, val, next) : val;
+    }
+    return out;
+  }
+  return v; // primitive stored directly
+}
+
+interface OakHome {
+  slug: string | null;
+  status: string | null; // top-level lifecycle ("active")
+  street: string | null;
+  city: string | null;
+  state: string | null;
+  zip: string | null;
+  price: number | null;
+  beds: number | null;
+  fullBaths: number | null;
+  halfBaths: number | null;
+  bathsTotal: number | null;
+  sqft: number | null;
+  garages: number | null;
+  lat: number | null;
+  lon: number | null;
+  planName: string | null;
+  community: string | null;
+  lotNumber: string | null;
+  estCompletion: string | null; // ISO date
+  builderInventoryId: string | null;
+  constructionStatus: "UNDER_CONSTRUCTION" | "MOVE_IN_READY";
+}
+
+/** ISO-8601 datetime (with offset) -> "YYYY-MM-DD", or null. Rejects the KOVA
+ * sentinel "1900-01-01" placeholder used for unset dates. */
+function toIsoDate(raw: unknown): string | null {
+  const s = clean(raw);
+  if (!s) return null;
+  const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
+  if (!m) return null;
+  if (m[1] === "1900") return null; // KOVA "unset" placeholder
+  return `${m[1]}-${m[2]}-${m[3]}`;
+}
+
+/**
+ * Parse ONE Oakwood Homes per-home page (Nuxt SSR) into a home, or null if the
+ * page carries no available-home object (a 404 / non-home page slipped through).
+ * Exported for the self-test / fixtures.
+ */
+export function parseHome(html: string): OakHome | null {
+  // Locate the Nuxt devalue state <script> (starts "[[", carries the KOVA object).
+  const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]!.trim());
+  let arr: unknown[] | null = null;
+  for (const s of scripts) {
+    if (!s.startsWith("[[") || !s.includes('"kova"') || !s.includes('"sales_order_rid"')) continue;
+    try {
+      const parsed = JSON.parse(s);
+      if (Array.isArray(parsed)) {
+        arr = parsed;
+        break;
+      }
+    } catch {
+      // not the payload we want (or truncated) — keep scanning
+    }
+  }
+  if (!arr) return null;
+
+  // The available-home object is the dict carrying sales_order_rid + kova + slug.
+  let homeIdx = -1;
+  for (let i = 0; i < arr.length; i++) {
+    const v = arr[i];
+    if (v && typeof v === "object" && !Array.isArray(v)) {
+      const o = v as Record<string, unknown>;
+      if ("sales_order_rid" in o && "kova" in o && "slug" in o) {
+        homeIdx = i;
+        break;
+      }
+    }
+  }
+  if (homeIdx < 0) return null;
+
+  const home = devalueResolve(arr, homeIdx, new Set<number>()) as Record<string, unknown> | null;
+  if (!home) return null;
+
+  const kova = (home.kova ?? {}) as Record<string, unknown>;
+  const lot = (kova.Lot ?? {}) as Record<string, unknown>;
+  const baths = (kova.Baths ?? {}) as Record<string, unknown>;
+
+  const street = clean(lot.StreetAddress);
+  if (!street) return null; // no resolvable per-home address -> not a home
+
+  const fullBaths = nonNegInt(baths.full);
+  const halfBaths = nonNegInt(baths.half);
+  // Prefer the explicit NumBaths decimal; else compose from full + half*0.5.
+  const bathsTotal =
+    posNum(kova.NumBaths) ?? (fullBaths === null ? null : fullBaths + (halfBaths ?? 0) * 0.5);
+
+  const estCompletion = toIsoDate(kova.EstComplDate);
+  const constructionStatus: OakHome["constructionStatus"] = estCompletion
+    ? "UNDER_CONSTRUCTION"
+    : "MOVE_IN_READY";
+
+  const invId = kova.SlsOrdRID ?? home.sales_order_rid;
+
+  return {
+    slug: clean(home.slug),
+    status: clean(home.status),
+    street,
+    city: clean(lot.City),
+    state: normalizeStateCode(clean(lot.State)),
+    zip: (clean(lot.ZipCode) ?? "").match(/\d{5}/)?.[0] ?? null,
+    price: posNum(kova.Price),
+    beds: nonNegInt(kova.NumBedrooms),
+    fullBaths,
+    halfBaths,
+    bathsTotal,
+    sqft: nonNegInt(kova.Sqf),
+    garages: nonNegInt(kova.NumGarages),
+    lat: coord(lot.GeoLocLat),
+    lon: coord(lot.GeoLocLng),
+    planName: clean(home.floorplan_name),
+    community: clean(home.collection_name),
+    lotNumber: clean(lot.LotNumber),
+    estCompletion,
+    builderInventoryId: invId != null ? String(invId) : null,
+    constructionStatus,
+  };
+}
+
+export const oakwoodHomesAdapter: SourceAdapter = {
+  key: "oakwood-homes-site",
+  version: "0.1.0",
+
+  async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
+    if (ctx.mode === "fixture") {
+      yield* fetchFixtures(ctx);
+      return;
+    }
+    const fetcher = new LiveFetcher(ctx.registry);
+    const index = await fetcher.fetch(INVENTORY_SITEMAP);
+    const homeUrls = [...index.body.toString("utf8").matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)]
+      .map((m) => m[1]!)
+      .filter((u) => /\/available-homes\/[^/?#]+$/.test(u));
+    for (const url of homeUrls.slice(0, PAGE_LIMIT)) {
+      try {
+        yield await fetcher.fetch(url);
+      } catch (error) {
+        // 404 (sold/removed but stale in sitemap), transient error, or a robots
+        // skip — log and continue; one dead URL never stops the run.
+        console.warn(`  skip ${url}: ${error instanceof Error ? error.message : String(error)}`);
+      }
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const html = page.body.toString("utf8");
+      const h = parseHome(html);
+      if (!h || !h.street) return { records: [], errors: [] }; // not a resolvable home page
+
+      if (STATE_FILTER && h.state !== STATE_FILTER) return { records: [], errors: [] };
+
+      const cname = h.community ?? "Unknown";
+      const records: ExtractedRecord[] = [];
+
+      // Community FIRST — publish creates the FK target the home record needs.
+      // Carries the per-home geo so the community also maps. No community-level
+      // sales phone in the per-home sales order -> salesPhone null (like
+      // richmond-american / smith-douglas).
+      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(h.city, h.city, page.url),
+          state: fv(h.state, h.state, page.url),
+          zip: fv(h.zip, h.zip, page.url),
+          county: fv<string>(null, null, page.url),
+          metro: fv<string>(null, null, page.url),
+          lat: fv(h.lat, h.lat != null ? String(h.lat) : null, page.url),
+          lon: fv(h.lon, h.lon != null ? String(h.lon) : 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),
+        },
+      });
+
+      records.push({
+        entityType: "inventory_home",
+        canonicalHints: {
+          builderSlug: BUILDER_SLUG,
+          communityName: cname,
+          address: h.street,
+          builderInventoryId: h.builderInventoryId ?? page.url,
+          lat: h.lat ?? undefined,
+          lon: h.lon ?? undefined,
+          planName: h.planName ?? undefined,
+        },
+        fields: {
+          street: fv(h.street, h.street, page.url),
+          city: fv(h.city, h.city, page.url),
+          state: fv(h.state, h.state, page.url),
+          zip: fv(h.zip, h.zip, page.url),
+          price: fv(
+            h.price,
+            h.price != null ? `$${h.price}` : null,
+            page.url,
+            h.price != null ? `KOVA sales-order Price $${h.price}` : "no listed price on the sales order",
+          ),
+          beds: fv(h.beds, h.beds != null ? String(h.beds) : null, page.url),
+          bathsTotal: fv(
+            h.bathsTotal,
+            h.bathsTotal != null ? String(h.bathsTotal) : null,
+            page.url,
+            h.fullBaths != null ? `${h.fullBaths} full + ${h.halfBaths ?? 0} half` : null,
+          ),
+          sqft: fv(h.sqft, h.sqft != null ? String(h.sqft) : null, page.url),
+          stories: fv<number>(null, null, page.url), // not published as a structured fact
+          garageSpaces: fv(h.garages, h.garages != null ? String(h.garages) : null, page.url),
+          homeType: fv("SINGLE_FAMILY" as const, null, page.url, "Oakwood Homes site-built single-family spec home"),
+          constructionStatus: fv<"PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY">(
+            h.constructionStatus,
+            null,
+            page.url,
+            h.constructionStatus === "MOVE_IN_READY"
+              ? "no future est-completion date (move-in ready)"
+              : `est completion ${h.estCompletion}`,
+          ),
+          estCompletionDate: fv(h.estCompletion, h.estCompletion, page.url),
+          lotNumber: fv(h.lotNumber, h.lotNumber, page.url),
+          builderInventoryId: fv(h.builderInventoryId, h.builderInventoryId, page.url),
+          lat: fv(h.lat, h.lat != null ? String(h.lat) : null, page.url),
+          lon: fv(h.lon, h.lon != null ? String(h.lon) : null, page.url),
+          planName: fv(h.planName, h.planName, page.url),
+          // Facts-only: images intentionally omitted (mediaRights=NONE).
+          images: fv<string[]>([], null, page.url),
+        },
+      });
+
+      return { records, errors: [] };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/oakwood-homes/tsconfig.json b/collectors/oakwood-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/oakwood-homes/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }

← 01913195 collectors/goodall-homes: facts-only inventory adapter (Clay  ·  back to Homesonspec  ·  TK-10487: build Goodall + Brohn + Oakwood CPG per-brand adap d3c30ea1 →