← back to Homesonspec
feat(specs): dr-horton capture proving slice — extract->validate->publish->render
7c3609ccc9682177c54896be41fbff35f3748fed · 2026-08-02 23:26:34 -0700 · Steve Abrams
Reference-builder end-to-end: schemas declares optional specs field; dr-horton
extractor emits a pruned specs map (planCode/multiGen/originalPrice — nulls &
false-flags dropped, verified vs a real snapshot); detail page renders an
'Additional details' block + builder-listing link. All 4 packages typecheck
clean. Monthly-payment/was-price live deeper in DRH's tree (follow-up noted).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/web/src/app/homes/[id]/page.tsxM collectors/SPECS-AUDIT.mdM collectors/dr-horton/src/index.tsM packages/schemas/src/index.ts
Diff
commit 7c3609ccc9682177c54896be41fbff35f3748fed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 2 23:26:34 2026 -0700
feat(specs): dr-horton capture proving slice — extract->validate->publish->render
Reference-builder end-to-end: schemas declares optional specs field; dr-horton
extractor emits a pruned specs map (planCode/multiGen/originalPrice — nulls &
false-flags dropped, verified vs a real snapshot); detail page renders an
'Additional details' block + builder-listing link. All 4 packages typecheck
clean. Monthly-payment/was-price live deeper in DRH's tree (follow-up noted).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/web/src/app/homes/[id]/page.tsx | 53 ++++++++++++++++++++++++++++++++++++
collectors/SPECS-AUDIT.md | 10 +++++++
collectors/dr-horton/src/index.ts | 29 ++++++++++++++++++++
packages/schemas/src/index.ts | 4 +++
4 files changed, 96 insertions(+)
diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index 74b4c396..3935168d 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -90,6 +90,33 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
],
];
+ // Long-tail specs captured into the extensible `specs` map (per builder). Rendered
+ // in their own "Additional details" block, separate from the core facts. Only
+ // known keys with a label surface; unknown keys are ignored (never fabricated).
+ const SPEC_LABELS: Record<string, string> = {
+ estMonthlyPayment: "Est. monthly payment",
+ originalPrice: "Original price",
+ totalSalePrice: "Sale price",
+ planCode: "Plan code",
+ multiGen: "Multi-gen layout",
+ allowOffer: "Accepts offers",
+ qmiAvailable: "Quick move-in",
+ requiresPromo: "Promo required",
+ };
+ const rawSpecs =
+ home.specs && typeof home.specs === "object" && !Array.isArray(home.specs)
+ ? (home.specs as Record<string, unknown>)
+ : {};
+ const specFacts: [string, string][] = Object.entries(rawSpecs)
+ .filter(([k]) => SPEC_LABELS[k])
+ .map(([k, v]): [string, string] => {
+ if (typeof v === "boolean") return [SPEC_LABELS[k]!, v ? "Yes" : "No"];
+ if (k === "estMonthlyPayment") return [SPEC_LABELS[k]!, `${fmtPrice(Number(v))}/mo`];
+ if (k === "originalPrice" || k === "totalSalePrice") return [SPEC_LABELS[k]!, fmtPrice(Number(v))];
+ return [SPEC_LABELS[k]!, String(v)];
+ });
+ const purchaseUrl = typeof rawSpecs.purchaseUrl === "string" ? rawSpecs.purchaseUrl : null;
+
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<nav className="text-sm text-neutral-500">
@@ -183,6 +210,32 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
“Not published” means the source did not state this fact. HomesOnSpec never fills in missing values.
</p>
+ {(specFacts.length > 0 || purchaseUrl) && (
+ <section className="mt-8">
+ <h2 className="font-display text-xl font-semibold text-brand-900">Additional details</h2>
+ {specFacts.length > 0 && (
+ <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
+ {specFacts.map(([label, value]) => (
+ <div key={label} className="border-b border-neutral-100 py-1.5">
+ <dt className="text-neutral-500">{label}</dt>
+ <dd className="font-medium">{value}</dd>
+ </div>
+ ))}
+ </dl>
+ )}
+ {purchaseUrl && (
+ <a
+ href={purchaseUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="mt-3 inline-block text-sm font-medium text-accent-600 hover:text-accent-700"
+ >
+ View this home on the builder’s site →
+ </a>
+ )}
+ </section>
+ )}
+
{incentives.length > 0 && (
<section className="mt-8">
<h2 className="font-display text-xl font-semibold text-brand-900">Incentives</h2>
diff --git a/collectors/SPECS-AUDIT.md b/collectors/SPECS-AUDIT.md
index d65b6cb7..9f4ad0c8 100644
--- a/collectors/SPECS-AUDIT.md
+++ b/collectors/SPECS-AUDIT.md
@@ -79,6 +79,16 @@ its structure). So they fold directly into the per-builder fan-out below —
each worker reads that builder's extractor, enumerates the object it iterates,
and adds the SPEC fields. No separate audit step for them.
+### Proving slice — dr-horton end-to-end (schema→extract→publish→render) — 2026-08-02
+DONE + typechecks clean across schemas/collector/publisher/web. Verified against a
+real snapshot: DRH home Items carry `PlanCode` (real, e.g. "X475"), `OriginalPrice`
+(often 0 sentinel → dropped), `MultiGen` (bool). Extractor emits a pruned `specs`
+map (nulls + false-flags dropped); publisher persists it; detail page shows an
+"Additional details" block + builder-listing link.
+**Follow-up:** `EstimatedMontlyPayment` / `TotalSalePrice` / `PurchaseUrl` are NOT on
+the home Items — they live elsewhere in DRH's `var model` tree (plan-level?). A
+second extraction pass is needed to reach them. Item-level specs work today.
+
### GATED — capture requires prod changes
Beyond read-only auditing, actually CAPTURING specs needs: (a) `specs Json?`
schema migration on the live Kamatera DB, (b) per-builder extractor edits, and
diff --git a/collectors/dr-horton/src/index.ts b/collectors/dr-horton/src/index.ts
index 9449c12d..2bb7665a 100644
--- a/collectors/dr-horton/src/index.ts
+++ b/collectors/dr-horton/src/index.ts
@@ -184,6 +184,29 @@ export const drHortonAdapter: SourceAdapter = {
const lot = str(it.LotNumber) ?? str(it.ItemId) ?? str(it.Id);
const planName = str(it.PlanName);
+ // Long-tail specs D.R. Horton exposes beyond the core columns (SPECS-AUDIT.md).
+ // Captured into the extensible `specs` map so buyers see monthly payment,
+ // was-price / price drops, plan code, multi-gen, and purchase link.
+ const bool = (v: unknown): boolean | null =>
+ typeof v === "boolean" ? v : v === "true" ? true : v === "false" ? false : null;
+ const purchaseRaw = str(it.PurchaseUrl);
+ const specs: Record<string, unknown> = {
+ originalPrice: num(it.OriginalPrice),
+ totalSalePrice: num(it.TotalSalePrice),
+ estMonthlyPayment: num(it.EstimatedMontlyPayment),
+ planCode: str(it.PlanCode),
+ multiGen: bool(it.MultiGen),
+ allowOffer: bool(it.AllowOffer),
+ qmiAvailable: bool(it.QmiAvailable),
+ requiresPromo: bool(it.RequiresPromo),
+ purchaseUrl: purchaseRaw
+ ? purchaseRaw.startsWith("http") ? purchaseRaw : ORIGIN + purchaseRaw
+ : null,
+ };
+ // Keep only real signal: drop nulls AND false feature-flags (a home that
+ // is NOT multi-gen shouldn't carry "multiGen: false" — that's noise, not a spec).
+ for (const k of Object.keys(specs)) if (specs[k] == null || specs[k] === false) delete specs[k];
+
records.push({
entityType: "inventory_home",
canonicalHints: {
@@ -215,6 +238,12 @@ export const drHortonAdapter: SourceAdapter = {
planName: fv(planName, planName, page.url),
availabilityStatus: fv(status, status, page.url),
images: fv<string[]>(image ? [image] : [], null, page.url, image ? "builder listing photo" : null),
+ specs: fv(
+ Object.keys(specs).length ? specs : null,
+ null,
+ page.url,
+ "dr-horton extra specs (monthly payment, was-price, plan code, multi-gen, lifecycle)",
+ ),
},
});
}
diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts
index 7a7edc52..661849bc 100644
--- a/packages/schemas/src/index.ts
+++ b/packages/schemas/src/index.ts
@@ -80,6 +80,10 @@ export const inventoryHomePayloadSchema = z.object({
// Builder-supplied listing photos (largest srcset variant per asset). Optional
// so adapters that don't yet capture media still validate.
images: fieldValueSchema(z.array(z.string())).optional(),
+ // Extensible long-tail specs (monthly payment, was-price, plan code, multi-gen,
+ // lifecycle flags, incentives, …) — an open object, per-builder. Optional so
+ // adapters that don't emit it still validate. See collectors/SPECS-AUDIT.md.
+ specs: fieldValueSchema(z.record(z.string(), z.unknown())).optional(),
});
export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;
← 26f91560 feat(specs): InventoryHome.specs Json foundation — schema +
·
back to Homesonspec
·
feat(specs): pulte specs capture — discount-off-list, monthl 1fd7b60a →