← back to Homesonspec
collectors/toll-brothers/src/index.ts
124 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";
/**
* Toll Brothers adapter — FEED source (recon 2026-07-22).
* Their sitemap itself lists /api/v1/search/homesearchV5.json: a public,
* unauthenticated community feed (609 communities). Facts only; no media.
* robots.txt: /api/ is not disallowed; we avoid the disallowed paths
* (/siteplans, /nse, /compare, /new-homes, /sitesearch) entirely.
*
* Wave 1 ingests COMMUNITIES from the feed. Per-home QMI ingestion (the
* ~11k sitemap-listed quick-move-in pages) is wave 2, gated on volume
* controls in the workers scheduler.
*/
const FEED_URL = "https://www.tollbrothers.com/api/v1/search/homesearchV5.json";
const BUILDER_SLUG = "toll-brothers";
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 };
}
interface TbCommunity {
communityId?: number | string;
name?: string;
url?: string;
state?: string;
city?: string;
county?: string;
metroName?: string;
lat?: number | string;
lon?: number | string;
pricedFrom?: number | string;
isFuture?: boolean;
}
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function extractCommunity(community: TbCommunity, sourceUrl: string): ExtractedRecord | null {
const name = community.name?.trim() || null;
if (!name) return null;
const stateCode = normalizeStateCode(community.state ?? null);
const lat = toNumber(community.lat);
const lon = toNumber(community.lon);
const evidence = (field: string, raw: unknown) =>
raw === null || raw === undefined ? null : `${field}: ${String(raw)} (homesearchV5 community ${community.communityId ?? "?"})`;
return {
entityType: "community",
canonicalHints: {
builderSlug: BUILDER_SLUG,
communityName: name,
builderInventoryId: community.communityId ? String(community.communityId) : null,
lat,
lon,
},
fields: {
name: fv(name, name, sourceUrl),
street: fv<string>(null, null, sourceUrl), // feed does not carry a street address
city: fv(community.city?.trim() || null, community.city ?? null, sourceUrl, evidence("city", community.city)),
state: fv(stateCode, community.state ?? null, sourceUrl, evidence("state", community.state)),
zip: fv<string>(null, null, sourceUrl), // not in feed — null, never guessed
county: fv(community.county?.trim() || null, community.county ?? null, sourceUrl),
metro: fv(community.metroName?.trim() || null, community.metroName ?? null, sourceUrl),
lat: fv(lat, community.lat === undefined ? null : String(community.lat), sourceUrl),
lon: fv(lon, community.lon === undefined ? null : String(community.lon), sourceUrl),
hoaFeeMonthly: fv<number>(null, null, sourceUrl),
schoolDistrict: fv<string>(null, null, sourceUrl),
ageRestricted: fv<boolean>(null, null, sourceUrl),
// Extra evidence-bearing facts (not in the strict community schema, harmless):
sourcePageUrl: fv(community.url ?? null, community.url ?? null, sourceUrl),
pricedFrom: fv(toNumber(community.pricedFrom), community.pricedFrom === undefined ? null : String(community.pricedFrom), sourceUrl),
},
};
}
export const tollBrothersAdapter: SourceAdapter = {
key: "toll-brothers-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);
yield await fetcher.fetch(FEED_URL);
},
extract(page: RawPage): ExtractionOutput {
try {
const data = JSON.parse(page.body.toString("utf8")) as {
perfect?: TbCommunity[];
partial?: TbCommunity[];
count?: number;
};
const communities = [...(data.perfect ?? []), ...(data.partial ?? [])];
const records: ExtractedRecord[] = [];
const errors: { url: string; reason: string }[] = [];
for (const community of communities) {
if (community.isFuture) continue; // future communities have no purchasable inventory yet
const record = extractCommunity(community, page.url);
if (record) records.push(record);
else errors.push({ url: page.url, reason: `community without a name (id ${community.communityId ?? "?"})` });
}
return { records, errors };
} catch (error) {
return { records: [], errors: [{ url: page.url, reason: `feed parse: ${String(error)}` }] };
}
},
};