SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.8 KB · 76 lines typescript
Raw Blame History
1import { load } from "cheerio";2import type { ExtractedRecord, Parser, RawDocument } from "@dci/connectors";3import { CA_PROVINCES, areaHa, areaSqm, bodyText, cleanText, description, extractCode, firstText, parseMw, record, statusFromText, usStateCode } from "./shared.js";45/**6 * STACK Infrastructure campus pages (www.stackinfra.com/locations/<region>/<metro>/<code>/).7 * <h1>ATL01 – Alpharetta</h1>; campus tiles p.mb-0.uppercase ("20MW", "2 Buildings", "18 Acres"); building cards8 * h3.title-campus (ATL01A) with div.flex.items-end values ("105,000 SQ FT", "8MW"); gallery caption "ATL01 Campus – Alpharetta, GA".9 * No street address or coordinates are published. Country comes from the US state / CA province in the caption, else from the10 * metro segment of the URL (fixed table of STACK metros; deterministic, not geocoding). One record for the campus + one per building.11 */12const METRO_COUNTRY: Record<string, string> = { atlanta: "US", chicago: "US", portland: "US", phoenix: "US", "northern-virginia": "US", "silicon-valley": "US", "dallas-fort-worth": "US", "new-albany": "US", toronto: "CA", calgary: "CA", milan: "IT", zurich: "CH", geneva: "CH", oslo: "NO", frankfurt: "DE", copenhagen: "DK", stockholm: "SE", tokyo: "JP", osaka: "JP", sydney: "AU", melbourne: "AU", "johor-bahru": "MY" };1314export const stackCampus: Parser = {15  name: "stack_campus_v1",16  version: "1.0.0",17  pageTypes: ["facility_page"],18  parse(doc: RawDocument): ExtractedRecord[] {19    const url = doc.finalUrl;20    const m = url.match(/\/locations\/(americas|emea|asia-pacific)\/([a-z-]+)\/([a-z0-9-]+)\/?$/);21    if (!m) return [];22    const metro = m[2]!, slug = m[3]!;23    const $ = load(doc.text);24    const h1 = firstText($, "h1");25    if (!h1) return [];26    const [codePart, cityPart] = h1.split(/\s[–—-]\s/);27    const code = extractCode(codePart) ?? slug.toUpperCase();28    let city = cleanText(cityPart) ?? null;29    const text = bodyText(doc.text);30    const cap = text.match(new RegExp(`${code}[^\\n]{0,24}[–—-]\\s*([A-Za-z .'-]+?),\\s*([A-Z]{2})\\b`));31    const regionName = cap?.[2] && (usStateCode(cap[2]) || CA_PROVINCES.has(cap[2])) ? cap[2] : null;32    city ??= cap?.[1] ? cleanText(cap[1]) : null;33    const countryIso2 = regionName ? (usStateCode(regionName) ? "US" : "CA") : (METRO_COUNTRY[metro] ?? null);3435    let totalPowerMw: number | null = null, siteAreaHa: number | null = null;36    $("p.mb-0.uppercase").each((_, e) => {37      const v = cleanText($(e).text());38      if (!v) return;39      if (/\d\s*MW\b/i.test(v) && totalPowerMw == null) totalPowerMw = parseMw(v);40      else if (/acre|hectare|\bha\b/i.test(v) && siteAreaHa == null) siteAreaHa = areaHa(v);41    });42    const designed = text.match(/designed (?:for|to deliver|to support)\s+(\d+(?:\.\d+)?)\s*MW/i);43    const plannedPowerMw = designed ? parseMw(`${designed[1]} MW`) : null;44    const introH2 = $("h2").filter((_, e) => new RegExp(`${code}\\s+Campus$`, "i").test($(e).text())).first();45    const intro = cleanText(introH2.nextAll("p").first().text()) ?? cleanText(introH2.parent().find("p").first().text()) ?? cleanText($("p").filter((_, p) => { const t = $(p).text(); return t.length > 80 && /campus/i.test(t); }).first().text());46    // Yoast builds the meta description by concatenating the page ("…Schedule a tour ATL01 Campus…") → only use it when it reads as prose47    const metaDesc = description(doc.text);48    const desc = metaDesc && !/schedule a tour|connect with us/i.test(metaDesc) ? metaDesc : null;49    const status = statusFromText(`${h1} ${intro ?? ""}`);50    const campusName = `STACK ${code}`;51    const common = { city, regionName, countryIso2, campusName };52    const records: ExtractedRecord[] = [];53    records.push(record("facility", `stack:${code.toLowerCase()}`, url, {54      name: `STACK ${code}${city ? ` – ${city}` : ""}`, code, aliases: [`${code} Campus`], ...common,55      totalPowerMw, plannedPowerMw: plannedPowerMw && plannedPowerMw !== totalPowerMw ? plannedPowerMw : null, siteAreaHa, status, description: intro ?? desc,56      externalIds: { stack_slug: slug },57    }, {58      name: "selector:h1", code: "selector:h1+regex", city: cityPart ? "selector:h1" : "regex:caption", regionName: "regex:caption(City, ST)", countryIso2: regionName ? "regex:caption>country" : "url:metro-table", campusName: "selector:h1",59      totalPowerMw: "selector:p.mb-0.uppercase>mw", plannedPowerMw: "regex:designed_for_mw", siteAreaHa: "selector:p.mb-0.uppercase>ha", status: "regex:status_v1", description: "selector:h2(Campus)+p", externalIds: "url:slug",60    }, 0.85));6162    $("h3.title-campus").each((_, e) => {63      const bcode = cleanText($(e).text())?.toUpperCase();64      if (!bcode || !/^[A-Z]{2,4}\d{1,3}[A-Z]?$/.test(bcode)) return;65      const vals = $(e).parent().find("div.flex.items-end").map((_, v) => cleanText($(v).text()) ?? "").get();66      let bSqm: number | null = null, bMw: number | null = null;67      for (const v of vals) { if (/SQ\.?\s?FT|SQFT|square feet/i.test(v)) bSqm ??= areaSqm(v); else if (/sqm|m²|square met/i.test(v)) bSqm ??= areaSqm(v); else if (/\d\s*MW\b/i.test(v)) bMw ??= parseMw(v); }68      records.push(record("facility", `stack:${bcode.toLowerCase()}`, url, {69        name: `STACK ${bcode}${city ? ` – ${city}` : ""}`, code: bcode, ...common, buildingSqm: bSqm, totalPowerMw: bMw, status,70        description: `Building ${bcode} on the STACK ${code} campus${city ? ` in ${city}` : ""}.`, externalIds: { stack_campus: code },71      }, { name: "selector:h3.title-campus", code: "selector:h3.title-campus", city: "selector:h1", regionName: "regex:caption(City, ST)", countryIso2: regionName ? "regex:caption>country" : "url:metro-table", campusName: "selector:h1", buildingSqm: "selector:div.flex.items-end>sqm", totalPowerMw: "selector:div.flex.items-end>mw", status: "regex:status_v1", description: "derived:campus", externalIds: "selector:h1" }, 0.85));72    });73    return records;74  },75};76