[object Object]

← 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

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 →