← back to Homesonspec
collectors/lennar/src/index.ts
260 lines
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";
/**
* Lennar adapter — EMBEDDED_JSON source (recon 2026-07-22, difficulty 2).
* Community pages under /new-homes/ ship a fully-hydrated Apollo state in
* __NEXT_DATA__: CommunityType (name, cityRef, stateCode, zipCode, hoa,
* lat/lon) + per-home HomesiteType records (price, beds, baths, sqft,
* status, address, lat/lon, planRef). robots.txt permits the /new-homes
* tree (only fragment paths disallowed).
*
* Statewide batching: LENNAR_PAGE_LIMIT community pages per run
* (default 300 — enough to cover a whole state in one pass; there is no
* cursor to advance past the head, so a low cap silently strands the tail)
* at the registry rate limit; change detection makes re-visits cheap
* (unchanged bytes stage nothing).
*/
const SITEMAP_URL = "https://www.lennar.com/api/images/sitemapfe.xml";
const BUILDER_SLUG = "lennar";
const PAGE_LIMIT = Number(process.env.LENNAR_PAGE_LIMIT ?? 300);
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 };
}
/** Community URL = /new-homes/{state}/{metro}/{city}/{community} exactly. */
function isCommunityUrl(url: string): boolean {
const path = url.replace(/^https?:\/\/[^/]+/, "").replace(/\/$/, "");
const segments = path.split("/").filter(Boolean);
return segments[0] === "new-homes" && segments.length === 5;
}
function titleCase(slug: string): string {
return slug.split("-").map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
}
type Apollo = Record<string, Record<string, unknown> | undefined>;
/** Resolve an Apollo `{__ref}` pointer (or return the value if plain). */
function deref(apollo: Apollo, value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object") {
const ref = (value as { __ref?: string }).__ref;
if (ref) return apollo[ref] ?? null;
return value as Record<string, unknown>;
}
return null;
}
function str(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Pull a clean https image URL from a Lennar Apollo image node. Handles both
* ImageType ({ url }) and TitleImageType ({ image: { url } }), deref-ing refs,
* and drops the ?d=…&w=… CDN sizing query so the largest asset is stored.
*/
function imgUrl(apollo: Apollo, node: unknown): string | null {
const n = deref(apollo, node) ?? (node as Record<string, unknown> | null);
if (!n || typeof n !== "object") return null;
const inner = (deref(apollo, n.image) ?? n.image ?? n) as Record<string, unknown>;
const u = inner?.url;
return typeof u === "string" && /^https:\/\//.test(u) ? u.split("?")[0]! : null;
}
/**
* Builder listing photos for a home, best-effort and deduped: the homesite's own
* elevation photo first, then its plan's hero + elevation, then the community
* hero + gallery as fallback. Facts-only sources yield []; never guessed.
*/
function collectImages(
apollo: Apollo,
home: Record<string, unknown>,
plan: Record<string, unknown> | null,
rollup: Record<string, unknown> | undefined,
): string[] {
const out: string[] = [];
const push = (v: unknown) => {
const u = imgUrl(apollo, v);
if (u) out.push(u);
};
push(home.elevationImage);
if (plan) {
push(plan.heroImage);
const el = plan['elevationImages({"first":1})'];
if (Array.isArray(el)) el.forEach(push);
}
push(rollup?.heroImage);
const gallery = rollup?.heroGalleryImages;
if (Array.isArray(gallery)) gallery.slice(0, 4).forEach(push);
return [...new Set(out)].slice(0, 6);
}
function numOrNull(value: unknown): number | null {
const n = Number(value);
return Number.isFinite(n) && n !== 0 ? n : null; // Lennar uses 0 for "no value"
}
function statusToEnum(status: string | null): "PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
if (!status) return null;
if (/under.?construction/i.test(status)) return "UNDER_CONSTRUCTION";
if (/move.?in.?ready|completed|ready/i.test(status)) return "MOVE_IN_READY";
if (/coming.?soon|planned|future/i.test(status)) return "PLANNED";
return null; // UNDEFINED etc — recorded raw in availabilityStatus, never guessed
}
export const lennarAdapter: SourceAdapter = {
key: "lennar-site",
version: "1.1.0",
async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
if (ctx.mode === "fixture") {
yield* fetchFixtures(ctx);
return;
}
const fetcher = new LiveFetcher(ctx.registry);
const sitemap = await fetcher.fetch(SITEMAP_URL);
let urls = [...sitemap.body.toString("utf8").matchAll(/<loc>([^<]+)<\/loc>/g)]
.map((m) => m[1]!)
.filter(isCommunityUrl);
// Drop obvious non-community length-5 URLs (promo/event pages waste fetches).
urls = urls.filter((u) => !/\/(promo|event)\//i.test(u));
// LENNAR_STATE_FILTER=texas restricts the sweep to one state so coverage can
// be filled targeted + fast instead of grinding the whole sitemap alphabetically.
const stateFilter = process.env.LENNAR_STATE_FILTER?.trim().toLowerCase();
if (stateFilter) urls = urls.filter((u) => u.toLowerCase().includes(`/new-homes/${stateFilter}/`));
urls.sort();
for (const url of urls.slice(0, PAGE_LIMIT)) {
// A dead/stale sitemap URL (404) or a transient blip must SKIP, never abort
// the whole sweep — large builder sitemaps always carry some rot.
try {
yield await fetcher.fetch(url);
} catch (error) {
console.warn(` skip ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
},
extract(page: RawPage): ExtractionOutput {
const errors: { url: string; reason: string }[] = [];
try {
const html = page.body.toString("utf8");
// Community sales-office phone — the "broker" contact shown on listings.
const phoneRaw = html.match(/"phoneNumber"\s*:\s*"([^"]+)"/)?.[1] ?? null;
const salesPhone = phoneRaw && /\d{3}[-.\s]?\d{3}[-.\s]?\d{4}/.test(phoneRaw) ? phoneRaw : null;
const match = html.match(/<script id="__NEXT_DATA__" type="application\/json"[^>]*>([\s\S]*?)<\/script>/);
if (!match) return { records: [], errors: [{ url: page.url, reason: "no __NEXT_DATA__" }] };
const nextData = JSON.parse(match[1]!) as {
props?: { pageProps?: { initialApolloState?: Apollo } };
};
const apollo: Apollo = nextData.props?.pageProps?.initialApolloState ?? {};
const segments = page.url.replace(/^https?:\/\/[^/]+/, "").split("/").filter(Boolean);
const urlState = normalizeStateCode(segments[1] ?? null);
const urlCity = segments[3] ? titleCase(segments[3]) : null;
const rollup = Object.entries(apollo).find(([key]) => key.startsWith("CommunityType:"))?.[1];
const communityName = str(rollup?.name) ?? (segments[4] ? titleCase(segments[4]) : null);
if (!communityName) return { records: [], errors: [{ url: page.url, reason: "no community name" }] };
const cityNode = deref(apollo, rollup?.city);
const cityName = str(cityNode?.name) ?? urlCity;
const stateCode = normalizeStateCode(str(rollup?.stateCode)) ?? urlState;
const zipRaw = str(rollup?.zipCode);
const zip = zipRaw && /^\d{5}$/.test(zipRaw) ? zipRaw : null;
const hoaMonthly = numOrNull(rollup?.monthlyHoaFee);
const records: ExtractedRecord[] = [];
// Community first — publish creates the FK target before homes validate.
records.push({
entityType: "community",
canonicalHints: { builderSlug: BUILDER_SLUG, communityName },
fields: {
name: fv(communityName, str(rollup?.name), page.url),
street: fv(str(rollup?.address), str(rollup?.address), page.url),
city: fv(cityName, str(cityNode?.name) ?? urlCity, page.url),
state: fv(stateCode, str(rollup?.stateCode) ?? segments[1] ?? null, page.url),
zip: fv(zip, zipRaw, page.url),
county: fv<string>(null, null, page.url),
metro: fv(segments[2] ? titleCase(segments[2]) : null, segments[2] ?? null, page.url),
lat: fv(numOrNull(rollup?.latitude), rollup?.latitude === undefined ? null : String(rollup?.latitude), page.url),
lon: fv(numOrNull(rollup?.longitude), rollup?.longitude === undefined ? null : String(rollup?.longitude), page.url),
hoaFeeMonthly: fv(hoaMonthly, rollup?.monthlyHoaFee === undefined ? null : String(rollup?.monthlyHoaFee), page.url),
schoolDistrict: fv<string>(null, null, page.url),
ageRestricted: fv<boolean>(null, null, page.url),
salesPhone: fv(salesPhone, salesPhone, page.url, salesPhone ? "community sales phone" : null),
},
});
for (const [key, entry] of Object.entries(apollo)) {
if (!key.startsWith("HomesiteType:") || !entry) continue;
const home = entry;
const status = str(home.status);
if (status && /sold|closed/i.test(status)) continue; // not available inventory
const address = str(home.address);
if (!address) continue; // no address → no canonical identity for a per-home record
const bathsFull = numOrNull(home.baths) ?? (home.baths === 0 ? null : null);
const halfBaths = Number(home.halfBaths ?? 0);
const baths = bathsFull !== null ? bathsFull + (Number.isFinite(halfBaths) ? halfBaths * 0.5 : 0) : null;
const plan = deref(apollo, home.plan);
const planName = str(plan?.name);
const garages = numOrNull(plan?.garages);
const lotid = str(home.lotid) ?? str(home.id);
records.push({
entityType: "inventory_home",
canonicalHints: {
builderSlug: BUILDER_SLUG,
communityName,
address,
builderInventoryId: lotid,
lat: numOrNull(home.latitude),
lon: numOrNull(home.longitude),
planName,
},
fields: {
street: fv(address, str(home.address), page.url),
city: fv(cityName, str(cityNode?.name) ?? urlCity, page.url),
state: fv(stateCode, str(rollup?.stateCode) ?? segments[1] ?? null, page.url),
zip: fv(zip, zipRaw, page.url),
price: fv(numOrNull(home.price), home.price === undefined ? null : String(home.price), page.url,
home.price ? `price: ${String(home.price)} (${key})` : null),
beds: fv(numOrNull(home.beds), home.beds === undefined ? null : String(home.beds), page.url),
bathsTotal: fv(baths, home.baths === undefined ? null : `${String(home.baths)} + ${String(home.halfBaths ?? 0)} half`, page.url),
sqft: fv(numOrNull(home.sqft), home.sqft === undefined ? null : String(home.sqft), page.url),
stories: fv<number>(null, null, page.url),
garageSpaces: fv(garages, plan?.garages === undefined ? null : String(plan?.garages), page.url),
homeType: fv("SINGLE_FAMILY" as const, null, page.url, "Lennar homesite listing"),
constructionStatus: fv(statusToEnum(status), status, page.url),
estCompletionDate: fv<string>(null, null, page.url),
lotNumber: fv(str(home.number), str(home.number), page.url),
builderInventoryId: fv(lotid, lotid, page.url),
lat: fv(numOrNull(home.latitude), home.latitude === undefined ? null : String(home.latitude), page.url),
lon: fv(numOrNull(home.longitude), home.longitude === undefined ? null : String(home.longitude), page.url),
planName: fv(planName, planName, page.url),
availabilityStatus: fv(status, status, page.url),
images: fv<string[]>(collectImages(apollo, home, plan, rollup), null, page.url, "builder listing photos"),
},
});
}
return { records, errors };
} catch (error) {
return { records: [], errors: [{ url: page.url, reason: String(error) }] };
}
},
};