← back to Homesonspec
Add Pulte adapter (4th builder) — JSON API (getcommunities + qmiplans), CA-scoped, WITH lat/lon so homes map; verified 8 live Ontario homes + community geo/phone; registered
74786960ea37fec7103acb438d86d09572f8cf89 · 2026-07-23 00:06:01 -0700 · Steve
Files touched
M apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/pulte/package.jsonA collectors/pulte/src/index.tsA collectors/pulte/tsconfig.jsonM pnpm-lock.yaml
Diff
commit 74786960ea37fec7103acb438d86d09572f8cf89
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 00:06:01 2026 -0700
Add Pulte adapter (4th builder) — JSON API (getcommunities + qmiplans), CA-scoped, WITH lat/lon so homes map; verified 8 live Ontario homes + community geo/phone; registered
---
apps/workers/package.json | 3 +-
apps/workers/src/cli.ts | 2 +
collectors/pulte/package.json | 15 ++++
collectors/pulte/src/index.ts | 173 +++++++++++++++++++++++++++++++++++++++++
collectors/pulte/tsconfig.json | 1 +
pnpm-lock.yaml | 25 ++++++
6 files changed, 218 insertions(+), 1 deletion(-)
diff --git a/apps/workers/package.json b/apps/workers/package.json
index eb05aaa..a36e1bb 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -23,7 +23,8 @@
"@spechomes/collector-toll-brothers": "workspace:*",
"@spechomes/collector-lennar": "workspace:*",
"@spechomes/collector-dr-horton": "workspace:*",
- "@spechomes/collector-kb-home": "workspace:*"
+ "@spechomes/collector-kb-home": "workspace:*",
+ "@spechomes/collector-pulte": "workspace:*"
},
"devDependencies": {
"tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index b3ce9a6..c279c65 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -4,6 +4,7 @@ import { tollBrothersAdapter } from "@spechomes/collector-toll-brothers";
import { lennarAdapter } from "@spechomes/collector-lennar";
import { drHortonAdapter } from "@spechomes/collector-dr-horton";
import { kbHomeAdapter } from "@spechomes/collector-kb-home";
+import { pulteAdapter } from "@spechomes/collector-pulte";
import { runPipeline } from "./pipeline";
import { recordSourceRun } from "./verify";
@@ -17,6 +18,7 @@ const ADAPTERS = {
[lennarAdapter.key]: lennarAdapter,
[drHortonAdapter.key]: drHortonAdapter,
[kbHomeAdapter.key]: kbHomeAdapter,
+ [pulteAdapter.key]: pulteAdapter,
};
async function main() {
diff --git a/collectors/pulte/package.json b/collectors/pulte/package.json
new file mode 100644
index 0000000..8032bfe
--- /dev/null
+++ b/collectors/pulte/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@spechomes/collector-pulte",
+ "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/pulte/src/index.ts b/collectors/pulte/src/index.ts
new file mode 100644
index 0000000..d2a0df0
--- /dev/null
+++ b/collectors/pulte/src/index.ts
@@ -0,0 +1,173 @@
+import type { ExtractedRecord, FieldValue } from "@spechomes/schemas";
+import {
+ fetchFixtures,
+ LiveFetcher,
+ type ExtractionOutput,
+ type FetchContext,
+ type RawPage,
+ type SourceAdapter,
+} from "@spechomes/collectors-common";
+
+/**
+ * Pulte adapter — JSON_API source (recon 2026-07-23). Cleanest of the set.
+ * `GET /api/community/getcommunities?state=…` enumerates communities; each
+ * community's inventory is `GET /api/plan/qmiplans?communityId=<id>` — a JSON
+ * array of quick-move-in homes carrying price/beds/baths/sqft/address/status +
+ * communityLatitude/Longitude + salesPhoneNumber (so homes get real geo).
+ * Plain HTTP, no anti-bot, no auth.
+ */
+const API = "https://www.pulte.com";
+const BUILDER_SLUG = "pulte";
+const STATE = process.env.PULTE_STATE ?? "California";
+const BRAND = process.env.PULTE_BRAND ?? "Pulte";
+const PAGE_LIMIT = Number(process.env.PULTE_PAGE_LIMIT ?? 200);
+
+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 };
+}
+const num = (v: unknown): number | null => {
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.replace(/[^0-9.]/g, "")) : NaN;
+ return Number.isFinite(n) && n > 0 ? n : null;
+};
+// lat/lon may be negative — num()'s >0 guard would wrongly drop western longitudes.
+const geo = (v: unknown): number | null => {
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
+ return Number.isFinite(n) && n !== 0 ? n : null;
+};
+const str = (v: unknown): string | null => {
+ const s = typeof v === "string" ? v.trim() : null;
+ return s ? s : null;
+};
+function titleCase(slug: string): string {
+ return slug.replace(/-\d+$/, "").split("-").map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
+}
+
+function statusOf(dateAvailable: string | null): "MOVE_IN_READY" | "UNDER_CONSTRUCTION" | "PLANNED" {
+ const s = (dateAvailable ?? "").toLowerCase();
+ if (/available now|move.?in|ready|complete/.test(s)) return "MOVE_IN_READY";
+ if (/coming soon|planned/.test(s)) return "PLANNED";
+ return "UNDER_CONSTRUCTION";
+}
+
+export const pulteAdapter: SourceAdapter = {
+ key: "pultegroup-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);
+ const commsPage = await fetcher.fetch(
+ `${API}/api/community/getcommunities?brand=${encodeURIComponent(BRAND)}&state=${encodeURIComponent(STATE)}®ion=&cityNames=&pageSize=1000000&pageNumber=0`,
+ );
+ let comms: Record<string, unknown>[] = [];
+ try {
+ const parsed = JSON.parse(commsPage.body.toString("utf8"));
+ comms = Array.isArray(parsed) ? parsed : (parsed.communities ?? parsed.data ?? []);
+ } catch { /* leave empty */ }
+ const withInventory = comms.filter((c) => c.hasActiveQMI || num(c.inventoryCount));
+ for (const c of withInventory.slice(0, PAGE_LIMIT)) {
+ const id = c.id ?? c.communityId;
+ if (id == null) continue;
+ try {
+ yield await fetcher.fetch(`${API}/api/plan/qmiplans?communityId=${id}`);
+ } catch (error) {
+ console.warn(` skip community ${id}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ },
+
+ extract(page: RawPage): ExtractionOutput {
+ const errors: { url: string; reason: string }[] = [];
+ try {
+ const parsed = JSON.parse(page.body.toString("utf8"));
+ const homes: Record<string, unknown>[] = Array.isArray(parsed) ? parsed : (parsed.plans ?? parsed.data ?? []);
+ if (!Array.isArray(homes) || homes.length === 0) {
+ return { records: [], errors: [] }; // community with no current inventory
+ }
+
+ const first = homes[0]!;
+ const firstUrl = str(first.inventoryPageURL) ?? "";
+ const slug = firstUrl.split("/").filter(Boolean)[4] ?? "";
+ const communityName = str(first.community) ?? (slug ? titleCase(slug) : null);
+ if (!communityName) return { records: [], errors: [{ url: page.url, reason: "no community name" }] };
+ const addr = (first.address as Record<string, unknown>) ?? {};
+ const lat = geo(first.communityLatitude);
+ const lon = geo(first.communityLongitude);
+ const phoneRaw = str(first.salesPhoneNumber);
+ const phone = phoneRaw ? phoneRaw.replace(/[^0-9+]/g, "") : null;
+
+ const records: ExtractedRecord[] = [];
+ records.push({
+ entityType: "community",
+ canonicalHints: { builderSlug: BUILDER_SLUG, communityName },
+ fields: {
+ name: fv(communityName, communityName, page.url),
+ street: fv<string>(null, null, page.url),
+ city: fv(str(addr.city), str(addr.city), page.url),
+ state: fv("CA" as const, str(addr.stateAbbreviation) ?? STATE, page.url),
+ zip: fv(str(addr.zipCode), str(addr.zipCode), page.url),
+ county: fv<string>(null, null, page.url),
+ metro: fv<string>(null, null, page.url),
+ lat: fv(lat, lat === null ? null : String(lat), page.url),
+ lon: fv(lon, lon === null ? null : String(lon), page.url),
+ hoaFeeMonthly: fv<number>(null, null, page.url),
+ schoolDistrict: fv<string>(null, null, page.url),
+ ageRestricted: fv<boolean>(null, null, page.url),
+ salesPhone: fv(phone && phone.length >= 10 ? phone : null, phoneRaw, page.url, "community sales phone"),
+ },
+ });
+
+ for (const h of homes) {
+ const a = (h.address as Record<string, unknown>) ?? {};
+ const address = str(a.street1);
+ if (!address) continue;
+ const price = num(h.finalPrice) ?? num(h.price);
+ const images = Array.isArray(h.images) ? (h.images as unknown[]) : [];
+ const img = images.map((im) => (typeof im === "string" ? im : str((im as Record<string, unknown>)?.url) ?? str((im as Record<string, unknown>)?.src)))
+ .filter((u): u is string => !!u).map((u) => (u.startsWith("http") ? u : API + u));
+ const invId = str(h.inventoryHomeID) ?? str(h.lotBlock);
+ records.push({
+ entityType: "inventory_home",
+ canonicalHints: {
+ builderSlug: BUILDER_SLUG,
+ communityName,
+ address,
+ builderInventoryId: invId,
+ lat: lat ?? undefined,
+ lon: lon ?? undefined,
+ planName: str(h.planName),
+ },
+ fields: {
+ street: fv(address, address, page.url),
+ city: fv(str(a.city), str(a.city), page.url),
+ state: fv("CA" as const, str(a.stateAbbreviation) ?? STATE, page.url),
+ zip: fv(str(a.zipCode), str(a.zipCode), page.url),
+ price: fv(price, price === null ? null : String(price), page.url, price ? `finalPrice ${price}` : null),
+ beds: fv(num(h.bedrooms), null, page.url),
+ bathsTotal: fv(num(h.totalBaths) ?? num(h.bathrooms), null, page.url),
+ sqft: fv(num(h.squareFeet), null, page.url),
+ stories: fv(num(h.floors), null, page.url),
+ garageSpaces: fv(num(h.garages), null, page.url),
+ homeType: fv((h.isSingleFamily === false ? "TOWNHOUSE" : "SINGLE_FAMILY") as never, null, page.url, "Pulte inventory home"),
+ constructionStatus: fv(statusOf(str(h.dateAvailable)), str(h.dateAvailable), page.url),
+ estCompletionDate: fv<string>(null, null, page.url),
+ lotNumber: fv(str(h.lotBlock), str(h.lotBlock), page.url),
+ builderInventoryId: fv(invId, invId, page.url),
+ planName: fv(str(h.planName), str(h.planName), page.url),
+ availabilityStatus: fv(str(h.dateAvailable), str(h.dateAvailable), page.url),
+ lat: fv(lat, null, page.url),
+ lon: fv(lon, null, page.url),
+ images: fv<string[]>(img.slice(0, 6), null, page.url, img.length ? "builder listing photo" : null),
+ },
+ });
+ }
+
+ return { records, errors };
+ } catch (error) {
+ return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+ }
+ },
+};
diff --git a/collectors/pulte/tsconfig.json b/collectors/pulte/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/collectors/pulte/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd8b2e3..d37a2d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -154,6 +154,9 @@ importers:
'@spechomes/collector-meridian-homes':
specifier: workspace:*
version: link:../../collectors/meridian-homes
+ '@spechomes/collector-pulte':
+ specifier: workspace:*
+ version: link:../../collectors/pulte
'@spechomes/collector-toll-brothers':
specifier: workspace:*
version: link:../../collectors/toll-brothers
@@ -327,6 +330,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/pulte:
+ 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))
+
collectors/toll-brothers:
dependencies:
'@spechomes/collectors-common':
← ca4ba1d CA builders: recon spec doc (collectors/BUILDERS.md) — turnk
·
back to Homesonspec
·
Fix new-builder ingest bugs caught on real data: lat/lon opt 8456a67 →