[object Object]

← 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

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)}&region=&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 →