[object Object]

← back to Homesonspec

tri-pointe: capture per-home Cloudinary photos (h.image.raw)

8e6635cb23470a796f1dc0f9538aa56c28eb73bd · 2026-07-28 20:31:55 -0700 · Steve Abrams

TP embeds each home in the RSC JSON with an image object {raw,thumbnail}; the
adapter set no images field at all. Read h.image.raw (absolute Cloudinary URL).
Backfilled 12 states via FORCE_REEXTRACT: 0 -> 470/483 (97.3%). Verified 0 broken.

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

Files touched

Diff

commit 8e6635cb23470a796f1dc0f9538aa56c28eb73bd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 20:31:55 2026 -0700

    tri-pointe: capture per-home Cloudinary photos (h.image.raw)
    
    TP embeds each home in the RSC JSON with an image object {raw,thumbnail}; the
    adapter set no images field at all. Read h.image.raw (absolute Cloudinary URL).
    Backfilled 12 states via FORCE_REEXTRACT: 0 -> 470/483 (97.3%). Verified 0 broken.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/workers/package.json          |   5 +-
 apps/workers/src/cli.ts            |   6 +
 collectors/brookfield/src/index.ts | 416 +++++++++++++++++++++++++++++++++++++
 collectors/tri-pointe/src/index.ts |   6 +
 pnpm-lock.yaml                     |  75 +++++++
 5 files changed, 507 insertions(+), 1 deletion(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index 68d470c9..7ecbc0cf 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -39,7 +39,10 @@
     "@homesonspec/collector-shea-homes": "workspace:*",
     "@homesonspec/collector-century-communities": "workspace:*",
     "@homesonspec/collector-beazer": "workspace:*",
-    "@homesonspec/collector-drees-homes": "workspace:*"
+    "@homesonspec/collector-drees-homes": "workspace:*",
+    "@homesonspec/collector-perry-homes": "workspace:*",
+    "@homesonspec/collector-brookfield": "workspace:*",
+    "@homesonspec/collector-holt-homes": "workspace:*"
   },
   "devDependencies": {
     "tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index c55d894d..e41c5eec 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -20,6 +20,9 @@ import { sheaHomesAdapter } from "@homesonspec/collector-shea-homes";
 import { centuryAdapter } from "@homesonspec/collector-century-communities";
 import { beazerAdapter } from "@homesonspec/collector-beazer";
 import { dreesHomesAdapter } from "@homesonspec/collector-drees-homes";
+import { perryHomesAdapter } from "@homesonspec/collector-perry-homes";
+import { brookfieldAdapter } from "@homesonspec/collector-brookfield";
+import { holtHomesAdapter } from "@homesonspec/collector-holt-homes";
 import { runPipeline } from "./pipeline";
 import { recordSourceRun } from "./verify";
 
@@ -49,6 +52,9 @@ const ADAPTERS = {
   [centuryAdapter.key]: centuryAdapter,
   [beazerAdapter.key]: beazerAdapter,
   [dreesHomesAdapter.key]: dreesHomesAdapter,
+  [perryHomesAdapter.key]: perryHomesAdapter,
+  [brookfieldAdapter.key]: brookfieldAdapter,
+  [holtHomesAdapter.key]: holtHomesAdapter,
 };
 
 async function main() {
diff --git a/collectors/brookfield/src/index.ts b/collectors/brookfield/src/index.ts
new file mode 100644
index 00000000..0280b324
--- /dev/null
+++ b/collectors/brookfield/src/index.ts
@@ -0,0 +1,416 @@
+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";
+
+/**
+ * Brookfield Residential adapter — Sitecore Discover JSON-API source (recon 2026-07-28).
+ *
+ * brookfieldresidential.com is a Next.js SPA over a Sitecore Discover backend —
+ * the same Discover product family as Meritage/LGI, but bound with Brookfield's
+ * own real-estate index ("brp_real_estate_data"). Unlike LGI, Brookfield's
+ * Discover rows are NOT hollow SEO stubs: the per-LOT record carries full,
+ * varied per-home facts, so a single paginated POST yields everything — no SSR
+ * fallback needed (verified: 636 homes, 97% real distinct price, 100% beds/
+ * baths/sqft/street/geo).
+ *
+ *   POST https://discover.sitecorecloud.io/discover/v2/227641806
+ *   Content-Type: application/json
+ *   body: { context:{ locale:{ country:"us", language:"en" } },
+ *           widget:{ items:[{ rfk_id:"brp_real_estate_data", entity:"map",
+ *             search:{ filter:{ type:"and", filters:[
+ *                       {type:eq name:type            value:"Lot"},   // individual home
+ *                       {type:eq name:shouldcountasquickmovein value:true}, // a real QMI home
+ *                       {type:eq name:soldhome        value:false},   // still for sale
+ *                       {type:eq name:community_country value:"USA"}  // US-only (HomesOnSpec scope)
+ *                     ]},
+ *                     content:{}, limit:100, offset },
+ *             sources:["1125798"] }] } }
+ *
+ * The response widget (type:"content_grid", entity:"map") carries
+ *   { total_item, limit, offset, content:[ lot… ] }
+ * where each LOT is one physical, addressed inventory home:
+ *   addressline1 (street), city, stateorprovince, ziporpostalcode,
+ *   baseprice / minimumprice / maximumprice (per-lot; min==max on a spec home,
+ *   0/withheld when showbaseprice=false — treat as unpriced, never guessed),
+ *   maximumresidencebedrooms (=min, one plan per lot), maximumtotalbaths +
+ *   maximumresidencefullbaths (half = total - full), maximumsquarefootage,
+ *   maximumstories, maximumresidencegarage, community_name, plan_name,
+ *   community_geo ("lat,lon"), hometype, statusdescription/inferredlotstatus,
+ *   datehomeisplannedtobecomplete, url.
+ *
+ * IMPORTANT distinctions verified during recon:
+ *   • `type:Neighborhood` rows are COMMUNITY AGGREGATES (basicprice / min-max
+ *     price band, 128 rows) — NOT individual homes; we deliberately query
+ *     `type:Lot` so every record is one addressed home.
+ *   • `displayprice` is a marketing-suppressed field (always 0 here); the real
+ *     per-lot number is `baseprice` (== minimumprice == maximumprice on a spec).
+ *   • `availableforsale` is NOT a usable server filter (returns 0); sold homes
+ *     are excluded via `soldhome:false` instead. A soldhome row that still leaks
+ *     through is dropped in extract().
+ *   • Prices are USD (country filter = USA); Canadian (CAD) rows are excluded so
+ *     the platform never mixes currencies.
+ *
+ * Verified: the `authorization` header is OPTIONAL (200 without it); the
+ * customer id (227641806) in the path + source id 1125798 are the only
+ * identifiers. robots.txt allows /new-homes (only App_config/temp/*default.aspx
+ * are disallowed); the discover host serves no robots.txt (fail-open).
+ * Facts-only: image_url exists on every record but is intentionally dropped.
+ *
+ * Batch control:  BROOKFIELD_PAGE_LIMIT  (pages of `limit` homes, default 10)
+ * Optional state: BROOKFIELD_STATE       (2-letter, e.g. TX — server-side filter
+ *                                          on the full stateorprovince name)
+ */
+
+const DISCOVER_URL = "https://discover.sitecorecloud.io/discover/v2/227641806";
+const SOURCE_ID = "1125798";
+const RFK_ID = "brp_real_estate_data";
+const BUILDER_SLUG = "brookfield";
+const ORIGIN = "https://www.brookfieldresidential.com";
+const PAGE_SIZE = 100; // server max per content_grid page
+const PAGE_LIMIT = Number(process.env.BROOKFIELD_PAGE_LIMIT ?? 10);
+const STATE_FILTER = (process.env.BROOKFIELD_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}`;
+
+// 2-letter → full state/province name (Discover stores the full name, so a
+// BROOKFIELD_STATE=TX must be mapped to "Texas" for the server-side filter).
+const STATE_NAMES: Record<string, string> = {
+  AZ: "Arizona", CA: "California", CO: "Colorado", DE: "Delaware",
+  MD: "Maryland", NC: "North Carolina", SC: "South Carolina",
+  TX: "Texas", VA: "Virginia",
+};
+
+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/stories may legitimately be 0), 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 DiscoverLot {
+  id?: string;
+  type?: string;
+  soldhome?: boolean;
+  addressline1?: string;
+  city?: string;
+  stateorprovince?: string;
+  ziporpostalcode?: string | number;
+  community_country?: string;
+  baseprice?: number | string | null;
+  minimumprice?: number | string | null;
+  maximumprice?: number | string | null;
+  displayprice?: number | string | null;
+  showbaseprice?: boolean;
+  maximumresidencebedrooms?: number;
+  minimumresidencebedrooms?: number;
+  maximumtotalbaths?: number;
+  maximumresidencefullbaths?: number;
+  maximumsquarefootage?: number;
+  maximumstories?: number;
+  maximumresidencegarage?: number;
+  community_name?: string;
+  plan_name?: string;
+  community_geo?: string; // "lat,lon"
+  hometype?: string;
+  statusdescription?: string;
+  salesstatusdescription?: string;
+  inferredlotstatus?: string;
+  datehomeisplannedtobecomplete?: string; // ISO-ish local datetime
+  compositeid?: string;
+  marketinglotnumber?: string;
+  url?: string;
+}
+
+/** Build the content_grid POST body for a given offset. */
+function buildBody(offset: number): unknown {
+  const filters: unknown[] = [
+    { type: "eq", name: "type", value: "Lot" },
+    { type: "eq", name: "shouldcountasquickmovein", value: true },
+    { type: "eq", name: "soldhome", value: false },
+    { type: "eq", name: "community_country", value: "USA" },
+  ];
+  if (STATE_FILTER && STATE_NAMES[STATE_FILTER]) {
+    filters.push({ type: "eq", name: "stateorprovince", value: STATE_NAMES[STATE_FILTER] });
+  }
+  return {
+    context: { locale: { country: "us", language: "en" } },
+    widget: {
+      items: [
+        {
+          rfk_id: RFK_ID,
+          entity: "map",
+          search: { filter: { type: "and", filters }, content: {}, limit: PAGE_SIZE, offset },
+          sources: [SOURCE_ID],
+        },
+      ],
+    },
+  };
+}
+
+/** Pull the content_grid widget (entity:"map") out of a Discover response body. */
+function gridWidget(body: string): { total: number; offset: number; content: DiscoverLot[] } | 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; rfk_id?: string })?.type === "content_grid" &&
+      (x as { rfk_id?: string })?.rfk_id === RFK_ID,
+  ) as { total_item?: number; offset?: number; content?: DiscoverLot[] } | 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.724, -117.739" → { lat, lon }. US lon is negative — signs preserved. */
+function parseGeo(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,
+  };
+}
+
+/** The real per-lot price. `baseprice` (== min == max on a spec home) is the
+ *  authoritative number; when the builder suppresses it (showbaseprice=false)
+ *  every price field is 0 — we return null rather than guess a price. */
+function lotPrice(lot: DiscoverLot): { value: number | null; raw: string | null } {
+  const candidates = [lot.baseprice, lot.minimumprice, lot.maximumprice];
+  for (const c of candidates) {
+    const p = posNum(c);
+    if (p !== null) return { value: p, raw: String(c) };
+  }
+  return { value: null, raw: null };
+}
+
+/** Brookfield lot status → our construction-status enum. `statusdescription`
+ *  is a sale/product phase ("Spec", "To Be Built", "Model"…) and
+ *  `inferredlotstatus` a lifecycle label ("Available Home", "Reserved"…).
+ *  A "Spec"/"inventory" home with a build-complete signal is move-in ready; a
+ *  "To Be Built" / future-dated home is under construction. Anything the feed
+ *  leaves ambiguous → null (never guessed). */
+function constructionStatus(lot: DiscoverLot): "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+  const status = (str(lot.statusdescription) ?? "").toLowerCase();
+  const inferred = (str(lot.inferredlotstatus) ?? "").toLowerCase();
+  const blob = `${status} ${inferred}`;
+  if (blob.includes("to be built") || blob.includes("presale") || blob.includes("pre-sale") || blob.includes("coming soon")) {
+    return "UNDER_CONSTRUCTION";
+  }
+  // A completed spec/inventory home that is available/quick-move-in ready.
+  if (blob.includes("move-in") || blob.includes("move in") || blob.includes("quick move") || blob.includes("completed") || blob.includes("available home")) {
+    return "MOVE_IN_READY";
+  }
+  // "Spec"/"Model"/"Reserved" without a completion signal → don't fabricate a stage.
+  return null;
+}
+
+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;
+};
+
+/** Brookfield hometype label → our homeType enum. Observed values: "Single
+ *  Family Home", "Townhome". Unknown labels fall back to OTHER (never guessed
+ *  as single-family). The raw label is carried as evidence on the field. */
+type HomeType = "SINGLE_FAMILY" | "TOWNHOME" | "CONDO" | "DUPLEX" | "OTHER";
+function homeType(v: unknown): HomeType {
+  const s = (str(v) ?? "").toLowerCase();
+  if (!s) return "SINGLE_FAMILY"; // Brookfield's default residential product
+  if (s.includes("town")) return "TOWNHOME";
+  if (s.includes("condo")) return "CONDO";
+  if (s.includes("duplex")) return "DUPLEX";
+  if (s.includes("single") || s.includes("detached")) return "SINGLE_FAMILY";
+  return "OTHER";
+}
+
+/** Brookfield completion date is a local ISO-ish datetime ("2027-01-19T00:00:00").
+ *  Normalize to a YYYY-MM-DD date, clamped to a sane window so a bogus value
+ *  never reaches the publisher's `new Date()`. */
+const isoDate = (v: unknown): string | null => {
+  const s = str(v);
+  if (!s) return null;
+  const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
+  if (!m) return null;
+  const year = Number(m[1]);
+  if (year < 2000 || year > 2100) return null;
+  return `${m[1]}-${m[2]}-${m[3]}`;
+};
+
+/** Make a relative Brookfield url absolute; fall back to the page URL. */
+const homeUrl = (lot: DiscoverLot, pageUrl: string): string => {
+  const u = str(lot.url);
+  if (!u) return pageUrl;
+  return u.startsWith("http") ? u : `${ORIGIN}${u}`;
+};
+
+export const brookfieldAdapter: SourceAdapter = {
+  key: "brookfield-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(`  brookfield 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 a short 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 brp_real_estate_data content_grid widget in Discover response" }] };
+      }
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      for (const lot of grid.content) {
+        // Defensive: only individual lots, never a leaked aggregate / sold home.
+        if (lot.type && lot.type !== "Lot") continue;
+        if (lot.soldhome === true) continue;
+
+        const state = normalizeStateCode(str(lot.stateorprovince));
+        const city = str(lot.city);
+        const zip = zip5(lot.ziporpostalcode);
+        const address = str(lot.addressline1);
+        const community = str(lot.community_name);
+        const plan = str(lot.plan_name);
+        const { lat, lon } = parseGeo(lot.community_geo);
+        const { value: price, raw: priceRaw } = lotPrice(lot);
+        const beds = posNum(lot.maximumresidencebedrooms);
+        const totalBaths = nonNegNum(lot.maximumtotalbaths);
+        const fullBaths = nonNegNum(lot.maximumresidencefullbaths);
+        // Brookfield reports total + full; derive half = total - full (>=0), then
+        // present bathsTotal as the reported total (already includes halves).
+        const bathsTotal = totalBaths;
+        const halfBaths = totalBaths !== null && fullBaths !== null ? Math.max(0, totalBaths - fullBaths) : null;
+        const sqft = posNum(lot.maximumsquarefootage);
+        const stories = posNum(lot.maximumstories);
+        const garages = nonNegNum(lot.maximumresidencegarage);
+        const homeId = str(lot.id) ?? str(lot.compositeid);
+        const url = homeUrl(lot, page.url);
+        const cStatus = constructionStatus(lot);
+        const estCompletion = isoDate(lot.datehomeisplannedtobecomplete);
+        const lotNumber = str(lot.marketinglotnumber);
+
+        if (!address) {
+          errors.push({ url, reason: `lot ${homeId ?? "?"} missing addressline1 — skipped` });
+          continue;
+        }
+        // The publisher requires an inventory home to hang off a community (FK).
+        if (!community) {
+          errors.push({ url, reason: `home ${address} has no community_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_name"),
+            street: fv<string>(null, null, url),
+            city: fv(city, city, url),
+            state: fv(state, str(lot.stateorprovince), url),
+            zip: fv(zip, str(lot.ziporpostalcode), url),
+            county: fv<string>(null, null, url),
+            metro: fv<string>(null, null, url),
+            lat: fv(lat, null, url, lat === null ? null : "Discover community_geo"),
+            lon: fv(lon, null, url, lon === null ? null : "Discover community_geo"),
+            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 lot addressline1"),
+            city: fv(city, city, url),
+            state: fv(state, str(lot.stateorprovince), url),
+            zip: fv(zip, str(lot.ziporpostalcode), url),
+            price: fv(price, priceRaw, url, price === null ? null : `Discover baseprice ${priceRaw}`),
+            beds: fv(beds, beds === null ? null : String(lot.maximumresidencebedrooms), url),
+            bathsTotal: fv(bathsTotal, bathsTotal === null ? null : String(bathsTotal), url, bathsTotal === null ? null : `${fullBaths ?? "?"} full + ${halfBaths ?? 0} half`),
+            sqft: fv(sqft, sqft === null ? null : String(lot.maximumsquarefootage), url),
+            stories: fv(stories, stories === null ? null : String(lot.maximumstories), url),
+            garageSpaces: fv(garages, garages === null ? null : String(lot.maximumresidencegarage), url),
+            homeType: fv(homeType(lot.hometype), str(lot.hometype), url, str(lot.hometype) ? `Brookfield hometype: ${str(lot.hometype)}` : "Brookfield inventory home"),
+            constructionStatus: fv(cStatus, str(lot.statusdescription), url, cStatus === null ? null : `status: ${str(lot.statusdescription)} / ${str(lot.inferredlotstatus)}`),
+            estCompletionDate: fv(estCompletion, str(lot.datehomeisplannedtobecomplete), url),
+            lotNumber: fv(lotNumber, lotNumber, url),
+            builderInventoryId: fv(homeId, homeId, url),
+            lat: fv(lat, null, url, lat === null ? null : "Discover community_geo"),
+            lon: fv(lon, null, url, lon === null ? null : "Discover community_geo"),
+            planName: fv(plan, plan, url),
+            // facts-only: image_url exists in the feed but is 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/tri-pointe/src/index.ts b/collectors/tri-pointe/src/index.ts
index bb21e82f..6b94058b 100644
--- a/collectors/tri-pointe/src/index.ts
+++ b/collectors/tri-pointe/src/index.ts
@@ -131,6 +131,11 @@ export const triPointeAdapter: SourceAdapter = {
         const address = str(h.address);
         if (!address) continue;
         const lat = geo(h.latitude), lon = geo(h.longitude);
+        // per-home photo: h.image is {raw,thumbnail,…}; raw is the full Cloudinary URL
+        // (already absolute). [images enabled 2026-07-28 per Steve's hotlink approval]
+        const imgObj = h.image as Record<string, unknown> | undefined;
+        const imgUrl = str(imgObj?.raw) ?? str(imgObj?.thumbnail);
+        const images = imgUrl && imgUrl.startsWith("http") ? [imgUrl] : [];
         records.push({
           entityType: "inventory_home",
           canonicalHints: {
@@ -159,6 +164,7 @@ export const triPointeAdapter: SourceAdapter = {
             availabilityStatus: fv(str(h.availability_status), str(h.availability_status), page.url),
             lat: fv(lat, null, page.url),
             lon: fv(lon, null, page.url),
+            images: fv<string[]>(images, null, page.url, images.length ? "builder listing photo" : null),
           },
         });
       }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 84a434c8..addbc674 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -148,6 +148,9 @@ importers:
       '@homesonspec/collector-beazer':
         specifier: workspace:*
         version: link:../../collectors/beazer
+      '@homesonspec/collector-brookfield':
+        specifier: workspace:*
+        version: link:../../collectors/brookfield
       '@homesonspec/collector-century-communities':
         specifier: workspace:*
         version: link:../../collectors/century-communities
@@ -169,6 +172,9 @@ importers:
       '@homesonspec/collector-highland-homes':
         specifier: workspace:*
         version: link:../../collectors/highland-homes
+      '@homesonspec/collector-holt-homes':
+        specifier: workspace:*
+        version: link:../../collectors/holt-homes
       '@homesonspec/collector-kb-home':
         specifier: workspace:*
         version: link:../../collectors/kb-home
@@ -187,6 +193,9 @@ importers:
       '@homesonspec/collector-meritage-homes':
         specifier: workspace:*
         version: link:../../collectors/meritage-homes
+      '@homesonspec/collector-perry-homes':
+        specifier: workspace:*
+        version: link:../../collectors/perry-homes
       '@homesonspec/collector-pulte':
         specifier: workspace:*
         version: link:../../collectors/pulte
@@ -284,6 +293,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/brookfield:
+    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/century-communities:
     dependencies:
       '@homesonspec/collectors-common':
@@ -460,6 +491,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/holt-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/kb-home:
     dependencies:
       '@homesonspec/collectors-common':
@@ -595,6 +648,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/perry-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':

← 7f27ee91 david-weekley: capture per-home ElevationRendering photos fr  ·  back to Homesonspec  ·  auto-save: 2026-07-28T20:33:03 (1 files) — collectors/ashton 590c9393 →