← 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
M apps/workers/package.jsonM apps/workers/src/cli.tsA collectors/highland-homes/package.jsonA collectors/highland-homes/src/index.tsA collectors/highland-homes/tsconfig.jsonA collectors/landsea-homes/package.jsonA collectors/landsea-homes/src/index.tsA collectors/landsea-homes/tsconfig.jsonM pnpm-lock.yamlM scripts/build-loop.sh
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 →