[object Object]

← back to Homesonspec

auto-save: 2026-07-22T12:13:58 (1 files) — collectors/dream-finders/

0e090e44d70d63ea0630392cd604c8ad17c7ddac · 2026-07-22 12:14:06 -0700 · Steve Abrams

Files touched

Diff

commit 0e090e44d70d63ea0630392cd604c8ad17c7ddac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 12:14:06 2026 -0700

    auto-save: 2026-07-22T12:13:58 (1 files) — collectors/dream-finders/
---
 collectors/dream-finders/package.json  |  15 +++
 collectors/dream-finders/src/index.ts  | 203 +++++++++++++++++++++++++++++++++
 collectors/dream-finders/tsconfig.json |   1 +
 3 files changed, 219 insertions(+)

diff --git a/collectors/dream-finders/package.json b/collectors/dream-finders/package.json
new file mode 100644
index 0000000..60bc81a
--- /dev/null
+++ b/collectors/dream-finders/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@spechomes/collector-dream-finders",
+  "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/dream-finders/src/index.ts b/collectors/dream-finders/src/index.ts
new file mode 100644
index 0000000..28afa42
--- /dev/null
+++ b/collectors/dream-finders/src/index.ts
@@ -0,0 +1,203 @@
+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";
+
+/**
+ * Dream Finders adapter — EMBEDDED_JSON source (recon 2026-07-22, difficulty 1).
+ * sitemap-specs.xml lists every spec/inventory home with daily lastmod —
+ * effectively a native inventory feed. Each spec page carries ld+json
+ * Product+Offer blocks (price, availability). robots.txt allows the
+ * inventory tree (only login/favorites/sitesearch.json disallowed).
+ *
+ * URL shape: /new-homes/{state}/{city}/{community}/{plan}/{address-slug}/
+ * Batch control: DREAMFINDERS_PAGE_LIMIT (default 10).
+ */
+
+const SITEMAP_URL = "https://dreamfindershomes.com/sitemap-specs.xml";
+const BUILDER_SLUG = "dream-finders";
+const PAGE_LIMIT = Number(process.env.DREAMFINDERS_PAGE_LIMIT ?? 10);
+
+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 };
+}
+
+function titleCase(slug: string): string {
+  return slug.split("-").map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
+}
+
+interface UrlParts {
+  state: string | null;
+  city: string | null;
+  community: string | null;
+  plan: string | null;
+  address: string | null;
+}
+
+/** /new-homes/co/longmont/mountain-brook/ridgeline/2848-bear-springs-cir/ */
+function parseSpecUrl(url: string): UrlParts | null {
+  const segments = url.replace(/^https?:\/\/[^/]+/, "").split("/").filter(Boolean);
+  if (segments[0] !== "new-homes" || segments.length < 6) return null;
+  return {
+    state: normalizeStateCode(segments[1] ?? null),
+    city: segments[2] ? titleCase(segments[2]) : null,
+    community: segments[3] ? titleCase(segments[3]) : null,
+    plan: segments[4] ? titleCase(segments[4]) : null,
+    address: segments[5] ? titleCase(segments[5]) : null,
+  };
+}
+
+function extractLdJsonBlocks(html: string): unknown[] {
+  const blocks: unknown[] = [];
+  for (const match of html.matchAll(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)) {
+    try {
+      blocks.push(JSON.parse(match[1]!));
+    } catch {
+      // malformed block — skip, never guess
+    }
+  }
+  return blocks;
+}
+
+/** Find the Product whose Offer.url matches THIS page (pages also list sibling homes). */
+function findOwnOffer(blocks: unknown[], pageUrl: string): { price: number | null; raw: string | null } {
+  const normalized = pageUrl.replace(/\/$/, "");
+  const walk = (node: unknown): { price: number | null; raw: string | null } | null => {
+    if (!node || typeof node !== "object") return null;
+    if (Array.isArray(node)) {
+      for (const item of node) {
+        const found = walk(item);
+        if (found) return found;
+      }
+      return null;
+    }
+    const obj = node as Record<string, unknown>;
+    const offers = obj.offers as Record<string, unknown> | undefined;
+    if (offers && typeof offers === "object") {
+      const offerUrl = String(offers.url ?? "").replace(/\/$/, "");
+      if (offerUrl === normalized) {
+        const price = Number(offers.price);
+        return Number.isFinite(price) && price > 0
+          ? { price, raw: String(offers.price) }
+          : { price: null, raw: offers.price === undefined ? null : String(offers.price) };
+      }
+    }
+    for (const value of Object.values(obj)) {
+      const found = walk(value);
+      if (found) return found;
+    }
+    return null;
+  };
+  for (const block of blocks) {
+    const found = walk(block);
+    if (found) return found;
+  }
+  return { price: null, raw: null };
+}
+
+/** "3,214 sq. ft. ... 5 bedrooms, 4 baths" — regex facts out of description text. */
+function factsFromText(html: string): { beds: number | null; baths: number | null; sqft: number | null } {
+  const bedsMatch = html.match(/(\d+)\s*(?:bedrooms|beds|bed\b)/i);
+  const bathsMatch = html.match(/(\d+(?:\.\d+)?)\s*(?:bathrooms|baths|bath\b)/i);
+  const sqftMatch = html.match(/([\d,]{3,6})\s*(?:sq\.?\s?ft|square feet)/i);
+  const sqft = sqftMatch ? Number(sqftMatch[1]!.replace(/,/g, "")) : null;
+  return {
+    beds: bedsMatch ? Number(bedsMatch[1]) : null,
+    baths: bathsMatch ? Number(bathsMatch[1]) : null,
+    sqft: sqft !== null && Number.isFinite(sqft) ? sqft : null,
+  };
+}
+
+export const dreamFindersAdapter: SourceAdapter = {
+  key: "dream-finders-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 sitemap = await fetcher.fetch(SITEMAP_URL);
+    const urls = [...sitemap.body.toString("utf8").matchAll(/<loc>([^<]+)<\/loc>/g)]
+      .map((m) => m[1]!)
+      .filter((url) => parseSpecUrl(url) !== null);
+    urls.sort();
+    for (const url of urls.slice(0, PAGE_LIMIT)) {
+      yield await fetcher.fetch(url);
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const parts = parseSpecUrl(page.url);
+      if (!parts?.community || !parts.address) {
+        return { records: [], errors: [{ url: page.url, reason: "unparseable spec URL" }] };
+      }
+      const html = page.body.toString("utf8");
+      const blocks = extractLdJsonBlocks(html);
+      const offer = findOwnOffer(blocks, page.url);
+      const facts = factsFromText(html);
+
+      const records: ExtractedRecord[] = [
+        // Community first — publish creates/refreshes the FK target.
+        {
+          entityType: "community",
+          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: parts.community },
+          fields: {
+            name: fv(parts.community, parts.community, page.url, "community segment of spec URL"),
+            street: fv<string>(null, null, page.url),
+            city: fv(parts.city, parts.city, page.url),
+            state: fv(parts.state, parts.state, page.url),
+            zip: fv<string>(null, null, page.url),
+            county: fv<string>(null, null, page.url),
+            metro: fv<string>(null, null, page.url),
+            lat: fv<number>(null, null, page.url),
+            lon: fv<number>(null, null, page.url),
+            hoaFeeMonthly: fv<number>(null, null, page.url),
+            schoolDistrict: fv<string>(null, null, page.url),
+            ageRestricted: fv<boolean>(null, null, page.url),
+          },
+        },
+        {
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName: parts.community,
+            address: parts.address,
+            planName: parts.plan,
+          },
+          fields: {
+            street: fv(parts.address, parts.address, page.url, "address segment of spec URL"),
+            city: fv(parts.city, parts.city, page.url),
+            state: fv(parts.state, parts.state, page.url),
+            zip: fv<string>(null, null, page.url),
+            price: fv(offer.price, offer.raw, page.url, offer.raw ? `ld+json Offer price ${offer.raw}` : null),
+            beds: fv(facts.beds, facts.beds === null ? null : String(facts.beds), page.url),
+            bathsTotal: fv(facts.baths, facts.baths === null ? null : String(facts.baths), page.url),
+            sqft: fv(facts.sqft, facts.sqft === null ? null : String(facts.sqft), page.url),
+            stories: fv<number>(null, null, page.url),
+            garageSpaces: fv<number>(null, null, page.url),
+            homeType: fv("SINGLE_FAMILY" as const, null, page.url, "Dream Finders spec home listing"),
+            constructionStatus: fv<"PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY">(null, null, page.url),
+            estCompletionDate: fv<string>(null, null, page.url),
+            lotNumber: fv<string>(null, null, page.url),
+            builderInventoryId: fv<string>(null, null, page.url),
+            lat: fv<number>(null, null, page.url),
+            lon: fv<number>(null, null, page.url),
+            planName: fv(parts.plan, parts.plan, page.url),
+          },
+        },
+      ];
+      return { records, errors: [] };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/dream-finders/tsconfig.json b/collectors/dream-finders/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/collectors/dream-finders/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }

← 4ba331c source runbooks for toll-brothers + lennar live adapters  ·  back to Homesonspec  ·  go-live: Kamatera deploy script; start scripts respect PORT 30af084 →