[object Object]

← back to Homesonspec

collectors: add Meritage Homes adapter (Sitecore Discover national JSON feed)

aa57e14f96e03bae67f13509efdacbb024445875 · 2026-07-28 15:54:57 -0700 · Steve Abrams

Reverse-engineered the Meritage inventory API: a single paginated Sitecore
Discover POST (discover.sitecorecloud.io/discover/v2/173266879, content_grid
widget) returns the whole company inventory (~2529 homes, 12 states) with
offset/limit pagination, no auth header required. Adapter emits community-then-
home records per QMI; facts-only (image_urls dropped). Test run (PAGE_LIMIT=15):
1488 homes, 100% price/beds/baths/sqft/zip, 96% lat/lon, 1488 distinct streets.

Added LiveFetcher.postJson() so the POST feed still rides the same rails as GET
(honest UA, robots.txt, per-source rate limit, stop-on-403/401/429). Wired into
the workers CLI + package.json. Source left inactive/PAUSED pending activation.

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

Files touched

Diff

commit aa57e14f96e03bae67f13509efdacbb024445875
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 15:54:57 2026 -0700

    collectors: add Meritage Homes adapter (Sitecore Discover national JSON feed)
    
    Reverse-engineered the Meritage inventory API: a single paginated Sitecore
    Discover POST (discover.sitecorecloud.io/discover/v2/173266879, content_grid
    widget) returns the whole company inventory (~2529 homes, 12 states) with
    offset/limit pagination, no auth header required. Adapter emits community-then-
    home records per QMI; facts-only (image_urls dropped). Test run (PAGE_LIMIT=15):
    1488 homes, 100% price/beds/baths/sqft/zip, 96% lat/lon, 1488 distinct streets.
    
    Added LiveFetcher.postJson() so the POST feed still rides the same rails as GET
    (honest UA, robots.txt, per-source rate limit, stop-on-403/401/429). Wired into
    the workers CLI + package.json. Source left inactive/PAUSED pending activation.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/workers/package.json               |   4 +-
 apps/workers/src/cli.ts                 |   4 +
 collectors/common/src/live-fetch.ts     |  50 +++++
 collectors/meritage-homes/package.json  |  15 ++
 collectors/meritage-homes/src/index.ts  | 323 ++++++++++++++++++++++++++++++++
 collectors/meritage-homes/tsconfig.json |   1 +
 pnpm-lock.yaml                          |  28 +++
 7 files changed, 424 insertions(+), 1 deletion(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index 22dd9b3f..9ccb0fbb 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -29,7 +29,9 @@
     "@homesonspec/collector-taylor-morrison": "workspace:*",
     "@homesonspec/collector-discovery": "workspace:*",
     "@homesonspec/collector-david-weekley": "workspace:*",
-    "@homesonspec/collector-ashton-woods": "workspace:*"
+    "@homesonspec/collector-ashton-woods": "workspace:*",
+    "@homesonspec/collector-dream-finders": "workspace:*",
+    "@homesonspec/collector-meritage-homes": "workspace:*"
   },
   "devDependencies": {
     "tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index b3fd471e..e5541380 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -10,6 +10,8 @@ import { taylorMorrisonAdapter } from "@homesonspec/collector-taylor-morrison";
 import { discoveryAdapter } from "@homesonspec/collector-discovery";
 import { davidWeekleyAdapter } from "@homesonspec/collector-david-weekley";
 import { ashtonWoodsAdapter } from "@homesonspec/collector-ashton-woods";
+import { dreamFindersAdapter } from "@homesonspec/collector-dream-finders";
+import { meritageHomesAdapter } from "@homesonspec/collector-meritage-homes";
 import { runPipeline } from "./pipeline";
 import { recordSourceRun } from "./verify";
 
@@ -29,6 +31,8 @@ const ADAPTERS = {
   [discoveryAdapter.key]: discoveryAdapter,
   [davidWeekleyAdapter.key]: davidWeekleyAdapter,
   [ashtonWoodsAdapter.key]: ashtonWoodsAdapter,
+  [dreamFindersAdapter.key]: dreamFindersAdapter,
+  [meritageHomesAdapter.key]: meritageHomesAdapter,
 };
 
 async function main() {
diff --git a/collectors/common/src/live-fetch.ts b/collectors/common/src/live-fetch.ts
index e153bb8b..337ab4e0 100644
--- a/collectors/common/src/live-fetch.ts
+++ b/collectors/common/src/live-fetch.ts
@@ -173,6 +173,56 @@ export class LiveFetcher {
       contentHash: sha256(body),
     };
   }
+
+  /**
+   * POST a JSON body and return the JSON response as a RawPage. Same rails as
+   * fetch(): honest UA (never disguised), robots.txt enforcement, per-source
+   * rate-limit, and STOP-on-403/401/429 (bot protection is never circumvented).
+   * No cookies, no login, no media. Added for JSON-search-API builders whose
+   * inventory feed is a POST (e.g. Sitecore Discover) rather than a GET —
+   * the only extra affordance is a JSON body; no bypass of any protection.
+   */
+  async postJson(url: string, payload: unknown): Promise<RawPage> {
+    const parsed = new URL(url);
+    if (parsed.pathname !== "/robots.txt") {
+      const rules = await loadRobots(parsed.origin);
+      if (!robotsAllows(rules, parsed.pathname + parsed.search)) {
+        throw new DisallowedError(url);
+      }
+    }
+
+    const wait = this.lastRequestAt + this.minIntervalMs - Date.now();
+    if (wait > 0) await sleep(wait);
+    this.lastRequestAt = Date.now();
+
+    const response = await fetch(url, {
+      method: "POST",
+      headers: {
+        "User-Agent": USER_AGENT,
+        "Content-Type": "application/json",
+        Accept: "application/json,*/*;q=0.8",
+      },
+      body: JSON.stringify(payload),
+      redirect: "follow",
+      signal: AbortSignal.timeout(25_000),
+    });
+
+    if (response.status === 401 || response.status === 403 || response.status === 429) {
+      throw new BlockedError(url, response.status);
+    }
+    if (!response.ok) {
+      throw new Error(`POST ${url} → HTTP ${response.status}`);
+    }
+
+    const body = Buffer.from(await response.arrayBuffer());
+    return {
+      url,
+      retrievedAt: new Date().toISOString(),
+      contentType: response.headers.get("content-type") ?? "application/json",
+      body,
+      contentHash: sha256(body),
+    };
+  }
 }
 
 export class BlockedError extends Error {
diff --git a/collectors/meritage-homes/package.json b/collectors/meritage-homes/package.json
new file mode 100644
index 00000000..2eac025f
--- /dev/null
+++ b/collectors/meritage-homes/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-meritage-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/meritage-homes/src/index.ts b/collectors/meritage-homes/src/index.ts
new file mode 100644
index 00000000..aecf5d35
--- /dev/null
+++ b/collectors/meritage-homes/src/index.ts
@@ -0,0 +1,323 @@
+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";
+
+/**
+ * Meritage Homes adapter — JSON-API source (recon 2026-07-28).
+ *
+ * meritagehomes.com is a Next.js + Sitecore-headless SPA. Metro/community pages
+ * are server-rendered chrome only; homes are client-injected via Sitecore Search
+ * ("Discover"). The inventory feed is a single POST that returns a NATIONAL,
+ * paginated home list:
+ *
+ *   POST https://discover.sitecorecloud.io/discover/v2/173266879
+ *   Content-Type: application/json
+ *   body: { context, widget: { items: [{ rfk_id:"rfkid_503",
+ *            search:{ content:{}, facet:{all:true}, offset, limit,
+ *              sort:{value:[{name:"title_descending"}]},
+ *              filter:{ type:"and", filters:[ {status ∈ [Available,Inventory]}
+ *                        (+ optional {state}) ] } },
+ *            entity:"home", sources:["xm_cloud_public_website"] }] } }
+ *
+ * The response widget (type:"content_grid", entity:"home") carries
+ *   { total_item, limit, offset, content:[ home… ] }
+ * where each home has address/city/state/zipcode/price/bedrooms/full_bathrooms/
+ * half_bathrooms/sqft/stories/garages/location("lat, lon")/floorplan_name/
+ * community_sheet_name/construction_stage/completion_estimated/url/id.
+ *
+ * Verified: the `authorization` header is OPTIONAL (200 without it); the customer
+ * id (173266879) in the path is the only identifier. Removing the geo/metro
+ * filters yields the whole company inventory (total_item ≈ 2529 across 11 states).
+ * robots.txt allows / (meritagehomes.com); the discover host serves no robots.txt
+ * (fail-open). Facts-only: image_urls are intentionally dropped (mediaRights=NONE).
+ *
+ * Batch control:  MERITAGE_PAGE_LIMIT  (pages of `limit` homes, default 10)
+ * Optional state: MERITAGE_STATE       (2-letter, e.g. TX — server-side `state` filter)
+ */
+
+const DISCOVER_URL = "https://discover.sitecorecloud.io/discover/v2/173266879";
+const BUILDER_SLUG = "meritage-homes";
+const PAGE_SIZE = 100; // server max per content_grid page
+const PAGE_LIMIT = Number(process.env.MERITAGE_PAGE_LIMIT ?? 10);
+const STATE_FILTER = (process.env.MERITAGE_STATE ?? "").trim().toUpperCase() || null;
+// The synthesized normalized-page URL carries the offset so snapshots stay distinct.
+const PAGE_URL = (offset: number) => `${DISCOVER_URL}#content_grid&offset=${offset}`;
+
+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. Never guesses; 0 / negative / NaN → null.
+const posNum = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+  return Number.isFinite(n) && n > 0 ? n : null;
+};
+// A non-negative number (baths/garages may legitimately be 0-ish), or null.
+const nonNegNum = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+  return Number.isFinite(n) && n >= 0 ? n : null;
+};
+const str = (v: unknown): string | null => {
+  if (v == null) return null;
+  const s = String(v).trim();
+  return s ? s : null;
+};
+
+interface DiscoverHome {
+  id?: string;
+  address?: string;
+  city?: string;
+  state?: string;
+  zipcode?: string | number;
+  price?: number | string;
+  bedrooms?: number;
+  full_bathrooms?: number;
+  half_bathrooms?: number;
+  sqft?: number;
+  stories?: number;
+  garages?: number;
+  location?: string; // "lat, lon"
+  floorplan_name?: string;
+  community_sheet_name?: string;
+  community_id?: string;
+  status?: string;
+  construction_stage?: string;
+  completion_estimated?: number; // unix seconds
+  url?: string;
+}
+
+/** Build the content_grid POST body for a given offset. */
+function buildBody(offset: number): unknown {
+  const filters: unknown[] = [
+    { type: "anyOf", name: "status", values: ["Available", "Inventory"] },
+  ];
+  if (STATE_FILTER) filters.push({ type: "anyOf", name: "state", values: [STATE_FILTER] });
+  return {
+    context: { page: { uri: "/search" } },
+    widget: {
+      items: [
+        {
+          rfk_id: "rfkid_503",
+          search: {
+            content: {},
+            facet: { all: false },
+            offset,
+            limit: PAGE_SIZE,
+            sort: { value: [{ name: "title_descending" }] },
+            filter: { type: "and", filters },
+          },
+          entity: "home",
+          sources: ["xm_cloud_public_website"],
+        },
+      ],
+    },
+  };
+}
+
+/** Pull the content_grid widget (entity:"home") out of a Discover response body. */
+function gridWidget(body: string): { total: number; offset: number; content: DiscoverHome[] } | null {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(body);
+  } catch {
+    return null;
+  }
+  const widgets = (parsed as { widgets?: unknown[] })?.widgets;
+  if (!Array.isArray(widgets)) return null;
+  const w = widgets.find(
+    (x) => (x as { type?: string; entity?: string })?.type === "content_grid" &&
+      (x as { entity?: string })?.entity === "home",
+  ) as { total_item?: number; offset?: number; content?: DiscoverHome[] } | undefined;
+  if (!w) return null;
+  return {
+    total: Number(w.total_item ?? 0),
+    offset: Number(w.offset ?? 0),
+    content: Array.isArray(w.content) ? w.content : [],
+  };
+}
+
+/** "33.507105, -112.440173" → { lat, lon } (US lon is negative — signs preserved). */
+function parseLocation(loc: unknown): { lat: number | null; lon: number | null } {
+  const s = str(loc);
+  if (!s) return { lat: null, lon: null };
+  const m = s.match(/(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/);
+  if (!m) return { lat: null, lon: null };
+  const lat = Number(m[1]);
+  const lon = Number(m[2]);
+  return {
+    lat: Number.isFinite(lat) && lat !== 0 ? lat : null,
+    lon: Number.isFinite(lon) && lon !== 0 ? lon : null,
+  };
+}
+
+/** Meritage stages → our construction-status enum. Inventory homes ("QMI") that
+ *  report "Construction Complete" are move-in ready; earlier stages are under
+ *  construction. Unrecognized/blank → null (never guessed). */
+function constructionStatus(stage: unknown): "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+  const s = (str(stage) ?? "").toLowerCase();
+  if (!s) return null;
+  // "Construction Complete" / "Warranty Handoff" = finished & ready; all other
+  // named stages (slab, framing, cabinets, countertops, flooring…) are in progress.
+  if (s.includes("construction complete") || s.includes("warranty")) return "MOVE_IN_READY";
+  return "UNDER_CONSTRUCTION";
+}
+
+const zip5 = (v: unknown): string | null => {
+  const s = str(v);
+  if (!s) return null;
+  const m = s.match(/\b(\d{5})\b/);
+  return m ? m[1]! : null;
+};
+
+/** completion_estimated is unix time, but the feed is inconsistent: most rows are
+ *  SECONDS (10 digits) while some are MILLISECONDS (13 digits). Normalize by
+ *  magnitude, then clamp to a sane window so a bogus timestamp never reaches the
+ *  publisher's `new Date()` (which overflowed to year 57626 on a raw ms value). */
+const isoFromUnix = (v: unknown): string | null => {
+  if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return null;
+  const ms = v > 1e12 ? v : v * 1000; // ≥1e12 ≈ ms since epoch; otherwise seconds
+  const d = new Date(ms);
+  if (Number.isNaN(d.getTime())) return null;
+  const year = d.getUTCFullYear();
+  if (year < 2000 || year > 2100) return null; // out of plausible range → don't guess
+  return d.toISOString().slice(0, 10);
+};
+
+export const meritageHomesAdapter: SourceAdapter = {
+  key: "meritage-homes-site",
+  version: "1.0.0",
+
+  async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
+    if (ctx.mode === "fixture") {
+      yield* fetchFixtures(ctx);
+      return;
+    }
+    const fetcher = new LiveFetcher(ctx.registry);
+    let offset = 0;
+    for (let pageNo = 0; pageNo < Math.max(1, PAGE_LIMIT); pageNo++) {
+      let page: RawPage;
+      try {
+        page = await fetcher.postJson(PAGE_URL(offset), buildBody(offset));
+      } catch (error) {
+        console.warn(`  meritage offset=${offset}: ${error instanceof Error ? error.message : String(error)}`);
+        return; // a block/403 stops collection (source marked degraded upstream)
+      }
+      const grid = gridWidget(page.body.toString("utf8"));
+      yield page;
+      const got = grid?.content.length ?? 0;
+      offset += PAGE_SIZE;
+      // Stop when the server returns fewer than a full page or we've covered total_item.
+      if (!grid || got < PAGE_SIZE || (grid.total > 0 && offset >= grid.total)) return;
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const grid = gridWidget(page.body.toString("utf8"));
+      if (!grid) {
+        return { records: [], errors: [{ url: page.url, reason: "no content_grid home widget in Discover response" }] };
+      }
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      for (const h of grid.content) {
+        const state = normalizeStateCode(str(h.state));
+        const city = str(h.city);
+        const zip = zip5(h.zipcode);
+        const address = str(h.address);
+        const community = str(h.community_sheet_name);
+        const plan = str(h.floorplan_name);
+        const { lat, lon } = parseLocation(h.location);
+        const price = posNum(h.price);
+        const beds = posNum(h.bedrooms);
+        const full = nonNegNum(h.full_bathrooms);
+        const half = nonNegNum(h.half_bathrooms);
+        const bathsTotal = full === null ? null : full + (half ?? 0) * 0.5;
+        const sqft = posNum(h.sqft);
+        const stories = posNum(h.stories);
+        const garages = nonNegNum(h.garages);
+        const homeId = str(h.id);
+        const url = str(h.url) ?? page.url;
+        const cStatus = constructionStatus(h.construction_stage);
+        const estCompletion = isoFromUnix(h.completion_estimated);
+
+        if (!address) {
+          errors.push({ url, reason: `home ${homeId ?? "?"} missing address — skipped` });
+          continue;
+        }
+        // The publisher requires an inventory home to hang off a community (FK).
+        // A home the feed leaves community-less can't be published — skip it and
+        // log it honestly rather than stage a record that will crash at publish.
+        if (!community) {
+          errors.push({ url, reason: `home ${address} has no community_sheet_name — cannot attach to a community, skipped` });
+          continue;
+        }
+
+        // Community FIRST — publish creates the FK target the home record needs.
+        records.push({
+          entityType: "community",
+          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: community },
+          fields: {
+            name: fv(community, community, url, "Discover community_sheet_name"),
+            street: fv<string>(null, null, url),
+            city: fv(city, city, url),
+            state: fv(state, str(h.state), url),
+            zip: fv(zip, str(h.zipcode), url),
+            county: fv<string>(null, null, url),
+            metro: fv<string>(null, null, url),
+            lat: fv(lat, null, url),
+            lon: fv(lon, null, url),
+            hoaFeeMonthly: fv<number>(null, null, url),
+            schoolDistrict: fv<string>(null, null, url),
+            ageRestricted: fv<boolean>(null, null, url),
+          },
+        });
+
+        records.push({
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName: community,
+            address,
+            builderInventoryId: homeId ?? undefined,
+            lat: lat ?? undefined,
+            lon: lon ?? undefined,
+            planName: plan ?? undefined,
+          },
+          fields: {
+            street: fv(address, address, url, "Discover home address"),
+            city: fv(city, city, url),
+            state: fv(state, str(h.state), url),
+            zip: fv(zip, str(h.zipcode), url),
+            price: fv(price, price === null ? null : String(h.price), url, price === null ? null : `Discover price ${h.price}`),
+            beds: fv(beds, beds === null ? null : String(h.bedrooms), url),
+            bathsTotal: fv(bathsTotal, bathsTotal === null ? null : String(bathsTotal), url, bathsTotal === null ? null : `${full} full + ${half ?? 0} half`),
+            sqft: fv(sqft, sqft === null ? null : String(h.sqft), url),
+            stories: fv(stories, stories === null ? null : String(h.stories), url),
+            garageSpaces: fv(garages, garages === null ? null : String(h.garages), url),
+            homeType: fv("SINGLE_FAMILY" as const, null, url, "Meritage single-family inventory home"),
+            constructionStatus: fv(cStatus, str(h.construction_stage), url, cStatus === null ? null : `stage: ${str(h.construction_stage)}`),
+            estCompletionDate: fv(estCompletion, estCompletion, url),
+            lotNumber: fv<string>(null, null, url),
+            builderInventoryId: fv(homeId, homeId, url),
+            lat: fv(lat, null, url, lat === null ? null : "Discover location"),
+            lon: fv(lon, null, url, lon === null ? null : "Discover location"),
+            planName: fv(plan, plan, url),
+            // facts-only: image_urls exist in the feed but are intentionally dropped.
+            images: fv<string[]>([], null, url),
+          },
+        });
+      }
+      return { records, errors };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/meritage-homes/tsconfig.json b/collectors/meritage-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/meritage-homes/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 586d2be1..209f25c3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -154,6 +154,9 @@ importers:
       '@homesonspec/collector-dr-horton':
         specifier: workspace:*
         version: link:../../collectors/dr-horton
+      '@homesonspec/collector-dream-finders':
+        specifier: workspace:*
+        version: link:../../collectors/dream-finders
       '@homesonspec/collector-kb-home':
         specifier: workspace:*
         version: link:../../collectors/kb-home
@@ -163,6 +166,9 @@ importers:
       '@homesonspec/collector-meridian-homes':
         specifier: workspace:*
         version: link:../../collectors/meridian-homes
+      '@homesonspec/collector-meritage-homes':
+        specifier: workspace:*
+        version: link:../../collectors/meritage-homes
       '@homesonspec/collector-pulte':
         specifier: workspace:*
         version: link:../../collectors/pulte
@@ -411,6 +417,28 @@ importers:
         specifier: ^4.0.0
         version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
 
+  collectors/meritage-homes:
+    dependencies:
+      '@homesonspec/collectors-common':
+        specifier: workspace:*
+        version: link:../common
+      '@homesonspec/schemas':
+        specifier: workspace:*
+        version: link:../../packages/schemas
+      '@homesonspec/shared':
+        specifier: workspace:*
+        version: link:../../packages/shared
+    devDependencies:
+      '@types/node':
+        specifier: ^22.10.5
+        version: 22.20.1
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+      vitest:
+        specifier: ^4.0.0
+        version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+
   collectors/pulte:
     dependencies:
       '@homesonspec/collectors-common':

← c4b894e7 storefront: enable home images (BUILDER_IMAGES_ENABLED=1) —  ·  back to Homesonspec  ·  build-loop: add Meritage (national Sitecore-Discover feed) t 6515c4d4 →