← back to Homesonspec
top-30 builder registry + recon findings recorded; toll-brothers live adapter: 337 real communities ingested via sitemap-sanctioned feed, full evidence trail; positioning update (no one-stop place)
f0e81ad040cc3538e8f37d3ea43938849c036ffe · 2026-07-22 11:50:19 -0700 · Steve Abrams
Files touched
M apps/web/src/app/page.tsxM apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/toll-brothers/package.jsonA collectors/toll-brothers/src/index.tsA collectors/toll-brothers/tsconfig.jsonA docs/product/POSITIONING.mdM packages/database/prisma/seed-national-builders.tsM packages/shared/src/geo.tsM pnpm-lock.yaml
Diff
commit f0e81ad040cc3538e8f37d3ea43938849c036ffe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 11:50:19 2026 -0700
top-30 builder registry + recon findings recorded; toll-brothers live adapter: 337 real communities ingested via sitemap-sanctioned feed, full evidence trail; positioning update (no one-stop place)
---
apps/web/src/app/page.tsx | 6 +
apps/workers/package.json | 3 +-
apps/workers/src/cli.ts | 2 +
collectors/toll-brothers/package.json | 15 +++
collectors/toll-brothers/src/index.ts | 123 +++++++++++++++++++++
collectors/toll-brothers/tsconfig.json | 1 +
docs/product/POSITIONING.md | 33 ++++++
packages/database/prisma/seed-national-builders.ts | 21 ++++
packages/shared/src/geo.ts | 25 +++++
pnpm-lock.yaml | 25 +++++
10 files changed, 253 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 6fc5901..eddf60d 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -21,6 +21,12 @@ export default async function HomePage() {
<p className="mt-4 text-lg text-teal-100">
Move-in-ready and under-construction homes, verified from the source.
</p>
+ <p className="mx-auto mt-3 max-w-2xl text-sm text-teal-200">
+ Search “spec home listings” today and you get scattered builder sites, generic portals, and
+ blog posts — there is no one place to see them all. Builders list inventory on dozens of
+ separate websites that never meet. SpecHomes puts every builder's available inventory in
+ one verified search.
+ </p>
<form action="/search" method="get" className="mx-auto mt-8 flex max-w-xl gap-2">
<input
type="text"
diff --git a/apps/workers/package.json b/apps/workers/package.json
index ff44da6..88e0c02 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -18,7 +18,8 @@
"@spechomes/collectors-common": "workspace:*",
"@spechomes/collector-meridian-homes": "workspace:*",
"pg-boss": "^10.1.5",
- "@spechomes/publisher": "workspace:*"
+ "@spechomes/publisher": "workspace:*",
+ "@spechomes/collector-toll-brothers": "workspace:*"
},
"devDependencies": {
"tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index 04679c3..b7b6a42 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -1,5 +1,6 @@
import { prisma } from "@spechomes/database";
import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
+import { tollBrothersAdapter } from "@spechomes/collector-toll-brothers";
import { runPipeline } from "./pipeline";
/**
@@ -8,6 +9,7 @@ import { runPipeline } from "./pipeline";
*/
const ADAPTERS = {
[meridianHomesAdapter.key]: meridianHomesAdapter,
+ [tollBrothersAdapter.key]: tollBrothersAdapter,
};
async function main() {
diff --git a/collectors/toll-brothers/package.json b/collectors/toll-brothers/package.json
new file mode 100644
index 0000000..bdb3827
--- /dev/null
+++ b/collectors/toll-brothers/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@spechomes/collector-toll-brothers",
+ "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": {
+ "@spechomes/collectors-common": "workspace:*",
+ "@spechomes/schemas": "workspace:*",
+ "@spechomes/shared": "workspace:*"
+ },
+ "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/collectors/toll-brothers/src/index.ts b/collectors/toll-brothers/src/index.ts
new file mode 100644
index 0000000..2285f27
--- /dev/null
+++ b/collectors/toll-brothers/src/index.ts
@@ -0,0 +1,123 @@
+import type { ExtractedRecord, FieldValue } from "@spechomes/schemas";
+import { normalizeStateCode } from "@spechomes/shared";
+import {
+ fetchFixtures,
+ LiveFetcher,
+ type ExtractionOutput,
+ type FetchContext,
+ type RawPage,
+ type SourceAdapter,
+} from "@spechomes/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)}` }] };
+ }
+ },
+};
diff --git a/collectors/toll-brothers/tsconfig.json b/collectors/toll-brothers/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/collectors/toll-brothers/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/docs/product/POSITIONING.md b/docs/product/POSITIONING.md
new file mode 100644
index 0000000..ec79741
--- /dev/null
+++ b/docs/product/POSITIONING.md
@@ -0,0 +1,33 @@
+# Positioning
+
+**One place to discover, compare, and contact builders for newly constructed homes
+across the United States.**
+
+## Consumer promise
+Every new home. Every builder. One search. Verified from the source.
+
+## The gap (validated 2026-07-22)
+A Google search for "spec home listing" returns: individual builder websites, generic
+resale portals where new construction is an afterthought filter, lead-gen aggregators
+with stale or partial inventory, and blog posts explaining what a spec home is. There
+is **no dedicated, trustworthy, one-stop search for builder spec inventory** — buyers
+must visit dozens of separate builder sites that never meet. That fragmentation is the
+product thesis: builders each publish inventory on their own site (D.R. Horton, Lennar,
+Pulte, Toll Brothers… 30+ national/regional sites and hundreds of local ones), and no
+neutral surface unifies them with verification labels and freshness guarantees.
+
+## Why we win (defensible asset)
+Not "a large crawler" — a **permission-aware, evidence-backed, frequently verified
+national database** of new-construction inventory:
+- every fact carries source evidence + retrieval timestamp,
+- verification labels derive from how data was collected and never overstate it,
+- stale inventory is labeled, not hidden,
+- builder relationships upgrade sources from crawl → feed → partnership.
+
+## Current acquisition wave
+Top 30 national builders registered in the source registry (2026-07-22). Recon
+verdicts recorded per source; adapters ship for the cleanest sources first
+(Toll Brothers sitemap-sanctioned JSON API, Taylor Morrison JSON-LD + llms.txt
+invitation, KB Home embedded regionMapData, LGI __NEXT_DATA__). Sources with
+robots-restricted inventory paths (Century Communities /site-data/*) stay
+community-level only pending a partner-feed conversation.
diff --git a/packages/database/prisma/seed-national-builders.ts b/packages/database/prisma/seed-national-builders.ts
index 16b6907..000dffb 100644
--- a/packages/database/prisma/seed-national-builders.ts
+++ b/packages/database/prisma/seed-national-builders.ts
@@ -26,6 +26,27 @@ const BUILDERS: {
{ slug: "century-communities", name: "Century Communities", legalName: "Century Communities, Inc.", website: "https://www.centurycommunities.com", termsUrl: "https://www.centurycommunities.com/terms-of-use", coverage: "National — 17+ states" },
{ slug: "lgi-homes", name: "LGI Homes", legalName: "LGI Homes, Inc.", website: "https://www.lgihomes.com", termsUrl: "https://www.lgihomes.com/terms-of-use", coverage: "National — 20+ states" },
{ slug: "toll-brothers", name: "Toll Brothers", legalName: "Toll Brothers, Inc.", website: "https://www.tollbrothers.com", termsUrl: "https://www.tollbrothers.com/terms-of-use", coverage: "National luxury — 24+ states" },
+ // ── 11–30 (expansion wave) ──
+ { slug: "richmond-american", name: "Richmond American Homes", legalName: "M.D.C. Holdings, Inc. (Sekisui House)", website: "https://www.richmondamerican.com", termsUrl: "https://www.richmondamerican.com/terms-of-use", coverage: "National — 16+ states" },
+ { slug: "clayton-properties", name: "Clayton Properties Group", legalName: "Clayton Properties Group, Inc.", website: "https://www.claytonhomes.com", termsUrl: "https://www.claytonhomes.com/terms-of-use", coverage: "Southeast/Midwest site-built brands" },
+ { slug: "david-weekley", name: "David Weekley Homes", legalName: "Weekley Homes, LLC", website: "https://www.davidweekleyhomes.com", termsUrl: "https://www.davidweekleyhomes.com/terms-of-use", coverage: "Sun Belt — 19+ markets" },
+ { slug: "dream-finders", name: "Dream Finders Homes", legalName: "Dream Finders Homes, Inc.", website: "https://www.dreamfindershomes.com", termsUrl: "https://www.dreamfindershomes.com/terms-of-use", coverage: "Southeast/Mid-Atlantic/TX/CO" },
+ { slug: "mi-homes", name: "M/I Homes", legalName: "M/I Homes, Inc.", website: "https://www.mihomes.com", termsUrl: "https://www.mihomes.com/terms-of-use", coverage: "Midwest/Southeast — 17 markets" },
+ { slug: "tri-pointe", name: "Tri Pointe Homes", legalName: "Tri Pointe Homes, Inc.", website: "https://www.tripointehomes.com", termsUrl: "https://www.tripointehomes.com/terms-of-use", coverage: "West/TX/Southeast" },
+ { slug: "beazer", name: "Beazer Homes", legalName: "Beazer Homes USA, Inc.", website: "https://www.beazer.com", termsUrl: "https://www.beazer.com/terms-of-use", coverage: "National — 13 states" },
+ { slug: "k-hovnanian", name: "K. Hovnanian Homes", legalName: "Hovnanian Enterprises, Inc.", website: "https://www.khov.com", termsUrl: "https://www.khov.com/terms-of-use", coverage: "National — 14 states" },
+ { slug: "ashton-woods", name: "Ashton Woods", legalName: "Ashton Woods Homes", website: "https://www.ashtonwoods.com", termsUrl: "https://www.ashtonwoods.com/terms-of-use", coverage: "Southeast/TX" },
+ { slug: "mattamy", name: "Mattamy Homes", legalName: "Mattamy Homes Limited (US)", website: "https://mattamyhomes.com", termsUrl: "https://mattamyhomes.com/terms-of-use", coverage: "FL/AZ/NC/SC/TX" },
+ { slug: "shea-homes", name: "Shea Homes", legalName: "Shea Homes Limited Partnership", website: "https://www.sheahomes.com", termsUrl: "https://www.sheahomes.com/terms-of-use", coverage: "West/TX/FL" },
+ { slug: "perry-homes", name: "Perry Homes", legalName: "Perry Homes, LLC", website: "https://www.perryhomes.com", termsUrl: "https://www.perryhomes.com/terms-of-use", coverage: "TX" },
+ { slug: "highland-homes", name: "Highland Homes", legalName: "Highland Homes, Ltd.", website: "https://www.highlandhomes.com", termsUrl: "https://www.highlandhomes.com/terms-of-use", coverage: "TX" },
+ { slug: "stanley-martin", name: "Stanley Martin Homes", legalName: "Stanley Martin Companies, LLC", website: "https://www.stanleymartin.com", termsUrl: "https://www.stanleymartin.com/terms-of-use", coverage: "Mid-Atlantic/Southeast" },
+ { slug: "smith-douglas", name: "Smith Douglas Homes", legalName: "Smith Douglas Homes Corp.", website: "https://www.smithdouglas.com", termsUrl: "https://www.smithdouglas.com/terms-of-use", coverage: "Southeast" },
+ { slug: "brookfield-residential", name: "Brookfield Residential", legalName: "Brookfield Residential Properties Inc.", website: "https://www.brookfieldresidential.com", termsUrl: "https://www.brookfieldresidential.com/terms-of-use", coverage: "West/TX/DC metro" },
+ { slug: "drees-homes", name: "Drees Homes", legalName: "The Drees Company", website: "https://www.dreeshomes.com", termsUrl: "https://www.dreeshomes.com/terms-of-use", coverage: "OH/KY/TN/TX/DC" },
+ { slug: "fischer-homes", name: "Fischer Homes", legalName: "Fischer Homes, Inc.", website: "https://www.fischerhomes.com", termsUrl: "https://www.fischerhomes.com/terms-of-use", coverage: "OH/KY/IN/GA/MO" },
+ { slug: "gl-homes", name: "GL Homes", legalName: "G.L. Homes of Florida Corporation", website: "https://www.glhomes.com", termsUrl: "https://www.glhomes.com/terms-of-use", coverage: "FL" },
+ { slug: "landsea-homes", name: "Landsea Homes", legalName: "Landsea Homes Corporation", website: "https://www.landseahomes.com", termsUrl: "https://www.landseahomes.com/terms-of-use", coverage: "AZ/CA/FL/TX/NY metro" },
];
async function main() {
diff --git a/packages/shared/src/geo.ts b/packages/shared/src/geo.ts
index 5d8aa2a..5d7f0e7 100644
--- a/packages/shared/src/geo.ts
+++ b/packages/shared/src/geo.ts
@@ -81,3 +81,28 @@ export function stateForZip(zip: string): string | null {
const prefix = zip.slice(0, 3);
return ZIP3_TO_STATE[prefix] ?? null;
}
+
+const STATE_NAME_TO_CODE: Record<string, string> = {
+ alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA",
+ colorado: "CO", connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA",
+ hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA",
+ kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
+ massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS", missouri: "MO",
+ montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ",
+ "new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", ohio: "OH",
+ oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI", "south carolina": "SC",
+ "south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT", vermont: "VT",
+ virginia: "VA", washington: "WA", "west virginia": "WV", wisconsin: "WI", wyoming: "WY",
+ "district of columbia": "DC",
+};
+
+/** "New Jersey" | "new-jersey" | "NJ" → "NJ". Null when unrecognized — never guessed. */
+export function normalizeStateCode(raw: string | null | undefined): string | null {
+ if (!raw) return null;
+ const trimmed = raw.trim();
+ if (/^[A-Za-z]{2}$/.test(trimmed)) {
+ const upper = trimmed.toUpperCase();
+ return Object.values(STATE_NAME_TO_CODE).includes(upper) ? upper : null;
+ }
+ return STATE_NAME_TO_CODE[trimmed.toLowerCase().replace(/-/g, " ")] ?? null;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 084f4d2..7c99656 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -145,6 +145,9 @@ importers:
'@spechomes/collector-meridian-homes':
specifier: workspace:*
version: link:../../collectors/meridian-homes
+ '@spechomes/collector-toll-brothers':
+ specifier: workspace:*
+ version: link:../../collectors/toll-brothers
'@spechomes/collectors-common':
specifier: workspace:*
version: link:../../collectors/common
@@ -227,6 +230,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/toll-brothers:
+ dependencies:
+ '@spechomes/collectors-common':
+ specifier: workspace:*
+ version: link:../common
+ '@spechomes/schemas':
+ specifier: workspace:*
+ version: link:../../packages/schemas
+ '@spechomes/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))
+
packages/database:
dependencies:
'@prisma/client':
← 1413161 polite LiveFetcher (rate-limited, never circumvents blocks)
·
back to Homesonspec
·
lennar live adapter (172 real homes from 8 pages, Apollo sta 45f9ee2 →