[object Object]

← back to Homesonspec

collectors: add Ryan Homes, Highland Homes, Landsea/Risewell adapters + wire into loop

c28b10e8227c221ddae85dc5554f17fb765b5826 · 2026-07-28 16:56:57 -0700 · Steve Abrams

- Ryan Homes/NVR (ryan-homes-site): national QMI server-rendered on one GET (~505 homes,
  17 states); 100% price/beds/baths, 99.6% sqft.
- Highland Homes (highland-homes-site): landing communities blob + per-community QMI pages
  (~315 homes, TX); 98.7% price, 100% beds/baths/sqft.
- Landsea/Risewell (landsea-homes-site): public Algolia proxy GET (119 homes, 7 states);
  100% price/beds/sqft. Proxy caps paging at 120/503 (documented in adapter header).
All facts-only, honest-UA, robots-respected, LiveFetcher GET. Wired into cli.ts + build-loop
(sweep_ryan/sweep_highland/sweep_landsea, national/regional once-per-sweep) + reactivation roster.

Files touched

Diff

commit c28b10e8227c221ddae85dc5554f17fb765b5826
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 16:56:57 2026 -0700

    collectors: add Ryan Homes, Highland Homes, Landsea/Risewell adapters + wire into loop
    
    - Ryan Homes/NVR (ryan-homes-site): national QMI server-rendered on one GET (~505 homes,
      17 states); 100% price/beds/baths, 99.6% sqft.
    - Highland Homes (highland-homes-site): landing communities blob + per-community QMI pages
      (~315 homes, TX); 98.7% price, 100% beds/baths/sqft.
    - Landsea/Risewell (landsea-homes-site): public Algolia proxy GET (119 homes, 7 states);
      100% price/beds/sqft. Proxy caps paging at 120/503 (documented in adapter header).
    All facts-only, honest-UA, robots-respected, LiveFetcher GET. Wired into cli.ts + build-loop
    (sweep_ryan/sweep_highland/sweep_landsea, national/regional once-per-sweep) + reactivation roster.
---
 apps/workers/package.json               |   5 +-
 apps/workers/src/cli.ts                 |   6 +
 collectors/highland-homes/package.json  |  15 ++
 collectors/highland-homes/src/index.ts  | 425 ++++++++++++++++++++++++++++++++
 collectors/highland-homes/tsconfig.json |   1 +
 collectors/landsea-homes/package.json   |  15 ++
 collectors/landsea-homes/src/index.ts   | 316 ++++++++++++++++++++++++
 collectors/landsea-homes/tsconfig.json  |   1 +
 pnpm-lock.yaml                          |  75 ++++++
 scripts/build-loop.sh                   |  19 +-
 10 files changed, 875 insertions(+), 3 deletions(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index 9ccb0fbb..59567b1f 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -31,7 +31,10 @@
     "@homesonspec/collector-david-weekley": "workspace:*",
     "@homesonspec/collector-ashton-woods": "workspace:*",
     "@homesonspec/collector-dream-finders": "workspace:*",
-    "@homesonspec/collector-meritage-homes": "workspace:*"
+    "@homesonspec/collector-meritage-homes": "workspace:*",
+    "@homesonspec/collector-landsea-homes": "workspace:*",
+    "@homesonspec/collector-ryan-homes": "workspace:*",
+    "@homesonspec/collector-highland-homes": "workspace:*"
   },
   "devDependencies": {
     "tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index e5541380..5c9c372a 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -12,6 +12,9 @@ import { davidWeekleyAdapter } from "@homesonspec/collector-david-weekley";
 import { ashtonWoodsAdapter } from "@homesonspec/collector-ashton-woods";
 import { dreamFindersAdapter } from "@homesonspec/collector-dream-finders";
 import { meritageHomesAdapter } from "@homesonspec/collector-meritage-homes";
+import { landseaAdapter } from "@homesonspec/collector-landsea-homes";
+import { ryanHomesAdapter } from "@homesonspec/collector-ryan-homes";
+import { highlandHomesAdapter } from "@homesonspec/collector-highland-homes";
 import { runPipeline } from "./pipeline";
 import { recordSourceRun } from "./verify";
 
@@ -33,6 +36,9 @@ const ADAPTERS = {
   [ashtonWoodsAdapter.key]: ashtonWoodsAdapter,
   [dreamFindersAdapter.key]: dreamFindersAdapter,
   [meritageHomesAdapter.key]: meritageHomesAdapter,
+  [landseaAdapter.key]: landseaAdapter,
+  [ryanHomesAdapter.key]: ryanHomesAdapter,
+  [highlandHomesAdapter.key]: highlandHomesAdapter,
 };
 
 async function main() {
diff --git a/collectors/highland-homes/package.json b/collectors/highland-homes/package.json
new file mode 100644
index 00000000..24d198ae
--- /dev/null
+++ b/collectors/highland-homes/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-highland-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/highland-homes/src/index.ts b/collectors/highland-homes/src/index.ts
new file mode 100644
index 00000000..7c68d6e4
--- /dev/null
+++ b/collectors/highland-homes/src/index.ts
@@ -0,0 +1,425 @@
+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";
+
+/**
+ * Highland Homes adapter — SERVER-RENDERED + EMBEDDED_JSON source (recon 2026-07-28).
+ *
+ * highlandhomes.com is a Vue.js storefront over a Yii/PHP backend. The
+ * for-sale landing page
+ *
+ *   GET https://www.highlandhomes.com/homes-for-sale
+ *
+ * server-embeds the full community list as an inline JS array —
+ *
+ *   var communities = [ { objectID, name, url, _geoloc:{lat,lng}, city, zip,
+ *                         region, status, quickMoveIn, lowPrice, highPrice … } ]
+ *
+ * (111 communities, all with lat/lng + city + zip; ~101 carry quick-move-in
+ * inventory). Per-HOME facts do NOT live on the landing page; each community's
+ * page (community.url, e.g. /austin/kyle/6-creeks-at-waterridge) SERVER-RENDERS
+ * one <a class="home-card"> per available spec home, carrying:
+ *   data-price / .home-price ($), .home-plan, .home-address ("street, city, ST"),
+ *   .home-bedrooms, .home-baths, .home-garages, .home-stories,
+ *   .home-squarefootage, a status tag ("Complete & Move-in Ready!" /
+ *   "Est. Completion - Nov '26"), href (detail URL) and data-algolia-object-id.
+ *
+ * There is also an Algolia index (app KOMTD97D6N, index "highland", type:"5home"
+ * = 904 homes) queryable with the public search key, but those records are
+ * SEARCH-LEAN (address + community + url only — no price/beds/baths/sqft), so
+ * the community-page HTML cards are the authoritative facts source and this
+ * adapter prefers them.
+ *
+ * Strategy: fetch the landing page → parse the `communities` blob → fetch each
+ * community page that has inventory (up to HIGHLAND_PAGE_LIMIT) → parse its
+ * home cards. Community lat/lng/zip (only on the landing blob) are injected into
+ * each community page's bytes as a `<!-- highland-community: {json} -->` comment
+ * so extract() stays a pure function of the RawPage it receives.
+ *
+ * robots.txt (highlandhomes.com) is `User-agent: *` with no Disallow → allow /.
+ * Facts-only: featuredImage/photos exist in the feed and are dropped.
+ *
+ * Batch control: HIGHLAND_PAGE_LIMIT (community pages to crawl, default 10).
+ */
+
+const ORIGIN = "https://www.highlandhomes.com";
+const LANDING_URL = `${ORIGIN}/homes-for-sale`;
+const BUILDER_SLUG = "highland-homes";
+const PAGE_LIMIT = Number(process.env.HIGHLAND_PAGE_LIMIT ?? 10);
+const META_MARKER = "highland-community:";
+
+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/garages 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;
+};
+const zip5 = (v: unknown): string | null => {
+  const s = str(v);
+  if (!s) return null;
+  const m = s.match(/\b(\d{5})\b/);
+  return m ? m[1]! : null;
+};
+
+interface Community {
+  objectID?: string;
+  name?: string;
+  url?: string;
+  _geoloc?: { lat?: string | number; lng?: string | number };
+  city?: string;
+  zip?: string | number;
+  region?: string;
+  status?: string;
+  quickMoveIn?: number;
+}
+
+/** The community metadata injected into a community-page RawPage so extract()
+ *  can attach lat/lng/zip that only exist on the landing-page blob. */
+interface InjectedMeta {
+  name: string | null;
+  city: string | null;
+  zip: string | null;
+  lat: number | null;
+  lon: number | null;
+  region: string | null;
+}
+
+/** Preserved signs: US lon is negative; a stringified "-97.9…" must stay negative. */
+function toCoord(v: unknown): number | null {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
+  return Number.isFinite(n) && n !== 0 ? n : null;
+}
+
+/** Scan the balanced `var communities = [ … ]` array literal out of the landing
+ *  page HTML (string/escape aware) and JSON.parse it. Returns [] if absent. */
+export function parseCommunitiesBlob(html: string): Community[] {
+  const needle = "var communities = ";
+  const at = html.indexOf(needle);
+  if (at < 0) return [];
+  const start = at + needle.length;
+  if (html[start] !== "[") return [];
+  let depth = 0;
+  let inStr = false;
+  let esc = false;
+  let end = -1;
+  for (let i = start; i < html.length; i++) {
+    const c = html[i]!;
+    if (esc) {
+      esc = false;
+    } else if (c === "\\") {
+      esc = true;
+    } else if (c === '"') {
+      inStr = !inStr;
+    } else if (!inStr) {
+      if (c === "[") depth++;
+      else if (c === "]") {
+        depth--;
+        if (depth === 0) {
+          end = i;
+          break;
+        }
+      }
+    }
+  }
+  if (end < 0) return [];
+  try {
+    const arr = JSON.parse(html.slice(start, end + 1));
+    return Array.isArray(arr) ? (arr as Community[]) : [];
+  } catch {
+    return [];
+  }
+}
+
+function communityMeta(c: Community): InjectedMeta {
+  return {
+    name: str(c.name),
+    city: str(c.city),
+    zip: zip5(c.zip),
+    lat: toCoord(c._geoloc?.lat),
+    lon: toCoord(c._geoloc?.lng),
+    region: str(c.region),
+  };
+}
+
+/** Absolute community-page URL from a `communities[].url` (which is relative). */
+function communityPageUrl(relUrl: string): string {
+  if (/^https?:\/\//.test(relUrl)) return relUrl;
+  return `${ORIGIN}${relUrl.startsWith("/") ? "" : "/"}${relUrl}`;
+}
+
+/** Read the injected `<!-- highland-community: {json} -->` comment, if any. */
+function readInjectedMeta(html: string): InjectedMeta | null {
+  const m = html.match(/<!--\s*highland-community:\s*(\{[\s\S]*?\})\s*-->/);
+  if (!m) return null;
+  try {
+    return JSON.parse(m[1]!) as InjectedMeta;
+  } catch {
+    return null;
+  }
+}
+
+interface HomeCard {
+  href: string | null;
+  objectID: string | null;
+  price: number | null;
+  priceRaw: string | null;
+  plan: string | null;
+  address: string | null; // "244 Basket Flower Loop, Kyle, TX"
+  beds: number | null;
+  baths: number | null;
+  garages: number | null;
+  stories: number | null;
+  sqft: number | null;
+  statusTag: string | null;
+}
+
+function pick(re: RegExp, block: string): string | null {
+  const m = block.match(re);
+  return m ? str(m[1]) : null;
+}
+
+/** Parse every <a class="home-card"> … </a> block out of a community page. */
+export function parseHomeCards(html: string): HomeCard[] {
+  const cards: HomeCard[] = [];
+  for (const m of html.matchAll(/<a class="home-card[^>]*>([\s\S]*?)<\/a>/g)) {
+    const outer = m[0];
+    const priceRaw = pick(/data-price="([^"]+)"/, outer) ?? pick(/home-price">\$?([0-9,]+)/, outer);
+    cards.push({
+      href: pick(/href="([^"]+)"/, outer),
+      objectID: pick(/data-algolia-object-id="([^"]+)"/, outer),
+      price: posNum(priceRaw),
+      priceRaw,
+      plan: pick(/home-plan">([^<]+)</, outer),
+      address: pick(/home-address">([^<]+)</, outer),
+      beds: posNum(pick(/home-bedrooms[^>]*>([0-9.]+)/, outer)),
+      baths: nonNegNum(pick(/home-baths[^>]*>([0-9.]+)/, outer)),
+      garages: nonNegNum(pick(/home-garages[^>]*>([0-9.]+)/, outer)),
+      stories: posNum(pick(/home-stories[^>]*>([0-9.]+)/, outer)),
+      sqft: posNum(pick(/home-squarefootage[^>]*>([0-9,]+)/, outer)),
+      statusTag: pick(/home-tag[^>]*>([^<]+)</, outer),
+    });
+  }
+  return cards;
+}
+
+/** "244 Basket Flower Loop, Kyle, TX" → { street, city, state }. */
+function parseAddress(addr: string | null): { street: string | null; city: string | null; state: string | null } {
+  if (!addr) return { street: null, city: null, state: null };
+  const parts = addr.split(",").map((p) => p.trim()).filter(Boolean);
+  if (parts.length >= 3) {
+    const state = normalizeStateCode(parts[parts.length - 1] ?? null);
+    const city = str(parts[parts.length - 2]);
+    const street = str(parts.slice(0, parts.length - 2).join(", "));
+    return { street, city, state };
+  }
+  if (parts.length === 2) {
+    return { street: str(parts[0]), city: str(parts[1]), state: null };
+  }
+  return { street: str(parts[0] ?? addr), city: null, state: null };
+}
+
+/** Highland status tag → our construction-status enum. "Complete & Move-in
+ *  Ready!" = finished; "Est. Completion - …" / "Under Construction" = in
+ *  progress. Unrecognized/blank → null (never guessed). */
+function constructionStatus(tag: string | null): "PLANNED" | "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+  const s = (str(tag) ?? "").toLowerCase();
+  if (!s) return null;
+  if (s.includes("move-in ready") || s.includes("move in ready") || s.includes("complete")) return "MOVE_IN_READY";
+  if (s.includes("est. completion") || s.includes("under construction") || s.includes("coming")) {
+    return "UNDER_CONSTRUCTION";
+  }
+  return null;
+}
+
+export const highlandHomesAdapter: SourceAdapter = {
+  key: "highland-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);
+
+    let landing: RawPage;
+    try {
+      landing = await fetcher.fetch(LANDING_URL);
+    } catch (error) {
+      console.warn(`  highland landing: ${error instanceof Error ? error.message : String(error)}`);
+      return; // a block/403 on the landing page stops collection (source degraded)
+    }
+    // Yield the landing page — extract() emits every community from its blob.
+    yield landing;
+
+    const communities = parseCommunitiesBlob(landing.body.toString("utf8"))
+      .filter((c) => str(c.url) && (c.quickMoveIn ?? 0) > 0); // only crawl communities with inventory
+
+    for (const community of communities.slice(0, Math.max(0, PAGE_LIMIT))) {
+      const url = communityPageUrl(String(community.url));
+      let page: RawPage;
+      try {
+        page = await fetcher.fetch(url);
+      } catch (error) {
+        console.warn(`  highland community ${url}: ${error instanceof Error ? error.message : String(error)}`);
+        continue; // one blocked/failed community page doesn't stop the rest
+      }
+      // Inject the community's geo/zip (only on the landing blob) so extract()
+      // stays pure over the bytes it receives.
+      const meta = JSON.stringify(communityMeta(community));
+      const injected = `<!-- ${META_MARKER} ${meta} -->\n${page.body.toString("utf8")}`;
+      yield { ...page, body: Buffer.from(injected, "utf8") };
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const html = page.body.toString("utf8");
+
+      // ---- Landing page: emit communities from the embedded blob -----------
+      if (page.url === LANDING_URL || html.includes("var communities = [")) {
+        const communities = parseCommunitiesBlob(html);
+        if (communities.length) {
+          const records: ExtractedRecord[] = [];
+          for (const c of communities) {
+            const name = str(c.name);
+            if (!name) continue;
+            const meta = communityMeta(c);
+            const state = normalizeStateCode(null); // landing blob has no state; city/zip only
+            records.push({
+              entityType: "community",
+              canonicalHints: { builderSlug: BUILDER_SLUG, communityName: name },
+              fields: {
+                name: fv(name, name, page.url, "landing-page communities blob"),
+                street: fv<string>(null, null, page.url),
+                city: fv(meta.city, meta.city, page.url),
+                state: fv(state, null, page.url),
+                zip: fv(meta.zip, meta.zip, page.url),
+                county: fv<string>(null, null, page.url),
+                metro: fv(meta.region, meta.region, page.url),
+                lat: fv(meta.lat, meta.lat === null ? null : String(meta.lat), page.url, meta.lat === null ? null : "_geoloc.lat"),
+                lon: fv(meta.lon, meta.lon === null ? null : String(meta.lon), page.url, meta.lon === null ? null : "_geoloc.lng"),
+                hoaFeeMonthly: fv<number>(null, null, page.url),
+                schoolDistrict: fv<string>(null, null, page.url),
+                ageRestricted: fv<boolean>(null, null, page.url),
+              },
+            });
+          }
+          return { records, errors: [] };
+        }
+        // Landing page but no blob — report honestly.
+        if (page.url === LANDING_URL) {
+          return { records: [], errors: [{ url: page.url, reason: "landing page had no `var communities` blob" }] };
+        }
+      }
+
+      // ---- Community page: emit inventory homes from the card grid ---------
+      const meta = readInjectedMeta(html);
+      const cards = parseHomeCards(html);
+      if (!cards.length) {
+        return { records: [], errors: [{ url: page.url, reason: "no home-card blocks on community page" }] };
+      }
+
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      for (const card of cards) {
+        const parsed = parseAddress(card.address);
+        const homeUrl = card.href ? communityPageUrl(card.href) : page.url;
+        const community = meta?.name ?? null;
+        // The community name is required — the publisher hangs each home off a
+        // community FK. Without it we can't attach the home, so skip + log.
+        if (!community) {
+          errors.push({ url: homeUrl, reason: `home ${card.objectID ?? card.address ?? "?"} has no community meta — skipped` });
+          continue;
+        }
+        if (!parsed.street) {
+          errors.push({ url: homeUrl, reason: `home ${card.objectID ?? "?"} missing address — skipped` });
+          continue;
+        }
+        const state = parsed.state ?? normalizeStateCode(null);
+        const city = parsed.city ?? meta?.city ?? null;
+        const zip = meta?.zip ?? null;
+        const cStatus = constructionStatus(card.statusTag);
+
+        // Community FIRST — publish creates/refreshes the FK target the home needs.
+        records.push({
+          entityType: "community",
+          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: community },
+          fields: {
+            name: fv(community, community, page.url, "injected community meta"),
+            street: fv<string>(null, null, page.url),
+            city: fv(meta?.city ?? null, meta?.city ?? null, page.url),
+            state: fv(state, null, page.url),
+            zip: fv(zip, zip, page.url),
+            county: fv<string>(null, null, page.url),
+            metro: fv(meta?.region ?? null, meta?.region ?? null, page.url),
+            lat: fv(meta?.lat ?? null, meta?.lat == null ? null : String(meta.lat), page.url, meta?.lat == null ? null : "_geoloc.lat"),
+            lon: fv(meta?.lon ?? null, meta?.lon == null ? null : String(meta.lon), page.url, meta?.lon == null ? null : "_geoloc.lng"),
+            hoaFeeMonthly: fv<number>(null, null, page.url),
+            schoolDistrict: fv<string>(null, null, page.url),
+            ageRestricted: fv<boolean>(null, null, page.url),
+          },
+        });
+
+        records.push({
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName: community,
+            address: parsed.street,
+            builderInventoryId: card.objectID ?? undefined,
+            lat: meta?.lat ?? undefined,
+            lon: meta?.lon ?? undefined,
+            planName: card.plan ?? undefined,
+          },
+          fields: {
+            street: fv(parsed.street, parsed.street, homeUrl, "community-page home-address"),
+            city: fv(city, city, homeUrl),
+            state: fv(state, parsed.state, homeUrl),
+            zip: fv(zip, zip, homeUrl),
+            price: fv(card.price, card.priceRaw, homeUrl, card.price === null ? null : `home card data-price ${card.priceRaw}`),
+            beds: fv(card.beds, card.beds === null ? null : String(card.beds), homeUrl),
+            bathsTotal: fv(card.baths, card.baths === null ? null : String(card.baths), homeUrl),
+            sqft: fv(card.sqft, card.sqft === null ? null : String(card.sqft), homeUrl),
+            stories: fv(card.stories, card.stories === null ? null : String(card.stories), homeUrl),
+            garageSpaces: fv(card.garages, card.garages === null ? null : String(card.garages), homeUrl),
+            homeType: fv("SINGLE_FAMILY" as const, null, homeUrl, "Highland single-family spec home"),
+            constructionStatus: fv(cStatus, card.statusTag, homeUrl, cStatus === null ? null : `tag: ${card.statusTag}`),
+            estCompletionDate: fv<string>(null, null, homeUrl),
+            lotNumber: fv<string>(null, null, homeUrl),
+            builderInventoryId: fv(card.objectID, card.objectID, homeUrl),
+            lat: fv(meta?.lat ?? null, meta?.lat == null ? null : String(meta.lat), homeUrl, meta?.lat == null ? null : "community _geoloc.lat"),
+            lon: fv(meta?.lon ?? null, meta?.lon == null ? null : String(meta.lon), homeUrl, meta?.lon == null ? null : "community _geoloc.lng"),
+            planName: fv(card.plan, card.plan, homeUrl),
+            // facts-only: card images exist in the feed but are intentionally dropped.
+            images: fv<string[]>([], null, homeUrl),
+          },
+        });
+      }
+      return { records, errors };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/highland-homes/tsconfig.json b/collectors/highland-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/highland-homes/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/collectors/landsea-homes/package.json b/collectors/landsea-homes/package.json
new file mode 100644
index 00000000..9546d969
--- /dev/null
+++ b/collectors/landsea-homes/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-landsea-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/landsea-homes/src/index.ts b/collectors/landsea-homes/src/index.ts
new file mode 100644
index 00000000..4b1fcc5e
--- /dev/null
+++ b/collectors/landsea-homes/src/index.ts
@@ -0,0 +1,316 @@
+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";
+
+/**
+ * Landsea Homes adapter — JSON-API source (recon 2026-07-28).
+ *
+ * Landsea rebranded to Risewell (risewellhomes.com). The site is WordPress
+ * fronting an Algolia index, proxied through a same-origin endpoint so no
+ * Algolia app-id/key is needed client-side:
+ *
+ *   GET https://risewellhomes.com/api/algolia/search?query=<BASE64>
+ *   where BASE64 = base64( JSON.stringify({
+ *     "index": "wp_posts_homesites", "query": "", "hitsPerPage": 1000, "page": N }) )
+ *
+ * The response is a standard Algolia payload:
+ *   { hits:[ homesite… ], nbHits, nbPages, page, hitsPerPage, … }
+ *
+ * PAGING CAVEAT (verified 2026-07-28): the WordPress proxy CAPS hitsPerPage at
+ * 120 AND *ignores the requested `page`* — every request echoes `page:0` and
+ * returns the SAME first 120 homesites. So although Algolia's metadata reports
+ * nbHits≈503 / nbPages≈5, only the first 120 homesites are actually reachable
+ * through this endpoint. We therefore fetch page 0, and if the server's echoed
+ * `page` does not match the page we asked for, we STOP (paging is a no-op — pulling
+ * more would only re-ingest the same 120 homes as duplicates). If Landsea ever
+ * fixes real paging, the same loop transparently walks the extra pages.
+ *
+ * Each hit carries the facts we keep (facts-only — main_image_* URLs are in the
+ * feed but intentionally DROPPED):
+ *   address:{ address1, city, state("Texas"), zip, county }
+ *   bedrooms, baths, sq_feet_total, stories, cars_spaces, _geoloc:{ lat, lng }
+ *   price, base_price, moveInWindow("1-3 mo."), status("Move-In Ready"),
+ *   neighborhood:{ name }  (the community), floorplan:{ name } (the plan),
+ *   region:{ name }        (full state name), url, objectID.
+ *
+ * Access: the honest bot UA gets HTTP 200 from /api/algolia (verified). The raw
+ * domain (and /robots.txt) is Cloudflare-challenged (403) on a plain bot fetch;
+ * LiveFetcher treats a non-2xx robots.txt as "no robots.txt → allow all"
+ * (fail-open, standard convention), and the /api/algolia path itself answers 200,
+ * so collection proceeds without circumventing any protection. If Cloudflare ever
+ * starts challenging the API endpoint too (403), LiveFetcher STOPs (BlockedError)
+ * and the source is marked degraded upstream — we never bypass a challenge.
+ *
+ * Batch control: LANDSEA_PAGE_LIMIT (pages of ~120 homesites, default 10).
+ */
+
+const API_BASE = "https://risewellhomes.com/api/algolia/search";
+const ALGOLIA_INDEX = "wp_posts_homesites";
+const BUILDER_SLUG = "landsea-homes";
+const PAGE_LIMIT = Number(process.env.LANDSEA_PAGE_LIMIT ?? 10);
+
+/** Build the base64 `query` param for a given 0-indexed Algolia page. */
+function queryUrl(page: number): string {
+  const payload = { index: ALGOLIA_INDEX, query: "", hitsPerPage: 1000, page };
+  const b64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
+  return `${API_BASE}?query=${encodeURIComponent(b64)}`;
+}
+
+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/garages/stories 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;
+};
+const zip5 = (v: unknown): string | null => {
+  const s = str(v);
+  if (!s) return null;
+  const m = s.match(/\b(\d{5})\b/);
+  return m ? m[1]! : null;
+};
+
+interface Geoloc {
+  lat?: number;
+  lng?: number;
+}
+interface HomesiteAddress {
+  address1?: string;
+  city?: string;
+  state?: string; // full name, e.g. "Texas"
+  zip?: string | number;
+  county?: string;
+}
+interface Named {
+  id?: number;
+  name?: string;
+}
+interface Homesite {
+  objectID?: string;
+  name?: string;
+  status?: string; // "Move-In Ready", "Under Construction", …
+  moveInWindow?: string; // "1-3 mo.", "Ready Now", …
+  url?: string;
+  address?: HomesiteAddress;
+  neighborhood?: Named; // community
+  floorplan?: Named; // plan
+  region?: Named; // full state name
+  bedrooms?: number;
+  baths?: number;
+  sq_feet_total?: number;
+  stories?: number;
+  cars_spaces?: number;
+  price?: number;
+  base_price?: number;
+  _geoloc?: Geoloc;
+}
+interface AlgoliaResponse {
+  hits?: Homesite[];
+  nbHits?: number;
+  nbPages?: number;
+  page?: number; // the server echoes the page it actually served
+  hitsPerPage?: number;
+}
+
+/** Resolve a 2-letter state code from a homesite, preferring the full region
+ *  name but falling back to address.state. region.name carries marketing regions
+ *  like "Southern California" / "Northern California" that don't normalize; in
+ *  those rows address.state is the real "California", so the fallback recovers
+ *  them instead of dropping the state. Returns null only when neither resolves. */
+function resolveState(h: Homesite): { code: string | null; raw: string | null } {
+  const addr = h.address ?? {};
+  const regionRaw = str(h.region?.name);
+  const addrRaw = str(addr.state);
+  const fromRegion = normalizeStateCode(regionRaw);
+  if (fromRegion) return { code: fromRegion, raw: regionRaw };
+  const fromAddr = normalizeStateCode(addrRaw);
+  if (fromAddr) return { code: fromAddr, raw: addrRaw };
+  return { code: null, raw: regionRaw ?? addrRaw };
+}
+
+function parseResponse(body: string): AlgoliaResponse | null {
+  try {
+    const parsed = JSON.parse(body) as AlgoliaResponse;
+    return parsed && Array.isArray(parsed.hits) ? parsed : null;
+  } catch {
+    return null;
+  }
+}
+
+/** { lat, lng } → { lat, lon } (US lon is negative — signs preserved; 0/NaN → null). */
+function parseGeoloc(geo: Geoloc | undefined): { lat: number | null; lon: number | null } {
+  const lat = typeof geo?.lat === "number" ? geo.lat : NaN;
+  const lon = typeof geo?.lng === "number" ? geo.lng : NaN;
+  return {
+    lat: Number.isFinite(lat) && lat !== 0 ? lat : null,
+    lon: Number.isFinite(lon) && lon !== 0 ? lon : null,
+  };
+}
+
+/** Map Landsea status / moveInWindow → our construction-status enum.
+ *  "Move-In Ready" / "Ready Now" = finished; "Under Construction" = in progress.
+ *  Unrecognized/blank → null (never guessed). */
+function constructionStatus(status: unknown, moveIn: unknown): "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+  const s = `${str(status) ?? ""} ${str(moveIn) ?? ""}`.toLowerCase();
+  if (!s.trim()) return null;
+  if (s.includes("move-in ready") || s.includes("move in ready") || s.includes("ready now")) return "MOVE_IN_READY";
+  if (s.includes("under construction") || s.includes("construction")) return "UNDER_CONSTRUCTION";
+  return null;
+}
+
+export const landseaAdapter: SourceAdapter = {
+  key: "landsea-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);
+    for (let pageNo = 0; pageNo < Math.max(1, PAGE_LIMIT); pageNo++) {
+      let page: RawPage;
+      try {
+        page = await fetcher.fetch(queryUrl(pageNo));
+      } catch (error) {
+        console.warn(`  landsea page=${pageNo}: ${error instanceof Error ? error.message : String(error)}`);
+        return; // a block/403 stops collection (source marked degraded upstream)
+      }
+      const resp = parseResponse(page.body.toString("utf8"));
+      const servedPage = Number(resp?.page ?? 0);
+      // Paging guard (checked BEFORE yield so the duplicate is never ingested):
+      // this proxy ignores the requested page and always serves page 0. If we
+      // asked for page N>0 but the server echoes a different page, paging is a
+      // no-op — stop without yielding (would re-ingest the same 120 homes).
+      if (pageNo > 0 && servedPage !== pageNo) return;
+      yield page;
+      const got = resp?.hits?.length ?? 0;
+      const nbPages = Number(resp?.nbPages ?? 0);
+      // Normal termination: empty page, unparseable body, or last Algolia page.
+      if (!resp || got === 0 || (nbPages > 0 && pageNo + 1 >= nbPages)) return;
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const resp = parseResponse(page.body.toString("utf8"));
+      if (!resp) {
+        return { records: [], errors: [{ url: page.url, reason: "no hits array in Algolia response" }] };
+      }
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      for (const h of resp.hits ?? []) {
+        const addr = h.address ?? {};
+        // region.name first, falling back to address.state (handles marketing
+        // regions like "Southern California" whose real state is address.state).
+        const { code: state, raw: stateRaw } = resolveState(h);
+        const city = str(addr.city);
+        const zip = zip5(addr.zip);
+        const county = str(addr.county);
+        const address = str(addr.address1);
+        const community = str(h.neighborhood?.name);
+        const plan = str(h.floorplan?.name);
+        const { lat, lon } = parseGeoloc(h._geoloc);
+        const price = posNum(h.price);
+        const beds = posNum(h.bedrooms);
+        const bathsTotal = nonNegNum(h.baths);
+        const sqft = posNum(h.sq_feet_total);
+        const stories = posNum(h.stories);
+        const garages = nonNegNum(h.cars_spaces);
+        const homeId = str(h.objectID);
+        const url = str(h.url) ?? page.url;
+        const cStatus = constructionStatus(h.status, h.moveInWindow);
+
+        if (!address) {
+          errors.push({ url, reason: `homesite ${homeId ?? "?"} missing address1 — skipped` });
+          continue;
+        }
+        // The publisher requires an inventory home to hang off a community (FK).
+        // A homesite the feed leaves community-less can't be published — skip it
+        // and log it honestly rather than stage a record that crashes at publish.
+        if (!community) {
+          errors.push({ url, reason: `homesite ${address} has no neighborhood.name — cannot attach to a community, skipped` });
+          continue;
+        }
+
+        // Community FIRST — publish creates the FK target the home record needs.
+        records.push({
+          entityType: "community",
+          canonicalHints: { builderSlug: BUILDER_SLUG, communityName: community },
+          fields: {
+            name: fv(community, community, url, "Algolia neighborhood.name"),
+            street: fv<string>(null, null, url),
+            city: fv(city, city, url),
+            state: fv(state, stateRaw, url),
+            zip: fv(zip, str(addr.zip), url),
+            county: fv(county, county, url),
+            metro: fv<string>(null, null, url),
+            lat: fv(lat, null, url),
+            lon: fv(lon, 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: community,
+            address,
+            builderInventoryId: homeId ?? undefined,
+            lat: lat ?? undefined,
+            lon: lon ?? undefined,
+            planName: plan ?? undefined,
+          },
+          fields: {
+            street: fv(address, address, url, "Algolia address.address1"),
+            city: fv(city, city, url),
+            state: fv(state, stateRaw, url),
+            zip: fv(zip, str(addr.zip), url),
+            price: fv(price, price === null ? null : String(h.price), url, price === null ? null : `Algolia price ${h.price}`),
+            beds: fv(beds, beds === null ? null : String(h.bedrooms), url),
+            bathsTotal: fv(bathsTotal, bathsTotal === null ? null : String(h.baths), url),
+            sqft: fv(sqft, sqft === null ? null : String(h.sq_feet_total), url),
+            stories: fv(stories, stories === null ? null : String(h.stories), url),
+            garageSpaces: fv(garages, garages === null ? null : String(h.cars_spaces), url),
+            homeType: fv("SINGLE_FAMILY" as const, null, url, "Landsea single-family homesite"),
+            constructionStatus: fv(cStatus, str(h.status), url, cStatus === null ? null : `status: ${str(h.status)} / moveIn: ${str(h.moveInWindow)}`),
+            estCompletionDate: fv<string>(null, null, url),
+            lotNumber: fv<string>(null, null, url),
+            builderInventoryId: fv(homeId, homeId, url),
+            lat: fv(lat, null, url, lat === null ? null : "Algolia _geoloc"),
+            lon: fv(lon, null, url, lon === null ? null : "Algolia _geoloc"),
+            planName: fv(plan, plan, url),
+            // facts-only: main_image_* exist in the feed but are intentionally dropped.
+            images: fv<string[]>([], null, url),
+          },
+        });
+      }
+      return { records, errors };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/collectors/landsea-homes/tsconfig.json b/collectors/landsea-homes/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/landsea-homes/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 209f25c3..a8a66f65 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,9 +157,15 @@ importers:
       '@homesonspec/collector-dream-finders':
         specifier: workspace:*
         version: link:../../collectors/dream-finders
+      '@homesonspec/collector-highland-homes':
+        specifier: workspace:*
+        version: link:../../collectors/highland-homes
       '@homesonspec/collector-kb-home':
         specifier: workspace:*
         version: link:../../collectors/kb-home
+      '@homesonspec/collector-landsea-homes':
+        specifier: workspace:*
+        version: link:../../collectors/landsea-homes
       '@homesonspec/collector-lennar':
         specifier: workspace:*
         version: link:../../collectors/lennar
@@ -172,6 +178,9 @@ importers:
       '@homesonspec/collector-pulte':
         specifier: workspace:*
         version: link:../../collectors/pulte
+      '@homesonspec/collector-ryan-homes':
+        specifier: workspace:*
+        version: link:../../collectors/ryan-homes
       '@homesonspec/collector-taylor-morrison':
         specifier: workspace:*
         version: link:../../collectors/taylor-morrison
@@ -348,6 +357,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/highland-homes:
+    dependencies:
+      '@homesonspec/collectors-common':
+        specifier: workspace:*
+        version: link:../common
+      '@homesonspec/schemas':
+        specifier: workspace:*
+        version: link:../../packages/schemas
+      '@homesonspec/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/kb-home:
     dependencies:
       '@homesonspec/collectors-common':
@@ -370,6 +401,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/landsea-homes:
+    dependencies:
+      '@homesonspec/collectors-common':
+        specifier: workspace:*
+        version: link:../common
+      '@homesonspec/schemas':
+        specifier: workspace:*
+        version: link:../../packages/schemas
+      '@homesonspec/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/lennar:
     dependencies:
       '@homesonspec/collectors-common':
@@ -461,6 +514,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/ryan-homes:
+    dependencies:
+      '@homesonspec/collectors-common':
+        specifier: workspace:*
+        version: link:../common
+      '@homesonspec/schemas':
+        specifier: workspace:*
+        version: link:../../packages/schemas
+      '@homesonspec/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/taylor-morrison:
     dependencies:
       '@homesonspec/collectors-common':
diff --git a/scripts/build-loop.sh b/scripts/build-loop.sh
index ce4eab60..0b2db881 100644
--- a/scripts/build-loop.sh
+++ b/scripts/build-loop.sh
@@ -89,6 +89,18 @@ sweep_discovery() { CLI discovery-homes-site | sed "s/^/  dsc REGIONAL: /"; }
 # x100/page uncaps it (well above the ~26 pages the full inventory needs). 100% price/beds/baths/sqft.
 sweep_meritage() { MERITAGE_PAGE_LIMIT=100 CLI meritage-homes-site | sed "s/^/  mer NATIONAL: /"; }
 
+# Ryan Homes (NVR) = single NATIONAL server-rendered QMI page (~505 homes, 17 states) parsed in ONE
+# GET — no per-state param, no XHR. Run ONCE per sweep like Meritage. 100% price/beds/baths coverage.
+sweep_ryan() { RYAN_PAGE_LIMIT=50 CLI ryan-homes-site | sed "s/^/  rya NATIONAL: /"; }
+
+# Highland Homes = landing communities blob + per-community QMI pages (~111 communities, mostly TX,
+# ~756 homes). Multi-page fetch (rate-limited), run ONCE per sweep; PAGE_LIMIT=150 covers all communities.
+sweep_highland() { HIGHLAND_PAGE_LIMIT=150 CLI highland-homes-site | sed "s/^/  hld REGIONAL: /"; }
+
+# Landsea/Risewell = single Algolia proxy GET (public, no auth). The proxy hard-caps paging at 120 of
+# ~503 homesites (documented limit); adapter stops before the dup page. Run ONCE per sweep.
+sweep_landsea() { LANDSEA_PAGE_LIMIT=10 CLI landsea-homes-site | sed "s/^/  lnd REGIONAL: /"; }
+
 # metro-iterating adapters (footprint = METROS, not states) — scoped to served metros only
 # (Cody WAF-lesson from the start). Ashton Woods publishes per-metro quick-move-in homes.
 sweep_metros() {
@@ -107,7 +119,7 @@ while pgrep -f "cli.ts --adapter" 2>/dev/null | grep -qv "^$$\$"; do sleep 20; d
 # a transient failure shouldn't permanently sideline a chosen builder, and genuine breakage is
 # surfaced by the dashboard's per-source failed_runs + the freshness canary (not silent). To
 # retire a builder, remove it from THIS list (don't rely on auto-pause surviving a restart).
-for k in dr-horton-site kb-home-site pultegroup-site tri-pointe-site lennar-site toll-brothers-site david-weekley-site taylor-morrison-site ashton-woods-site discovery-homes-site meritage-homes-site; do
+for k in dr-horton-site kb-home-site pultegroup-site tri-pointe-site lennar-site toll-brothers-site david-weekley-site taylor-morrison-site ashton-woods-site discovery-homes-site meritage-homes-site ryan-homes-site highland-homes-site landsea-homes-site; do
   PSQL "update \"SourceRegistry\" set active=true, health='HEALTHY', \"consecutiveFailures\"=0 where key='$k';" >/dev/null
 done
 
@@ -117,10 +129,13 @@ while [ ! -f "$STOP" ] && [ "$sweep" -lt "$MAX_SWEEPS" ]; do
   s0=$(PSQL "select count(*) from \"InventoryHome\" where status='PUBLISHED';")
   b0=$(PSQL "select count(distinct \"builderId\") from \"InventoryHome\" where status='PUBLISHED';")
   LOG "===== SWEEP $sweep/$MAX_SWEEPS start: $s0 homes / $b0 builders ====="
-  LOG "fanning out 4 state adapters + KB(national) + Meritage(national) + AW(metros) + Toll(throttled) across ${CAP}-deep states (West Coast first)"
+  LOG "fanning out 4 state adapters + KB/Meritage/Ryan(national) + Highland/Landsea/Discovery(regional) + AW(metros) + Toll(throttled) across ${CAP}-deep states (West Coast first)"
   sweep_kb          &
   sweep_discovery   &
   sweep_meritage    &
+  sweep_ryan        &
+  sweep_highland    &
+  sweep_landsea     &
   sweep_adapter drh &
   sweep_adapter plt &
   sweep_adapter tri &

← 0c5a3968 Add Ryan Homes (NVR) collector adapter  ·  back to Homesonspec  ·  pulte: capture listing photos (feed stores image URL under ' 20b71fae →