[object Object]

← back to Homesonspec

Add Ryan Homes (NVR) collector adapter

0c5a3968e7ab14e9898ad6e44e95a10ce971d031 · 2026-07-28 16:41:48 -0700 · Steve

Server-rendered national QMI page (bucket C): one GET to
/quick-move-in-homes yields all 505 homes as our-homes-card blocks.
100% coverage on price/beds/baths/community/city/state/county/plan/id,
99.6% sqft (2 homes genuinely size-0). Emits community then inventory_home
per home, mirroring Meritage's fields. Facts-only: street & lat/lon null
(not on the list page), images dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0c5a3968e7ab14e9898ad6e44e95a10ce971d031
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 16:41:48 2026 -0700

    Add Ryan Homes (NVR) collector adapter
    
    Server-rendered national QMI page (bucket C): one GET to
    /quick-move-in-homes yields all 505 homes as our-homes-card blocks.
    100% coverage on price/beds/baths/community/city/state/county/plan/id,
    99.6% sqft (2 homes genuinely size-0). Emits community then inventory_home
    per home, mirroring Meritage's fields. Facts-only: street & lat/lon null
    (not on the list page), images dropped.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 collectors/ryan-homes/package.json  |  15 ++
 collectors/ryan-homes/src/index.ts  | 317 ++++++++++++++++++++++++++++++++++++
 collectors/ryan-homes/tsconfig.json |   1 +
 3 files changed, 333 insertions(+)

diff --git a/collectors/ryan-homes/package.json b/collectors/ryan-homes/package.json
new file mode 100644
index 00000000..6ecaf1ca
--- /dev/null
+++ b/collectors/ryan-homes/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-ryan-homes",
+  "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": {
+    "@homesonspec/collectors-common": "workspace:*",
+    "@homesonspec/schemas": "workspace:*",
+    "@homesonspec/shared": "workspace:*"
+  },
+  "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/collectors/ryan-homes/src/index.ts b/collectors/ryan-homes/src/index.ts
new file mode 100644
index 00000000..78af224c
--- /dev/null
+++ b/collectors/ryan-homes/src/index.ts
@@ -0,0 +1,317 @@
+import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
+import { normalizeStateCode } from "@homesonspec/shared";
+import {
+  fetchFixtures,
+  LiveFetcher,
+  type ExtractionOutput,
+  type FetchContext,
+  type RawPage,
+  type SourceAdapter,
+} from "@homesonspec/collectors-common";
+
+/**
+ * Ryan Homes (NVR) adapter — SERVER-RENDERED HTML source (recon 2026-07-28, bucket C).
+ *
+ * Ryan Homes' entire NATIONAL quick-move-in (QMI) inventory is server-rendered
+ * onto ONE page — no XHR, no pagination, no auth, no Turnstile:
+ *
+ *   GET https://www.ryanhomes.com/quick-move-in-homes   (~1.9 MB HTML)
+ *
+ * Recon findings (why we parse the HTML cards, not a JSON blob):
+ *   - There is NO __NEXT_DATA__ / self.__next_f / window.__* / ld+json / redux
+ *     blob carrying the homes. The only inline JSON is state/county filter facets
+ *     ({"stateAbbr":…}, {"homeType":…,"minSize":…}) — aggregates, not per-home.
+ *   - Every home IS a fully-populated <div class="our-homes-card"> with rich
+ *     data-* attributes + a spec URL + an aria-label + a price footer. The page's
+ *     own JS hard-codes `$('#modelCount').html(505)` and shows all cards
+ *     (cap 2000), so the single GET returns the WHOLE national list (505 homes).
+ *
+ * Per-card facts extracted:
+ *   - data-id      → builderInventoryId (spec id, e.g. "29195")
+ *   - data-model   → planName ("3-Story Mozart")
+ *   - data-size    → sqft ("2122")
+ *   - data-state   → state ('["TN"]' → TN)
+ *   - data-county  → county ('["Davidson"]' → Davidson)
+ *   - <div class="subheading"><p>{community}</p><p>{City, ST}</p>
+ *   - stat_line    → "3 Bed", "2 Bath", "1 Half Bath" → beds / full+half baths
+ *   - "Own For $309,990" footer → price
+ *   - spec href    → /new-homes/communities/{commId}/specs/{specId}/{state}/{city}/{commSlug}/{planSlug}
+ *
+ * Facts NOT available on the list page (never guessed → null):
+ *   - street address (cards show community + City,ST only)
+ *   - lat/lon (a spec DETAIL page carries data-lat/data-lng, but for a LIST of
+ *     nearby communities, not this home — ambiguous + 505 extra fetches, so we
+ *     leave lat/lon null rather than attach a wrong coordinate).
+ *   - images are intentionally dropped (facts-only).
+ *
+ * robots.txt (ryanhomes.com) = `User-agent: *` with no Disallow → fully open.
+ *
+ * Batch control: RYAN_PAGE_LIMIT (default 10) caps how many list-page fetches we
+ * make. The national QMI list is a single page today, so one fetch covers it;
+ * the cap is a safety guard should Ryan ever paginate this route.
+ */
+
+const QMI_URL = "https://www.ryanhomes.com/quick-move-in-homes";
+const BUILDER_SLUG = "ryan-homes";
+const PAGE_LIMIT = Number(process.env.RYAN_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 };
+}
+
+// A positive finite number, or null. Never guesses; 0 / negative / NaN → null.
+const posNum = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+  return Number.isFinite(n) && n > 0 ? n : null;
+};
+// A non-negative number (baths may legitimately be 0), or null.
+const nonNegNum = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+  return Number.isFinite(n) && n >= 0 ? n : null;
+};
+const str = (v: unknown): string | null => {
+  if (v == null) return null;
+  const s = String(v).trim();
+  return s ? s : null;
+};
+
+/** Minimal HTML-entity decode for the handful of entities the cards emit
+ *  (&quot; &amp; &#x2B; &#x27; &#x2F; etc.) — never a full HTML parser. */
+function decodeEntities(s: string): string {
+  return s
+    .replace(/&quot;/g, '"')
+    .replace(/&#x2B;/gi, "+")
+    .replace(/&#43;/g, "+")
+    .replace(/&#x27;/gi, "'")
+    .replace(/&#39;/g, "'")
+    .replace(/&#x2F;/gi, "/")
+    .replace(/&#47;/g, "/")
+    .replace(/&apos;/g, "'")
+    .replace(/&nbsp;/g, " ")
+    .replace(/&amp;/g, "&")
+    .trim();
+}
+
+/** Pull the value of an attribute out of a card block. */
+function attr(block: string, name: string): string | null {
+  const m = block.match(new RegExp(`${name}="([^"]*)"`));
+  return m ? decodeEntities(m[1]!) : null;
+}
+
+/** Unwrap a JSON-array-in-attribute like '["TN"]' or '["Davidson"]' → first value. */
+function firstOfJsonArray(raw: string | null): string | null {
+  const s = str(raw);
+  if (!s) return null;
+  const m = s.match(/"([^"]+)"/); // first quoted token inside the array
+  if (m) return m[1]!.trim() || null;
+  // fall back to a bare, unquoted value between the brackets
+  const bare = s.replace(/[\[\]"]/g, "").split(",")[0]?.trim();
+  return bare || null;
+}
+
+interface RyanCard {
+  specId: string | null;
+  plan: string | null;
+  sqft: number | null;
+  state: string | null;
+  county: string | null;
+  community: string | null;
+  city: string | null;
+  beds: number | null;
+  fullBaths: number | null;
+  halfBaths: number | null;
+  price: number | null;
+  priceRaw: string | null;
+  href: string | null;
+  communityId: string | null;
+}
+
+/**
+ * Split the QMI HTML into per-home card blocks. Each home is a
+ * <div class="col-12 col-lg-4 our-homes-card" data-id=…>…</div>. We slice from
+ * one card's opening tag to the next card's opening tag (last card runs to EOF).
+ */
+function splitCards(html: string): string[] {
+  const marker = '<div class="col-12 col-lg-4 our-homes-card"';
+  const starts: number[] = [];
+  let idx = html.indexOf(marker);
+  while (idx !== -1) {
+    starts.push(idx);
+    idx = html.indexOf(marker, idx + marker.length);
+  }
+  const blocks: string[] = [];
+  for (let i = 0; i < starts.length; i++) {
+    const end = i + 1 < starts.length ? starts[i + 1]! : html.length;
+    blocks.push(html.slice(starts[i]!, end));
+  }
+  return blocks;
+}
+
+/** Parse one card block into structured facts. */
+function parseCard(block: string): RyanCard {
+  const specId = attr(block, "data-id");
+  const plan = attr(block, "data-model");
+  const sqft = posNum(attr(block, "data-size"));
+  const state = normalizeStateCode(firstOfJsonArray(attr(block, "data-state")));
+  const county = firstOfJsonArray(attr(block, "data-county"));
+
+  // subheading: <div class="subheading"><p>{community}</p><p>{City, ST}</p></div>
+  let community: string | null = null;
+  let city: string | null = null;
+  const sub = block.match(/<div class="subheading">\s*<p>([^<]+)<\/p>\s*<p>([^<]+)<\/p>/);
+  if (sub) {
+    community = decodeEntities(sub[1]!);
+    const citystate = decodeEntities(sub[2]!); // "Antioch, TN"
+    city = citystate.split(",")[0]?.trim() || null;
+  }
+
+  // stat_line: "<p>3 Bed<span>2 Bath</span></p> ... <p>1 Half Bath</p>"
+  const bedsM = block.match(/([0-9]+)\s*Bed/);
+  const fullM = block.match(/([0-9]+)\s*Bath/);
+  const halfM = block.match(/([0-9]+)\s*Half\s*Bath/);
+  const beds = posNum(bedsM?.[1] ?? null);
+  const fullBaths = nonNegNum(fullM?.[1] ?? null);
+  const halfBaths = nonNegNum(halfM?.[1] ?? null);
+
+  // "Own For <span>$309,990</span>"
+  const priceM = block.match(/Own For\s*<span>\s*\$?\s*([0-9,]+)/i);
+  const priceRaw = priceM ? priceM[1]! : null;
+  const price = posNum(priceRaw);
+
+  // spec href: /new-homes/communities/{commId}/specs/{specId}/...
+  const hrefM = block.match(/href="(\/new-homes\/communities\/[^"]*\/specs\/[^"]+)"/);
+  const href = hrefM ? hrefM[1]! : null;
+  const commIdM = href ? href.match(/\/communities\/([^/]+)\/specs\//) : null;
+  const communityId = commIdM ? commIdM[1]! : null;
+
+  return {
+    specId,
+    plan,
+    sqft,
+    state,
+    county,
+    community,
+    city,
+    beds,
+    fullBaths,
+    halfBaths,
+    price,
+    priceRaw,
+    href,
+    communityId,
+  };
+}
+
+export const ryanHomesAdapter: SourceAdapter = {
+  key: "ryan-homes-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);
+    // The national QMI list is a single server-rendered page today. We fetch it
+    // once; PAGE_LIMIT caps fetches defensively should Ryan ever paginate.
+    for (let pageNo = 0; pageNo < Math.max(1, PAGE_LIMIT); pageNo++) {
+      try {
+        yield await fetcher.fetch(QMI_URL);
+      } catch (error) {
+        console.warn(`  ryan qmi: ${error instanceof Error ? error.message : String(error)}`);
+      }
+      return; // one page holds the whole list — stop after the first fetch
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const html = page.body.toString("utf8");
+      const blocks = splitCards(html);
+      if (blocks.length === 0) {
+        return { records: [], errors: [{ url: page.url, reason: "no our-homes-card blocks in QMI page" }] };
+      }
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      for (const block of blocks) {
+        const c = parseCard(block);
+        const url = c.href ? `https://www.ryanhomes.com${c.href}` : page.url;
+        const bathsTotal = c.fullBaths === null ? null : c.fullBaths + (c.halfBaths ?? 0) * 0.5;
+
+        if (!c.community) {
+          errors.push({ url, reason: `home ${c.specId ?? "?"} has no community — cannot attach, skipped` });
+          continue;
+        }
+
+        // Community FIRST — publish creates the FK target the home record needs.
+        records.push({
+          entityType: "community",
+          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: c.community },
+          fields: {
+            name: fv(c.community, c.community, url, "QMI card subheading community"),
+            street: fv<string>(null, null, url),
+            city: fv(c.city, c.city, url),
+            state: fv(c.state, c.state, url),
+            zip: fv<string>(null, null, url),
+            county: fv(c.county, c.county, url),
+            metro: fv<string>(null, null, url),
+            lat: fv<number>(null, null, url),
+            lon: fv<number>(null, null, url),
+            hoaFeeMonthly: fv<number>(null, null, url),
+            schoolDistrict: fv<string>(null, null, url),
+            ageRestricted: fv<boolean>(null, null, url),
+          },
+        });
+
+        records.push({
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName: c.community,
+            // No street address on the list page; anchor on the builder spec id.
+            address: c.specId ? `Ryan Homes spec ${c.specId}` : undefined,
+            builderInventoryId: c.specId ?? undefined,
+            planName: c.plan ?? undefined,
+          },
+          fields: {
+            // Facts-only: the list page carries no street address → null (never guessed).
+            street: fv<string>(null, null, url),
+            city: fv(c.city, c.city, url),
+            state: fv(c.state, c.state, url),
+            zip: fv<string>(null, null, url),
+            price: fv(c.price, c.priceRaw, url, c.price === null ? null : `QMI "Own For $${c.priceRaw}"`),
+            beds: fv(c.beds, c.beds === null ? null : String(c.beds), url),
+            bathsTotal: fv(
+              bathsTotal,
+              bathsTotal === null ? null : String(bathsTotal),
+              url,
+              bathsTotal === null ? null : `${c.fullBaths} full + ${c.halfBaths ?? 0} half`,
+            ),
+            sqft: fv(c.sqft, c.sqft === null ? null : String(c.sqft), url),
+            stories: fv<number>(null, null, url),
+            garageSpaces: fv<number>(null, null, url),
+            homeType: fv("SINGLE_FAMILY" as const, null, url, "Ryan Homes single-family inventory home"),
+            constructionStatus: fv<"PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY">(
+              "MOVE_IN_READY",
+              null,
+              url,
+              "listed on quick-move-in-homes",
+            ),
+            estCompletionDate: fv<string>(null, null, url),
+            lotNumber: fv<string>(null, null, url),
+            builderInventoryId: fv(c.specId, c.specId, url),
+            // Facts-only: list page has no per-home coordinate → null (never guessed).
+            lat: fv<number>(null, null, url),
+            lon: fv<number>(null, null, url),
+            planName: fv(c.plan, c.plan, url),
+            images: fv<string[]>([], null, url),
+          },
+        });
+      }
+      return { records, errors };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/ryan-homes/tsconfig.json b/collectors/ryan-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/ryan-homes/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }

← c3bf5d1d storefront: render home photos on the search grid (+ absolut  ·  back to Homesonspec  ·  collectors: add Ryan Homes, Highland Homes, Landsea/Risewell c28b10e8 →