[object Object]

← back to Homesonspec

Add Century Communities scraper adapter (two-layer: findcommunities API + sitemap -> per-home QMI cards)

a867626b2de1225807e69779423165320f8f8130 · 2026-07-28 17:09:35 -0700 · Steve

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

Files touched

Diff

commit a867626b2de1225807e69779423165320f8f8130
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 17:09:35 2026 -0700

    Add Century Communities scraper adapter (two-layer: findcommunities API + sitemap -> per-home QMI cards)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 collectors/century-communities/package.json  |  15 +
 collectors/century-communities/src/index.ts  | 598 +++++++++++++++++++++++++++
 collectors/century-communities/tsconfig.json |   1 +
 3 files changed, 614 insertions(+)

diff --git a/collectors/century-communities/package.json b/collectors/century-communities/package.json
new file mode 100644
index 00000000..c35abd66
--- /dev/null
+++ b/collectors/century-communities/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@homesonspec/collector-century-communities",
+  "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/century-communities/src/index.ts b/collectors/century-communities/src/index.ts
new file mode 100644
index 00000000..6499332e
--- /dev/null
+++ b/collectors/century-communities/src/index.ts
@@ -0,0 +1,598 @@
+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";
+
+/**
+ * Century Communities adapter — JSON-API list + SERVER-RENDERED per-home source
+ * (recon 2026-07-28).
+ *
+ * centurycommunities.com runs an EPiServer/Optimizely CMS. Two layers:
+ *
+ * LAYER 1 — community list.
+ *   GET https://www.centurycommunities.com/api/search/findcommunities?…&latitude=…&longitude=…&radius=…
+ *   No auth. Returns { communities:[ { name, cityStateZip, startingPrice, url,
+ *     quickMoveInAvailable, latitude, longitude, zipCode, communityId, bed, bath,
+ *     sqft, address1 … } ], total, showLoadMore }. The community rows are
+ *   AGGREGATES (startingPrice; bed/bath/sqft/address1 are null) — NOT per-home.
+ *   VERIFIED CAP: this endpoint always returns the same fixed 12 rows regardless
+ *   of latitude/longitude/radius/page/skip/offset/pageSize (total=438,
+ *   showLoadMore=true forever) — server-side paging is not exposed on it. So the
+ *   API alone can only seed 12 communities. To reach the full ~490-community
+ *   universe we ALSO read the CMS sitemap (/sitemap.xml, 8k+ URLs), whose
+ *   community pages match /find-your-new-home/{state}/{metro}/{city}/{community}[/{collection}]/
+ *   (not /lots/, /plans/, /promo). Every findcommunities url is already a subset
+ *   of the sitemap, so the two are merged + de-duped.
+ *
+ * LAYER 2 — per-home quick-move-in (QMI) inventory, SERVER-RENDERED on each
+ *   community page (community.url). Each available spec home is one
+ *     <li class="floor_plan_contain card quick-move-in-card"
+ *         data-price data-sqft data-date data-template="QuickMoveInCommerceBlockCard">
+ *   carrying: data-price ($), data-sqft, an <h4 .street-number><a href=".../lots/…">
+ *   <span .street-number-text>"2641 Gibraltar Drive | Lot 0187"</span></a>,
+ *   <span .title> plan name, <p .home-type> ("Single Family Home"), a
+ *   <span .est-complete-date> status ("Move-in Ready!" / "Est. Completion: Aug…"),
+ *   and Bedrooms / Bathrooms / Square Footage icon rows (real ints/decimals).
+ *   The community page's ld+json (Place / HomeAndConstructionBusiness) carries the
+ *   community geo (GeoCoordinates lat/lng) + PostalAddress (city/region/postal),
+ *   which is authoritative and injected onto each home.
+ *
+ * robots.txt (centurycommunities.com) is `User-agent: *` with a Disallow list that
+ * covers /EPiServer/*, /util/, /purchase/*, /site-data/* etc. — NONE of which
+ * touch /api/search/findcommunities, /sitemap.xml, or /find-your-new-home/* → all
+ * our paths are allowed. Facts-only: the images[] feed + card carousels are dropped.
+ *
+ * Batch control:  CENTURY_PAGE_LIMIT  (community pages to crawl, default 10)
+ */
+
+const ORIGIN = "https://www.centurycommunities.com";
+const SITEMAP_URL = `${ORIGIN}/sitemap.xml`;
+const BUILDER_SLUG = "century-communities";
+const PAGE_LIMIT = Number(process.env.CENTURY_PAGE_LIMIT ?? 10);
+const META_MARKER = "century-community:";
+// National geo center + max radius — the endpoint ignores these, but they keep the
+// request well-formed and self-documenting.
+const FINDCOMMUNITIES_URL =
+  `${ORIGIN}/api/search/findcommunities?minPrice=0&maxPrice=999999999999&bedrooms=0` +
+  `&bathrooms=0&minSquareFeet=0&maxSquareFeet=99999&latitude=39.8&longitude=-98.5&radius=99999`;
+
+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 small; 0 allowed), 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;
+};
+/** Preserved signs: US lon is negative; a stringified "-121.9…" must stay negative. */
+const 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;
+};
+
+// ---------------------------------------------------------------------------
+// LAYER 1 — community list (findcommunities API + sitemap)
+// ---------------------------------------------------------------------------
+
+interface FindCommunity {
+  name?: string;
+  cityStateZip?: string;
+  startingPrice?: number;
+  url?: string;
+  quickMoveInAvailable?: boolean;
+  latitude?: string | number;
+  longitude?: string | number;
+  zipCode?: string | number;
+  communityId?: number;
+}
+
+/** The community metadata injected into a community-page RawPage so extract()
+ *  can attach the API's quick-move-in flag / starting price / community id. */
+interface InjectedMeta {
+  apiName: string | null;
+  startingPrice: number | null;
+  quickMoveInAvailable: boolean | null;
+  communityId: string | null;
+  apiLat: number | null;
+  apiLon: number | null;
+  apiZip: string | null;
+}
+
+/** Parse the findcommunities JSON body → community rows. Returns [] on any failure. */
+export function parseFindCommunities(body: string): FindCommunity[] {
+  try {
+    const j = JSON.parse(body) as { communities?: FindCommunity[] };
+    return Array.isArray(j.communities) ? j.communities : [];
+  } catch {
+    return [];
+  }
+}
+
+/** Absolute community-page URL from a relative `communities[].url`. */
+function absUrl(relOrAbs: string): string {
+  if (/^https?:\/\//.test(relOrAbs)) return relOrAbs;
+  return `${ORIGIN}${relOrAbs.startsWith("/") ? "" : "/"}${relOrAbs}`;
+}
+
+/** A community page path (not a lot/plan/promo detail):
+ *  /find-your-new-home/{state}/{metro}/{city}/{community}[/{collection}]/  */
+function isCommunityPageUrl(u: string): boolean {
+  let path: string;
+  try {
+    path = new URL(u).pathname;
+  } catch {
+    return false;
+  }
+  if (!path.startsWith("/find-your-new-home/")) return false;
+  if (path.includes("/lots/") || path.includes("/plans/") || path.includes("/promo")) return false;
+  const segs = path.replace(/\/+$/, "").split("/").filter(Boolean);
+  // ["find-your-new-home", state, metro, city, community] (5) or +collection (6)
+  return segs.length === 5 || segs.length === 6;
+}
+
+/** Pull community-page <loc> URLs out of the CMS sitemap XML. */
+export function parseSitemapCommunityUrls(xml: string): string[] {
+  const out: string[] = [];
+  const seen = new Set<string>();
+  for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
+    const u = m[1]!.trim();
+    if (isCommunityPageUrl(u) && !seen.has(u)) {
+      seen.add(u);
+      out.push(u);
+    }
+  }
+  return out;
+}
+
+/** Build the ordered, de-duped community-page crawl list: findcommunities rows
+ *  first (they carry the QMI flag + geo), then any sitemap community pages not
+ *  already seeded. `metaByUrl` maps a normalized URL → the API meta to inject. */
+export function buildCrawlList(
+  apiCommunities: FindCommunity[],
+  sitemapUrls: string[],
+): { urls: string[]; metaByUrl: Map<string, InjectedMeta> } {
+  const urls: string[] = [];
+  const seen = new Set<string>();
+  const metaByUrl = new Map<string, InjectedMeta>();
+
+  const norm = (u: string) => u.replace(/\/+$/, "/").toLowerCase();
+
+  // Prefer communities the API says HAVE quick-move-in inventory (crawl budget is
+  // scarce), then the rest of the API rows, then the sitemap universe.
+  const apiWithQmi = apiCommunities.filter((c) => str(c.url) && c.quickMoveInAvailable);
+  const apiRest = apiCommunities.filter((c) => str(c.url) && !c.quickMoveInAvailable);
+
+  for (const c of [...apiWithQmi, ...apiRest]) {
+    const u = absUrl(String(c.url));
+    const key = norm(u);
+    if (seen.has(key)) continue;
+    seen.add(key);
+    urls.push(u);
+    const zipFromCsz = str(c.cityStateZip)?.match(/\b(\d{5})\b/)?.[1] ?? null;
+    metaByUrl.set(key, {
+      apiName: str(c.name),
+      startingPrice: posNum(c.startingPrice),
+      quickMoveInAvailable: c.quickMoveInAvailable ?? null,
+      communityId: str(c.communityId),
+      apiLat: toCoord(c.latitude),
+      apiLon: toCoord(c.longitude),
+      apiZip: zip5(c.zipCode) ?? zipFromCsz,
+    });
+  }
+  for (const u of sitemapUrls) {
+    const key = norm(u);
+    if (seen.has(key)) continue;
+    seen.add(key);
+    urls.push(u);
+  }
+  return { urls, metaByUrl };
+}
+
+function encodeMeta(meta: InjectedMeta | undefined): string {
+  return JSON.stringify(meta ?? null);
+}
+function readInjectedMeta(html: string): InjectedMeta | null {
+  const m = html.match(new RegExp(`<!--\\s*${META_MARKER}\\s*([\\s\\S]*?)\\s*-->`));
+  if (!m) return null;
+  try {
+    const v = JSON.parse(m[1]!);
+    return v && typeof v === "object" ? (v as InjectedMeta) : null;
+  } catch {
+    return null;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// LAYER 2 — per-home QMI cards + community ld+json geo (parsed from page HTML)
+// ---------------------------------------------------------------------------
+
+interface CommunityLd {
+  name: string | null;
+  street: string | null;
+  city: string | null;
+  state: string | null;
+  zip: string | null;
+  metro: string | null;
+  lat: number | null;
+  lon: number | null;
+}
+
+/** Read the community's geo/address + metro from the page's ld+json (Place /
+ *  HomeAndConstructionBusiness carry geo+address; a custom block carries the
+ *  metro/city names). Any field absent → null. */
+export function parseCommunityLd(html: string): CommunityLd {
+  const out: CommunityLd = {
+    name: null, street: null, city: null, state: null, zip: null, metro: null, lat: null, lon: null,
+  };
+  for (const m of html.matchAll(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)) {
+    let j: any;
+    try {
+      j = JSON.parse(m[1]!.trim());
+    } catch {
+      continue;
+    }
+    const type = j?.["@type"];
+    if ((type === "Place" || type === "HomeAndConstructionBusiness") && j.geo) {
+      out.lat ??= toCoord(j.geo.latitude);
+      out.lon ??= toCoord(j.geo.longitude);
+      if (j.address) {
+        out.street ??= str(j.address.streetAddress);
+        out.city ??= str(j.address.addressLocality);
+        out.state ??= normalizeStateCode(str(j.address.addressRegion));
+        out.zip ??= zip5(j.address.postalCode);
+      }
+      out.name ??= str(j.name);
+    }
+    // The custom EPiServer block: { CommunityName, CityName, StateName, MetroName }
+    if (j && (j.CommunityName || j.MetroName)) {
+      out.name ??= str(j.CommunityName);
+      out.metro ??= str(j.MetroName);
+      out.city ??= str(j.CityName);
+      out.state ??= normalizeStateCode(str(j.StateName));
+    }
+  }
+  return out;
+}
+
+interface QmiHome {
+  price: number | null;
+  sqft: number | null;
+  address: string | null; // street portion, "Lot NNNN" stripped
+  lotNumber: string | null;
+  lotUrl: string | null;
+  plan: string | null;
+  homeType: string | null;
+  status: string | null;
+  beds: number | null;
+  baths: number | null;
+}
+
+/** Slice out each <li …quick-move-in-card… data-template="QuickMoveInCommerceBlockCard">
+ *  … </li> block by balancing nested <li>/<\/li>. */
+function sliceCards(html: string): string[] {
+  const cards: string[] = [];
+  const marker = 'data-template="QuickMoveInCommerceBlockCard"';
+  let from = 0;
+  let at: number;
+  while ((at = html.indexOf(marker, from)) >= 0) {
+    const start = html.lastIndexOf("<li", at);
+    if (start < 0) {
+      from = at + marker.length;
+      continue;
+    }
+    let depth = 0;
+    let j = start;
+    let end = -1;
+    while (j < html.length) {
+      if (html.startsWith("<li", j)) {
+        depth++;
+        j += 3;
+        continue;
+      }
+      if (html.startsWith("</li>", j)) {
+        depth--;
+        if (depth === 0) {
+          end = j + 5;
+          break;
+        }
+        j += 5;
+        continue;
+      }
+      j++;
+    }
+    if (end < 0) {
+      from = at + marker.length;
+      continue;
+    }
+    cards.push(html.slice(start, end));
+    from = end;
+  }
+  return cards;
+}
+
+/** "2641 Gibraltar Drive | Lot 0187" → { address, lotNumber }. */
+function splitAddress(raw: string | null): { address: string | null; lotNumber: string | null } {
+  const s = str(raw);
+  if (!s) return { address: null, lotNumber: null };
+  const m = s.match(/^(.*?)\s*\|\s*Lot\s*([A-Za-z0-9-]+)\s*$/i);
+  if (m) return { address: str(m[1]), lotNumber: str(m[2]) };
+  return { address: s, lotNumber: null };
+}
+
+export function parseQmiCards(html: string): QmiHome[] {
+  const g = (card: string, re: RegExp): string | null => {
+    const m = card.match(re);
+    return m ? str(m[1]!.replace(/\s+/g, " ")) : null;
+  };
+  return sliceCards(html).map((card) => {
+    const clean = card.replace(/<svg[\s\S]*?<\/svg>/g, "");
+    const addrRaw = g(clean, /street-number-text"[^>]*>([^<]+)</);
+    const { address, lotNumber } = splitAddress(addrRaw);
+    const bedbath = (label: string): number | null => {
+      const m = clean.match(new RegExp(`alt="${label}"[\\s\\S]{0,220}?<span>\\s*([0-9.,]+)`));
+      return m ? nonNegNum(m[1]) : null;
+    };
+    // status: the est-complete-date span may carry "Move-in Ready!" or "Est.
+    // Completion: <date>" (possibly after a <br/> and label). Take its trailing text.
+    let status = g(clean, /est-complete-date"[^>]*>[\s\S]*?<br\s*\/?>\s*([^<]+)</);
+    if (!status) status = g(clean, /est-complete-date"[^>]*>\s*([^<]+?)\s*<\/span>/);
+    if (status) status = status.replace(/Est\.\s*Completion:\s*/i, "").trim() || null;
+    return {
+      price: posNum(g(clean, /data-price="([^"]+)"/)),
+      sqft: posNum(g(clean, /data-sqft="([^"]+)"/)),
+      address,
+      lotNumber,
+      lotUrl: g(clean, /<a href="([^"]*\/lots\/[^"]*)"/),
+      plan: g(clean, /<span class="title">([^<]+)</),
+      homeType: g(clean, /home-type"[^>]*>([^<]+)</),
+      status,
+      beds: posNum(bedbath("Bedrooms")),
+      baths: nonNegNum(bedbath("Bathrooms")),
+    };
+  });
+}
+
+/** Century status text → construction-status enum. "Move-in Ready!" = finished;
+ *  a dated / "Under Construction" text = in progress. Blank/unknown → null. */
+function constructionStatus(status: string | null): "UNDER_CONSTRUCTION" | "MOVE_IN_READY" | null {
+  const s = (str(status) ?? "").toLowerCase();
+  if (!s) return null;
+  if (s.includes("move-in ready") || s.includes("move in ready") || s.includes("ready now")) return "MOVE_IN_READY";
+  // A month/date estimate ("Aug. Move In", "Oct '26") or explicit under-construction = in progress.
+  if (s.includes("under construction") || s.includes("coming") || /move[\s-]?in/.test(s) || /'?\d{2}\b|\d{4}/.test(s)) {
+    return "UNDER_CONSTRUCTION";
+  }
+  return null;
+}
+
+// ---------------------------------------------------------------------------
+// Adapter
+// ---------------------------------------------------------------------------
+
+export const centuryAdapter: SourceAdapter = {
+  key: "century-communities-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);
+
+    // LAYER 1a — findcommunities (geo-enriched seed; also emits community records).
+    let apiCommunities: FindCommunity[] = [];
+    try {
+      const api = await fetcher.fetch(FINDCOMMUNITIES_URL);
+      yield api; // extract() emits a community record per API row
+      apiCommunities = parseFindCommunities(api.body.toString("utf8"));
+    } catch (error) {
+      console.warn(`  century findcommunities: ${error instanceof Error ? error.message : String(error)}`);
+      // Non-fatal — the sitemap can still seed the crawl.
+    }
+
+    // LAYER 1b — sitemap (the full ~490-community universe; the API caps at 12).
+    let sitemapUrls: string[] = [];
+    try {
+      const sm = await fetcher.fetch(SITEMAP_URL);
+      sitemapUrls = parseSitemapCommunityUrls(sm.body.toString("utf8"));
+    } catch (error) {
+      console.warn(`  century sitemap: ${error instanceof Error ? error.message : String(error)}`);
+    }
+
+    const { urls, metaByUrl } = buildCrawlList(apiCommunities, sitemapUrls);
+
+    // LAYER 2 — crawl community pages, parse per-home QMI cards.
+    const norm = (u: string) => u.replace(/\/+$/, "/").toLowerCase();
+    for (const url of urls.slice(0, Math.max(0, PAGE_LIMIT))) {
+      let page: RawPage;
+      try {
+        page = await fetcher.fetch(url);
+      } catch (error) {
+        console.warn(`  century community ${url}: ${error instanceof Error ? error.message : String(error)}`);
+        continue; // one blocked/failed community page doesn't stop the rest
+      }
+      const meta = encodeMeta(metaByUrl.get(norm(url)));
+      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");
+
+      // ---- findcommunities response: emit a community record per API row -----
+      if (page.url.includes("/api/search/findcommunities")) {
+        const communities = parseFindCommunities(html);
+        const records: ExtractedRecord[] = [];
+        for (const c of communities) {
+          const name = str(c.name);
+          if (!name) continue;
+          const csz = str(c.cityStateZip); // "Port Charlotte, FL 33981"
+          let city: string | null = null;
+          let state: string | null = null;
+          if (csz) {
+            const m = csz.match(/^(.*?),\s*([A-Za-z]{2})\s+\d{5}/);
+            if (m) {
+              city = str(m[1]);
+              state = normalizeStateCode(m[2]!);
+            } else {
+              city = str(csz.split(",")[0] ?? null);
+            }
+          }
+          const zip = zip5(c.zipCode) ?? (csz ? zip5(csz) : null);
+          const lat = toCoord(c.latitude);
+          const lon = toCoord(c.longitude);
+          records.push({
+            entityType: "community",
+            canonicalHints: { builderSlug: BUILDER_SLUG, communityName: name },
+            fields: {
+              name: fv(name, name, page.url, "findcommunities communities[].name"),
+              street: fv<string>(null, null, page.url),
+              city: fv(city, city, page.url),
+              state: fv(state, state === null ? null : csz, page.url),
+              zip: fv(zip, zip, page.url),
+              county: fv<string>(null, null, page.url),
+              metro: fv<string>(null, null, page.url),
+              lat: fv(lat, lat === null ? null : String(c.latitude), page.url, lat === null ? null : "communities[].latitude"),
+              lon: fv(lon, lon === null ? null : String(c.longitude), page.url, lon === null ? null : "communities[].longitude"),
+              hoaFeeMonthly: fv<number>(null, null, page.url),
+              schoolDistrict: fv<string>(null, null, page.url),
+              ageRestricted: fv<boolean>(null, null, page.url),
+            },
+          });
+        }
+        if (!records.length) {
+          return { records: [], errors: [{ url: page.url, reason: "findcommunities returned no named communities" }] };
+        }
+        return { records, errors: [] };
+      }
+
+      // ---- community page: emit community (from ld+json) + its QMI homes ------
+      const ld = parseCommunityLd(html);
+      const meta = readInjectedMeta(html);
+      const cards = parseQmiCards(html);
+
+      // Community name: ld+json first, API meta as fallback.
+      const community = ld.name ?? meta?.apiName ?? null;
+      const city = ld.city ?? null;
+      const state = ld.state ?? null;
+      const zip = ld.zip ?? meta?.apiZip ?? null;
+      const lat = ld.lat ?? meta?.apiLat ?? null;
+      const lon = ld.lon ?? meta?.apiLon ?? null;
+      const metro = ld.metro ?? null;
+
+      if (!community) {
+        return { records: [], errors: [{ url: page.url, reason: "community page had no ld+json name and no injected API meta" }] };
+      }
+
+      const records: ExtractedRecord[] = [];
+      const errors: { url: string; reason: string }[] = [];
+
+      // Community FIRST — publish creates/refreshes the FK target the homes need.
+      records.push({
+        entityType: "community",
+        canonicalHints: { builderSlug: BUILDER_SLUG, communityName: community },
+        fields: {
+          name: fv(community, community, page.url, "community-page ld+json"),
+          street: fv(ld.street, ld.street, page.url),
+          city: fv(city, city, page.url),
+          state: fv(state, ld.state === null ? null : "ld+json addressRegion", page.url),
+          zip: fv(zip, zip, page.url),
+          county: fv<string>(null, null, page.url),
+          metro: fv(metro, metro, page.url),
+          lat: fv(lat, lat === null ? null : String(lat), page.url, lat === null ? null : "ld+json geo.latitude"),
+          lon: fv(lon, lon === null ? null : String(lon), page.url, lon === null ? null : "ld+json geo.longitude"),
+          hoaFeeMonthly: fv<number>(null, null, page.url),
+          schoolDistrict: fv<string>(null, null, page.url),
+          ageRestricted: fv<boolean>(null, null, page.url),
+        },
+      });
+
+      if (!cards.length) {
+        // A community page with no QMI inventory is normal (aggregate-only /
+        // sold-out community). We still emitted the community; note it and move on.
+        errors.push({ url: page.url, reason: "no quick-move-in home cards on community page (aggregate-only)" });
+        return { records, errors };
+      }
+
+      for (const card of cards) {
+        const homeUrl = card.lotUrl ? absUrl(card.lotUrl) : page.url;
+        if (!card.address) {
+          errors.push({ url: homeUrl, reason: `QMI home ${card.lotNumber ?? "?"} missing street address — skipped` });
+          continue;
+        }
+        const cStatus = constructionStatus(card.status);
+
+        records.push({
+          entityType: "inventory_home",
+          canonicalHints: {
+            builderSlug: BUILDER_SLUG,
+            communityName: community,
+            address: card.address,
+            builderInventoryId: card.lotNumber ?? undefined,
+            lat: lat ?? undefined,
+            lon: lon ?? undefined,
+            planName: card.plan ?? undefined,
+          },
+          fields: {
+            street: fv(card.address, card.address, homeUrl, "QMI card street-number-text"),
+            city: fv(city, city, homeUrl),
+            state: fv(state, ld.state === null ? null : "community ld+json", homeUrl),
+            zip: fv(zip, zip, homeUrl),
+            price: fv(card.price, card.price === null ? null : String(card.price), homeUrl, card.price === null ? null : `QMI card data-price ${card.price}`),
+            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<number>(null, null, homeUrl),
+            garageSpaces: fv<number>(null, null, homeUrl),
+            homeType: fv(
+              card.homeType && /town|condo|multi/i.test(card.homeType)
+                ? (/town/i.test(card.homeType) ? ("TOWNHOME" as const) : ("CONDO" as const))
+                : ("SINGLE_FAMILY" as const),
+              card.homeType,
+              homeUrl,
+              card.homeType ?? "Century QMI spec home",
+            ),
+            constructionStatus: fv(cStatus, card.status, homeUrl, cStatus === null ? null : `status: ${card.status}`),
+            estCompletionDate: fv<string>(null, null, homeUrl),
+            lotNumber: fv(card.lotNumber, card.lotNumber, homeUrl),
+            builderInventoryId: fv(card.lotNumber, card.lotNumber, homeUrl),
+            lat: fv(lat, lat === null ? null : String(lat), homeUrl, lat === null ? null : "community ld+json geo.latitude"),
+            lon: fv(lon, lon === null ? null : String(lon), homeUrl, lon === null ? null : "community ld+json geo.longitude"),
+            planName: fv(card.plan, card.plan, homeUrl),
+            // facts-only: card carousel images exist 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/century-communities/tsconfig.json b/collectors/century-communities/tsconfig.json
new file mode 100644
index 00000000..e9bfc482
--- /dev/null
+++ b/collectors/century-communities/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }

← 9b622394 auto-save: 2026-07-28T17:01:56 (130 files) — apps/workers/va  ·  back to Homesonspec  ·  collectors: add LGI, Shea, Century, Beazer, Drees adapters ( d9241682 →