← 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
A collectors/dream-finders/package.jsonA collectors/dream-finders/src/index.tsA collectors/dream-finders/tsconfig.json
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 →