[object Object]

← 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

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&apos;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 →