← 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
A collectors/ryan-homes/package.jsonA collectors/ryan-homes/src/index.tsA collectors/ryan-homes/tsconfig.json
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
+ * (" & + ' / etc.) — never a full HTML parser. */
+function decodeEntities(s: string): string {
+ return s
+ .replace(/"/g, '"')
+ .replace(/+/gi, "+")
+ .replace(/+/g, "+")
+ .replace(/'/gi, "'")
+ .replace(/'/g, "'")
+ .replace(///gi, "/")
+ .replace(///g, "/")
+ .replace(/'/g, "'")
+ .replace(/ /g, " ")
+ .replace(/&/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 →