← back to Homesonspec
collector adapter contract + meridian-homes fixture adapter with hand-authored extraction contracts (7 tests green, null-never-guessed proven)
1e9a023a98fafd5cb19f7bf78787a0db4d6cb1cb · 2026-07-22 11:22:15 -0700 · Steve Abrams
Files touched
A collectors/common/src/adapter.tsA collectors/common/src/index.tsA collectors/meridian-homes/src/extract.test.tsA collectors/meridian-homes/src/index.tsA fixtures/expected-records/meridian-homes.jsonA fixtures/raw-pages/meridian-homes/01-community-cedar-bend.htmlA fixtures/raw-pages/meridian-homes/02-home-118-juniper.htmlA fixtures/raw-pages/meridian-homes/03-home-124-silverleaf.htmlA fixtures/raw-pages/meridian-homes/04-home-131-brackens.htmlA fixtures/raw-pages/meridian-homes/05-incentives.html
Diff
commit 1e9a023a98fafd5cb19f7bf78787a0db4d6cb1cb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 11:22:15 2026 -0700
collector adapter contract + meridian-homes fixture adapter with hand-authored extraction contracts (7 tests green, null-never-guessed proven)
---
collectors/common/src/adapter.ts | 81 ++++++++
collectors/common/src/index.ts | 1 +
collectors/meridian-homes/src/extract.test.ts | 75 ++++++++
collectors/meridian-homes/src/index.ts | 214 +++++++++++++++++++++
fixtures/expected-records/meridian-homes.json | 138 +++++++++++++
.../meridian-homes/01-community-cedar-bend.html | 29 +++
.../meridian-homes/02-home-118-juniper.html | 32 +++
.../meridian-homes/03-home-124-silverleaf.html | 35 ++++
.../meridian-homes/04-home-131-brackens.html | 34 ++++
.../raw-pages/meridian-homes/05-incentives.html | 22 +++
10 files changed, 661 insertions(+)
diff --git a/collectors/common/src/adapter.ts b/collectors/common/src/adapter.ts
new file mode 100644
index 0000000..87f9f8a
--- /dev/null
+++ b/collectors/common/src/adapter.ts
@@ -0,0 +1,81 @@
+import { createHash } from "node:crypto";
+import { readFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+import type { ExtractedRecord } from "@spechomes/schemas";
+
+/**
+ * The contract every builder collector implements. v1 ships fixture mode
+ * only — live fetching arrives later as a second FetchContext implementation
+ * that honors the source registry's rate limits and permissions. Extraction
+ * is pure and deterministic: same bytes in, same records out.
+ */
+
+export interface RawPage {
+ url: string;
+ retrievedAt: string; // ISO
+ contentType: string;
+ body: Buffer;
+ contentHash: string; // sha256 hex
+}
+
+export interface SourceRegistryEntry {
+ key: string;
+ collectionMethod: "FEED" | "CRAWL" | "MANUAL" | "FIXTURE" | "SYNTHETIC";
+ rateLimitRpm?: number | null;
+ mediaRights: "NONE" | "LINK_ONLY" | "LICENSED";
+}
+
+export interface FetchContext {
+ mode: "fixture" | "live";
+ fixtureDir?: string; // fixtures/raw-pages/<key>/
+ registry: SourceRegistryEntry;
+}
+
+export interface ExtractionOutput {
+ records: ExtractedRecord[];
+ errors: { url: string; reason: string }[];
+}
+
+export interface SourceAdapter {
+ /** Must equal the SourceRegistry.key row this adapter collects for. */
+ key: string;
+ /** Stamped as extractor_version on every evidence + staged record. */
+ version: string;
+ fetch(ctx: FetchContext): AsyncIterable<RawPage>;
+ extract(page: RawPage): ExtractionOutput;
+ /**
+ * Optional LLM-assist hook (terminology mapping, incentive parsing).
+ * v1 is identity. Whatever this returns still faces the deterministic
+ * validators — normalization can never bless a record into publishing.
+ */
+ normalize?(record: ExtractedRecord): Promise<ExtractedRecord>;
+}
+
+export function sha256(buffer: Buffer | string): string {
+ return createHash("sha256").update(buffer).digest("hex");
+}
+
+/**
+ * Shared fixture fetcher: yields every .html/.json file in the adapter's
+ * fixture directory as a RawPage. The "url" is reconstructed from a
+ * `<!-- source-url: ... -->` comment on line 1 when present, else a
+ * fixture:// pseudo-URL — keeping evidence traceable even in fixture mode.
+ */
+export async function* fetchFixtures(ctx: FetchContext): AsyncIterable<RawPage> {
+ if (ctx.mode !== "fixture" || !ctx.fixtureDir) {
+ throw new Error("fetchFixtures requires mode=fixture and a fixtureDir");
+ }
+ const files = (await readdir(ctx.fixtureDir)).filter((f) => /\.(html|json)$/.test(f)).sort();
+ for (const file of files) {
+ const body = await readFile(join(ctx.fixtureDir, file));
+ const firstLine = body.toString("utf8").split("\n", 1)[0] ?? "";
+ const match = firstLine.match(/source-url:\s*(\S+)/);
+ yield {
+ url: match?.[1] ?? `fixture://${ctx.registry.key}/${file}`,
+ retrievedAt: new Date().toISOString(),
+ contentType: file.endsWith(".json") ? "application/json" : "text/html",
+ body,
+ contentHash: sha256(body),
+ };
+ }
+}
diff --git a/collectors/common/src/index.ts b/collectors/common/src/index.ts
new file mode 100644
index 0000000..e202a52
--- /dev/null
+++ b/collectors/common/src/index.ts
@@ -0,0 +1 @@
+export * from "./adapter.js";
diff --git a/collectors/meridian-homes/src/extract.test.ts b/collectors/meridian-homes/src/extract.test.ts
new file mode 100644
index 0000000..11ed8d2
--- /dev/null
+++ b/collectors/meridian-homes/src/extract.test.ts
@@ -0,0 +1,75 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { sha256 } from "@spechomes/collectors-common";
+import { meridianHomesAdapter } from "./index.js";
+
+const FIXTURE_DIR = join(import.meta.dirname, "../../../fixtures/raw-pages/meridian-homes");
+const EXPECTED_PATH = join(import.meta.dirname, "../../../fixtures/expected-records/meridian-homes.json");
+
+interface ExpectedRecord {
+ entityType: string;
+ canonicalHints?: Record<string, unknown>;
+ values: Record<string, unknown>;
+}
+interface ExpectedFile {
+ pages: Record<string, ExpectedRecord[]>;
+}
+
+async function extractPage(file: string) {
+ const body = await readFile(join(FIXTURE_DIR, file));
+ const firstLine = body.toString("utf8").split("\n", 1)[0] ?? "";
+ const url = firstLine.match(/source-url:\s*(\S+)/)?.[1] ?? `fixture://${file}`;
+ return meridianHomesAdapter.extract({
+ url,
+ retrievedAt: new Date().toISOString(),
+ contentType: "text/html",
+ body,
+ contentHash: sha256(body),
+ });
+}
+
+describe("meridian-homes extractor vs hand-authored expected records", async () => {
+ const expected: ExpectedFile = JSON.parse(await readFile(EXPECTED_PATH, "utf8"));
+
+ for (const [file, expectedRecords] of Object.entries(expected.pages)) {
+ it(`${file} extracts the contracted records exactly`, async () => {
+ const { records, errors } = await extractPage(file);
+ expect(errors).toEqual([]);
+ expect(records).toHaveLength(expectedRecords.length);
+
+ records.forEach((record, i) => {
+ const want = expectedRecords[i]!;
+ expect(record.entityType).toBe(want.entityType);
+ if (want.canonicalHints) {
+ for (const [key, value] of Object.entries(want.canonicalHints)) {
+ expect(record.canonicalHints[key as keyof typeof record.canonicalHints], `hint ${key}`).toBe(value);
+ }
+ }
+ for (const [field, value] of Object.entries(want.values)) {
+ expect(record.fields[field]?.value, `${file} field ${field}`).toEqual(value);
+ }
+ });
+ });
+ }
+
+ it("every extracted field carries a sourceUrl and confidence", async () => {
+ for (const file of Object.keys(expected.pages)) {
+ const { records } = await extractPage(file);
+ for (const record of records) {
+ for (const [field, envelope] of Object.entries(record.fields)) {
+ expect(envelope.sourceUrl, `${file}/${field} sourceUrl`).toMatch(/^https?:\/\/|^fixture:\/\//);
+ expect(envelope.confidence, `${file}/${field} confidence`).toBeGreaterThanOrEqual(0);
+ expect(envelope.confidence).toBeLessThanOrEqual(1);
+ }
+ }
+ }
+ });
+
+ it("null values (missing from source) always have confidence 0 — never guessed", async () => {
+ const { records } = await extractPage("03-home-124-silverleaf.html");
+ const home = records[0]!;
+ expect(home.fields.price?.value).toBeNull();
+ expect(home.fields.price?.confidence).toBe(0);
+ });
+});
diff --git a/collectors/meridian-homes/src/index.ts b/collectors/meridian-homes/src/index.ts
new file mode 100644
index 0000000..6fc531c
--- /dev/null
+++ b/collectors/meridian-homes/src/index.ts
@@ -0,0 +1,214 @@
+import { parse, type HTMLElement } from "node-html-parser";
+import type { ExtractedRecord, FieldValue } from "@spechomes/schemas";
+import {
+ fetchFixtures,
+ type ExtractionOutput,
+ type RawPage,
+ type SourceAdapter,
+} from "@spechomes/collectors-common";
+
+/**
+ * Meridian Homes adapter — the reference fixture adapter. Deterministic
+ * selector-based extraction; every field carries evidence; anything the
+ * page doesn't state is null.
+ */
+
+const BUILDER_SLUG = "meridian-homes";
+
+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, // deterministic extraction: found = 1, absent = 0
+ };
+}
+
+function text(root: HTMLElement, selector: string): string | null {
+ const el = root.querySelector(selector);
+ const t = el?.textContent?.trim();
+ return t && t.length > 0 ? t : null;
+}
+
+function parseMoney(raw: string | null): number | null {
+ if (!raw) return null;
+ const match = raw.replace(/,/g, "").match(/\$?\s*(\d+(?:\.\d+)?)/);
+ return match?.[1] ? Number(match[1]) : null;
+}
+
+function parseLeadingNumber(raw: string | null): number | null {
+ if (!raw) return null;
+ const match = raw.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
+ return match?.[1] ? Number(match[1]) : null;
+}
+
+/** "Est. Completion: November 2026" → ISO first-of-month. Null if absent/unparseable. */
+function parseCompletion(raw: string | null): string | null {
+ if (!raw) return null;
+ const match = raw.match(/([A-Z][a-z]+)\s+(\d{4})/);
+ if (!match) return null;
+ const date = new Date(`${match[1]} 1, ${match[2]} 12:00:00 UTC`);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
+}
+
+function parseStatus(raw: string | null): "PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+ if (!raw) return null;
+ const normalized = raw.toLowerCase();
+ if (normalized.includes("move-in ready") || normalized.includes("move in ready")) return "MOVE_IN_READY";
+ if (normalized.includes("under construction")) return "UNDER_CONSTRUCTION";
+ if (normalized.includes("planned") || normalized.includes("coming soon")) return "PLANNED";
+ return null;
+}
+
+function geoAttr(root: HTMLElement, attr: string): number | null {
+ const el = root.querySelector(".geo");
+ const raw = el?.getAttribute(attr);
+ const value = raw ? Number(raw) : NaN;
+ return Number.isFinite(value) ? value : null;
+}
+
+function extractCommunity(root: HTMLElement, url: string): ExtractedRecord {
+ const name = text(root, ".community-name");
+ const hoaRaw = text(root, ".hoa");
+ const ageRaw = text(root, ".age-restriction");
+ const lat = geoAttr(root, "data-lat");
+ const lon = geoAttr(root, "data-lon");
+ return {
+ entityType: "community",
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName: name,
+ lat,
+ lon,
+ },
+ fields: {
+ name: fv(name, name, url),
+ street: fv(text(root, ".community-address .street"), text(root, ".community-address .street"), url),
+ city: fv(text(root, ".city"), text(root, ".city"), url),
+ state: fv(text(root, ".state"), text(root, ".state"), url),
+ zip: fv(text(root, ".zip"), text(root, ".zip"), url),
+ county: fv(text(root, ".county"), text(root, ".county"), url),
+ metro: fv(text(root, ".metro"), text(root, ".metro"), url),
+ lat: fv(lat, lat === null ? null : String(lat), url),
+ lon: fv(lon, lon === null ? null : String(lon), url),
+ hoaFeeMonthly: fv(parseMoney(hoaRaw), hoaRaw, url),
+ schoolDistrict: fv(
+ text(root, ".school-district")?.replace(/^School District:\s*/i, "") ?? null,
+ text(root, ".school-district"),
+ url,
+ ),
+ ageRestricted: fv(
+ ageRaw === null ? null : /no$/i.test(ageRaw.trim()) ? false : /yes$/i.test(ageRaw.trim()) ? true : null,
+ ageRaw,
+ url,
+ ),
+ },
+ };
+}
+
+function extractHome(root: HTMLElement, url: string): ExtractedRecord {
+ const address = text(root, ".home-address");
+ const priceRaw = text(root, ".price"); // absent on contact-for-pricing pages → null, never guessed
+ const bedsRaw = text(root, ".beds");
+ const bathsRaw = text(root, ".baths");
+ const sqftRaw = text(root, ".sqft");
+ const storiesRaw = text(root, ".stories");
+ const garageRaw = text(root, ".garage");
+ const statusRaw = text(root, ".construction-status");
+ const completionRaw = text(root, ".est-completion");
+ const planName = text(root, ".plan-name");
+ const lotRaw = text(root, ".lot-number");
+ const lotNumber = lotRaw?.replace(/^Lot\s+/i, "") ?? null;
+ const inventoryId = root.querySelector(".inventory-home")?.getAttribute("data-inventory-id") ?? null;
+ const lat = geoAttr(root, "data-lat");
+ const lon = geoAttr(root, "data-lon");
+
+ return {
+ entityType: "inventory_home",
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName: "Cedar Bend",
+ address,
+ lotNumber,
+ builderInventoryId: inventoryId,
+ lat,
+ lon,
+ planName,
+ },
+ fields: {
+ street: fv(address, address, url),
+ city: fv(text(root, ".city"), text(root, ".city"), url),
+ state: fv(text(root, ".state"), text(root, ".state"), url),
+ zip: fv(text(root, ".zip"), text(root, ".zip"), url),
+ price: fv(parseMoney(priceRaw), priceRaw, url),
+ beds: fv(parseLeadingNumber(bedsRaw), bedsRaw, url),
+ bathsTotal: fv(parseLeadingNumber(bathsRaw), bathsRaw, url),
+ sqft: fv(parseLeadingNumber(sqftRaw), sqftRaw, url),
+ stories: fv(parseLeadingNumber(storiesRaw), storiesRaw, url),
+ garageSpaces: fv(parseLeadingNumber(garageRaw), garageRaw, url),
+ homeType: fv("SINGLE_FAMILY" as const, null, url, "single-family inventory page layout"),
+ constructionStatus: fv(parseStatus(statusRaw), statusRaw, url),
+ estCompletionDate: fv(parseCompletion(completionRaw), completionRaw, url),
+ lotNumber: fv(lotNumber, lotRaw, url),
+ builderInventoryId: fv(inventoryId, inventoryId, url),
+ lat: fv(lat, lat === null ? null : String(lat), url),
+ lon: fv(lon, lon === null ? null : String(lon), url),
+ planName: fv(planName, planName, url),
+ },
+ };
+}
+
+function extractIncentives(root: HTMLElement, url: string): ExtractedRecord[] {
+ return root.querySelectorAll("section.incentive").map((section, index) => {
+ const title = section.querySelector(".incentive-title")?.textContent?.trim() ?? null;
+ const description = section.querySelector(".incentive-description")?.textContent?.trim() ?? null;
+ const valueRaw = section.querySelector(".incentive-value")?.textContent?.trim() ?? null;
+ const expiresRaw = section.querySelector(".incentive-expires")?.textContent?.trim() ?? null;
+ const expiresMatch = expiresRaw?.match(/(\d{4}-\d{2}-\d{2})/) ?? null;
+ return {
+ entityType: "incentive" as const,
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName: "Cedar Bend",
+ builderInventoryId: `incentive-${index + 1}`,
+ },
+ fields: {
+ title: fv(title, title, url),
+ description: fv(description, description, url),
+ valueUsd: fv(parseMoney(valueRaw), valueRaw, url),
+ expiresAt: fv(expiresMatch?.[1] ?? null, expiresRaw, url),
+ },
+ };
+ });
+}
+
+export const meridianHomesAdapter: SourceAdapter = {
+ key: "meridian-homes-fixtures",
+ version: "1.0.0",
+
+ fetch: fetchFixtures,
+
+ extract(page: RawPage): ExtractionOutput {
+ try {
+ const root = parse(page.body.toString("utf8"));
+ if (root.querySelector(".community-page")) {
+ return { records: [extractCommunity(root, page.url)], errors: [] };
+ }
+ if (root.querySelector(".inventory-home")) {
+ return { records: [extractHome(root, page.url)], errors: [] };
+ }
+ if (root.querySelector(".incentives-page")) {
+ return { records: extractIncentives(root, page.url), errors: [] };
+ }
+ return { records: [], errors: [{ url: page.url, reason: "unrecognized page structure" }] };
+ } catch (error) {
+ return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+ }
+ },
+};
diff --git a/fixtures/expected-records/meridian-homes.json b/fixtures/expected-records/meridian-homes.json
new file mode 100644
index 0000000..09a922c
--- /dev/null
+++ b/fixtures/expected-records/meridian-homes.json
@@ -0,0 +1,138 @@
+{
+ "comment": "Hand-authored extraction contracts for the meridian-homes fixture pages. Tests assert every listed field VALUE matches the extractor output exactly. null means the source page does not state the fact — the extractor must return null, never a guess.",
+ "pages": {
+ "01-community-cedar-bend.html": [
+ {
+ "entityType": "community",
+ "values": {
+ "name": "Cedar Bend",
+ "street": "4200 Cedar Bend Parkway",
+ "city": "Leander",
+ "state": "TX",
+ "zip": "78641",
+ "county": "Williamson County",
+ "metro": "Austin-Round Rock",
+ "lat": 30.5785,
+ "lon": -97.8532,
+ "hoaFeeMonthly": 65,
+ "schoolDistrict": "Leander ISD",
+ "ageRestricted": false
+ }
+ }
+ ],
+ "02-home-118-juniper.html": [
+ {
+ "entityType": "inventory_home",
+ "canonicalHints": {
+ "builderSlug": "meridian-homes",
+ "communityName": "Cedar Bend",
+ "address": "118 Juniper Draw",
+ "lotNumber": "118",
+ "builderInventoryId": "MH-1118",
+ "planName": "The Juniper"
+ },
+ "values": {
+ "street": "118 Juniper Draw",
+ "city": "Leander",
+ "state": "TX",
+ "zip": "78641",
+ "price": 462990,
+ "beds": 4,
+ "bathsTotal": 3,
+ "sqft": 2415,
+ "stories": 2,
+ "garageSpaces": 2,
+ "constructionStatus": "MOVE_IN_READY",
+ "estCompletionDate": null,
+ "lotNumber": "118",
+ "builderInventoryId": "MH-1118",
+ "lat": 30.5791,
+ "lon": -97.8519,
+ "planName": "The Juniper"
+ }
+ }
+ ],
+ "03-home-124-silverleaf.html": [
+ {
+ "entityType": "inventory_home",
+ "canonicalHints": {
+ "builderSlug": "meridian-homes",
+ "communityName": "Cedar Bend",
+ "address": "124 Silverleaf Pass",
+ "lotNumber": "124",
+ "builderInventoryId": "MH-1124",
+ "planName": "The Silverleaf"
+ },
+ "values": {
+ "street": "124 Silverleaf Pass",
+ "city": "Leander",
+ "state": "TX",
+ "zip": "78641",
+ "price": null,
+ "beds": 5,
+ "bathsTotal": 4,
+ "sqft": 3102,
+ "stories": 2,
+ "garageSpaces": 3,
+ "constructionStatus": "UNDER_CONSTRUCTION",
+ "estCompletionDate": "2026-11-01",
+ "lotNumber": "124",
+ "builderInventoryId": "MH-1124",
+ "lat": 30.5802,
+ "lon": -97.8504,
+ "planName": "The Silverleaf"
+ }
+ }
+ ],
+ "04-home-131-brackens.html": [
+ {
+ "entityType": "inventory_home",
+ "canonicalHints": {
+ "builderSlug": "meridian-homes",
+ "communityName": "Cedar Bend",
+ "address": "131 Brackens Crossing",
+ "lotNumber": "131",
+ "builderInventoryId": "MH-1131",
+ "planName": "The Bracken"
+ },
+ "values": {
+ "street": "131 Brackens Crossing",
+ "city": "Leander",
+ "state": "TX",
+ "zip": "78641",
+ "price": 389990,
+ "beds": 3,
+ "bathsTotal": 2.5,
+ "sqft": 1988,
+ "stories": 1,
+ "garageSpaces": 2,
+ "constructionStatus": "UNDER_CONSTRUCTION",
+ "estCompletionDate": "2026-09-01",
+ "lotNumber": "131",
+ "builderInventoryId": "MH-1131",
+ "lat": 30.5773,
+ "lon": -97.8548,
+ "planName": "The Bracken"
+ }
+ }
+ ],
+ "05-incentives.html": [
+ {
+ "entityType": "incentive",
+ "values": {
+ "title": "$10,000 Closing Cost Credit",
+ "valueUsd": 10000,
+ "expiresAt": "2026-09-30"
+ }
+ },
+ {
+ "entityType": "incentive",
+ "values": {
+ "title": "Rate Buydown Available",
+ "valueUsd": null,
+ "expiresAt": null
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/raw-pages/meridian-homes/01-community-cedar-bend.html b/fixtures/raw-pages/meridian-homes/01-community-cedar-bend.html
new file mode 100644
index 0000000..51fe0df
--- /dev/null
+++ b/fixtures/raw-pages/meridian-homes/01-community-cedar-bend.html
@@ -0,0 +1,29 @@
+<!-- source-url: https://fixtures.meridianhomes.example/communities/cedar-bend -->
+<!DOCTYPE html>
+<html lang="en">
+<head><title>Cedar Bend | Meridian Homes</title></head>
+<body>
+ <!-- SYNTHETIC FIXTURE — demonstration data for a fictional builder. -->
+ <main class="community-page" data-community-id="MH-CB">
+ <h1 class="community-name">Cedar Bend</h1>
+ <div class="community-address">
+ <span class="street">4200 Cedar Bend Parkway</span>,
+ <span class="city">Leander</span>, <span class="state">TX</span> <span class="zip">78641</span>
+ </div>
+ <div class="community-meta">
+ <span class="county">Williamson County</span> ·
+ <span class="metro">Austin-Round Rock</span>
+ </div>
+ <div class="geo" data-lat="30.578500" data-lon="-97.853200"></div>
+ <ul class="community-facts">
+ <li class="hoa">HOA: $65/month</li>
+ <li class="school-district">School District: Leander ISD</li>
+ <li class="age-restriction">Age restricted: No</li>
+ </ul>
+ <section class="amenities">
+ <h2>Amenities</h2>
+ <ul><li>Community pool</li><li>Trail network</li><li>Neighborhood park</li></ul>
+ </section>
+ </main>
+</body>
+</html>
diff --git a/fixtures/raw-pages/meridian-homes/02-home-118-juniper.html b/fixtures/raw-pages/meridian-homes/02-home-118-juniper.html
new file mode 100644
index 0000000..6904772
--- /dev/null
+++ b/fixtures/raw-pages/meridian-homes/02-home-118-juniper.html
@@ -0,0 +1,32 @@
+<!-- source-url: https://fixtures.meridianhomes.example/communities/cedar-bend/homes/MH-1118 -->
+<!DOCTYPE html>
+<html lang="en">
+<head><title>118 Juniper Draw | Cedar Bend | Meridian Homes</title></head>
+<body>
+ <!-- SYNTHETIC FIXTURE — demonstration data for a fictional builder. -->
+ <main class="inventory-home" data-inventory-id="MH-1118" data-community-id="MH-CB">
+ <h1 class="home-address">118 Juniper Draw</h1>
+ <div class="home-location">
+ <span class="city">Leander</span>, <span class="state">TX</span> <span class="zip">78641</span>
+ </div>
+ <div class="price-block">
+ <span class="price">$462,990</span>
+ </div>
+ <ul class="home-specs">
+ <li class="beds">4 Beds</li>
+ <li class="baths">3 Baths</li>
+ <li class="sqft">2,415 sq ft</li>
+ <li class="stories">2 Story</li>
+ <li class="garage">2-Car Garage</li>
+ </ul>
+ <div class="status-block">
+ <span class="construction-status">Move-In Ready</span>
+ </div>
+ <div class="plan-block">
+ <span class="plan-name">The Juniper</span>
+ <span class="lot-number">Lot 118</span>
+ </div>
+ <div class="geo" data-lat="30.579100" data-lon="-97.851900"></div>
+ </main>
+</body>
+</html>
diff --git a/fixtures/raw-pages/meridian-homes/03-home-124-silverleaf.html b/fixtures/raw-pages/meridian-homes/03-home-124-silverleaf.html
new file mode 100644
index 0000000..90362b5
--- /dev/null
+++ b/fixtures/raw-pages/meridian-homes/03-home-124-silverleaf.html
@@ -0,0 +1,35 @@
+<!-- source-url: https://fixtures.meridianhomes.example/communities/cedar-bend/homes/MH-1124 -->
+<!DOCTYPE html>
+<html lang="en">
+<head><title>124 Silverleaf Pass | Cedar Bend | Meridian Homes</title></head>
+<body>
+ <!-- SYNTHETIC FIXTURE — demonstration data for a fictional builder.
+ This page deliberately omits a price: the extractor must emit
+ price.value = null, never a guess. -->
+ <main class="inventory-home" data-inventory-id="MH-1124" data-community-id="MH-CB">
+ <h1 class="home-address">124 Silverleaf Pass</h1>
+ <div class="home-location">
+ <span class="city">Leander</span>, <span class="state">TX</span> <span class="zip">78641</span>
+ </div>
+ <div class="price-block">
+ <span class="price-contact">Contact for pricing</span>
+ </div>
+ <ul class="home-specs">
+ <li class="beds">5 Beds</li>
+ <li class="baths">4 Baths</li>
+ <li class="sqft">3,102 sq ft</li>
+ <li class="stories">2 Story</li>
+ <li class="garage">3-Car Garage</li>
+ </ul>
+ <div class="status-block">
+ <span class="construction-status">Under Construction</span>
+ <span class="est-completion">Est. Completion: November 2026</span>
+ </div>
+ <div class="plan-block">
+ <span class="plan-name">The Silverleaf</span>
+ <span class="lot-number">Lot 124</span>
+ </div>
+ <div class="geo" data-lat="30.580200" data-lon="-97.850400"></div>
+ </main>
+</body>
+</html>
diff --git a/fixtures/raw-pages/meridian-homes/04-home-131-brackens.html b/fixtures/raw-pages/meridian-homes/04-home-131-brackens.html
new file mode 100644
index 0000000..2fcfb25
--- /dev/null
+++ b/fixtures/raw-pages/meridian-homes/04-home-131-brackens.html
@@ -0,0 +1,34 @@
+<!-- source-url: https://fixtures.meridianhomes.example/communities/cedar-bend/homes/MH-1131 -->
+<!DOCTYPE html>
+<html lang="en">
+<head><title>131 Brackens Crossing | Cedar Bend | Meridian Homes</title></head>
+<body>
+ <!-- SYNTHETIC FIXTURE — demonstration data for a fictional builder. -->
+ <main class="inventory-home" data-inventory-id="MH-1131" data-community-id="MH-CB">
+ <h1 class="home-address">131 Brackens Crossing</h1>
+ <div class="home-location">
+ <span class="city">Leander</span>, <span class="state">TX</span> <span class="zip">78641</span>
+ </div>
+ <div class="price-block">
+ <span class="price">$389,990</span>
+ <span class="previous-price">Was $405,990</span>
+ </div>
+ <ul class="home-specs">
+ <li class="beds">3 Beds</li>
+ <li class="baths">2.5 Baths</li>
+ <li class="sqft">1,988 sq ft</li>
+ <li class="stories">1 Story</li>
+ <li class="garage">2-Car Garage</li>
+ </ul>
+ <div class="status-block">
+ <span class="construction-status">Under Construction</span>
+ <span class="est-completion">Est. Completion: September 2026</span>
+ </div>
+ <div class="plan-block">
+ <span class="plan-name">The Bracken</span>
+ <span class="lot-number">Lot 131</span>
+ </div>
+ <div class="geo" data-lat="30.577300" data-lon="-97.854800"></div>
+ </main>
+</body>
+</html>
diff --git a/fixtures/raw-pages/meridian-homes/05-incentives.html b/fixtures/raw-pages/meridian-homes/05-incentives.html
new file mode 100644
index 0000000..cb7efdb
--- /dev/null
+++ b/fixtures/raw-pages/meridian-homes/05-incentives.html
@@ -0,0 +1,22 @@
+<!-- source-url: https://fixtures.meridianhomes.example/communities/cedar-bend/incentives -->
+<!DOCTYPE html>
+<html lang="en">
+<head><title>Current Offers | Cedar Bend | Meridian Homes</title></head>
+<body>
+ <!-- SYNTHETIC FIXTURE — demonstration data for a fictional builder. -->
+ <main class="incentives-page" data-community-id="MH-CB">
+ <h1>Current Offers at Cedar Bend</h1>
+ <section class="incentive">
+ <h2 class="incentive-title">$10,000 Closing Cost Credit</h2>
+ <p class="incentive-description">Receive up to $10,000 toward closing costs when using an approved lender on select move-in-ready homes.</p>
+ <span class="incentive-value">$10,000</span>
+ <span class="incentive-expires">Expires: 2026-09-30</span>
+ </section>
+ <section class="incentive">
+ <h2 class="incentive-title">Rate Buydown Available</h2>
+ <p class="incentive-description">Ask about our 2-1 interest-rate buydown program on under-construction homes.</p>
+ <!-- No expiration published — extractor must emit null, publish maps to "expiration not provided". -->
+ </section>
+ </main>
+</body>
+</html>
← d635803 shared utilities (canonicalKey/geo/queue) + zod schemas + va
·
back to Homesonspec
·
worker pipeline (fetch/extract/validate/publish) + CLI + syn 75e00bb →