← 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
A collectors/oakwood-homes/package.jsonA collectors/oakwood-homes/src/index.tsA collectors/oakwood-homes/tsconfig.json
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 →