← 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
M apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/brookfield/src/index.tsM collectors/tri-pointe/src/index.tsM pnpm-lock.yaml
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 →