SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.1 KB · 73 lines typescript
Raw Blame History
1import { load } from "cheerio";2import type { ExtractedRecord, Parser, RawDocument } from "@dci/connectors";3import { certificationsFrom, cleanText, coolingFrom, description, firstText, lastSlug, mwWithContext, parseCityLine, parseMw, record, renewableFrom, statusFromText } from "./shared.js";45/**6 * CyrusOne facility pages (www.cyrusone.com/data-centers/<region>/<slug>). HubSpot CMS:7 *   <h1>Frankfurt, Germany</h1><h3>FRA5</h3>, breadcrumb #hs_cos_wrapper_name "Frankfurt, Germany: FRA5",8 *   "Key Features" tiles: <h3>54 MW</h3><p>Total IT Capacity</p>, <h3>18,000 sq. m.</h3><p>Technical IT Space</p>,9 *   accordions "Technical Specifications" / "Sustainability" (li/span items).10 * No street address and no coordinates are published → geo left null (city-level from other sources).11 */12export const cyrusOneFacility: Parser = {13  name: "cyrusone_facility_v1",14  version: "1.0.0",15  pageTypes: ["facility_page"],16  parse(doc: RawDocument): ExtractedRecord[] {17    const url = doc.finalUrl;18    if (!/\/data-centers\/(north-america|emea|apac)\/[a-z0-9-]+\/?$/.test(url)) return [];19    const $ = load(doc.text);20    const h1 = firstText($, "h1");21    if (!h1) return [];22    const codeLine = cleanText($("h1").first().nextAll("h3").first().text());23    const codes = codeLine ? (codeLine.toUpperCase().match(/\b[A-Z]{2,4}\d{1,2}[A-Z]?\b/g) ?? []) : [];24    // h1 is normally "City, ST" / "City, Country"; campus pages ("Yorkville Technology Campus") name the place in the meta description ("… campus in Yorkville, Illinois")25    let cl = parseCityLine(h1);26    const metaDesc = description(doc.text);27    if (!h1.includes(",") || !cl.countryIso2) {28      const inLoc = metaDesc?.match(/\b(?:in|near|located in)\s+([A-Z][A-Za-z .'-]+?),\s*([A-Z][A-Za-z .]+?)(?:[,.;]|\s-|$)/);29      if (inLoc) { const alt = parseCityLine(`${inLoc[1]}, ${inLoc[2]}`); if (alt.countryIso2) cl = alt; }30      if (!h1.includes(",") && cl.city === h1) cl = { ...cl, city: null };31    }32    const slug = lastSlug(url);33    const key = codes.length === 1 ? `cyrusone:${codes[0]}` : `cyrusone:${slug}`;34    const name = codes.length ? `CyrusOne ${codes.join("/")} (${cl.city ?? h1})` : `CyrusOne ${h1}`;3536    // Key Features tiles37    let itCapacityMw: number | null = null;38    let techSpace: string | null = null;39    $("h3").each((_, e) => {40      const v = cleanText($(e).text());41      const label = cleanText($(e).nextAll("p").first().text()) ?? cleanText($(e).parent().find("p").first().text());42      if (!v || !label || !/\d/.test(v)) return; // tile values are figures ("54 MW", "18,000 sq. m."); headings like "Key Features:" are not43      if (/IT capacity|critical (?:IT )?(?:load|power)/i.test(label) && itCapacityMw == null) itCapacityMw = parseMw(v);44      if (/technical (?:IT )?space|white ?space|data hall space|raised floor/i.test(label) && !techSpace) techSpace = v;45    });46    const text = cleanText($("main, body").first().text()) ?? "";47    const techSpaceStr = techSpace as string | null; // assigned inside the .each callback (TS narrows the let to null otherwise)48    itCapacityMw ??= mwWithContext(text, /\s*(?:of\s+)?(?:total\s+)?IT\s+capacity/i) ?? mwWithContext(text, /\s*(?:of\s+)?critical\s+(?:IT\s+)?load/i);4950    const accordionItems = $("li, span").filter((_, e) => $(e).children().length === 0).map((_, e) => cleanText($(e).text()) ?? "").get().filter((t) => t.length > 2 && t.length < 160);51    const certifications = certificationsFrom(accordionItems.filter((t) => /ISO|SOC|PCI|HIPAA|LEED|BREEAM|Uptime|Tier|NIST|FedRAMP|HITRUST|EN\s?50600/i.test(t)));52    const sustText = accordionItems.filter((t) => /renewable|carbon|green|wind|solar|water|PUE|WUE/i.test(t)).join(". ");53    const h2 = firstText($, "h2");54    const firstP = cleanText($("h2").first().nextAll("div, p").find("p").first().text()) ?? cleanText($("h2").first().next("p").text());55    const desc = description(doc.text);56    const status = statusFromText(`${h1} ${codeLine ?? ""} ${h2 ?? ""} ${firstP ?? ""}`);5758    const data: Record<string, unknown> = {59      name, code: codes.length === 1 ? codes[0] : null, aliases: codes.map((c) => `CyrusOne ${c}`),60      city: cl.city, regionName: cl.region, countryIso2: cl.countryIso2,61      itCapacityMw, certifications, renewableClaim: renewableFrom(sustText), coolingType: coolingFrom(`${sustText} ${accordionItems.join(". ")}`),62      status, description: [desc, techSpaceStr ? `Technical IT space: ${techSpaceStr.replace(/\.+$/, "")}.` : null].filter(Boolean).join(" ") || null,63      externalIds: { cyrusone_slug: slug },64    };65    const methods: Record<string, string> = {66      name: "selector:h1+h3", code: "selector:h1~h3", city: "selector:h1+parse", regionName: "selector:h1+parse", countryIso2: "selector:h1+parse>country",67      itCapacityMw: "selector:key-features(h3+p 'IT Capacity')>mw", certifications: "selector:accordion li+regex", renewableClaim: "selector:accordion li+regex", coolingType: "regex:cooling_v1",68      status: "regex:status_v1", description: "meta:description+key-features", externalIds: "url:slug",69    };70    return [record("facility", key, url, data, methods, codes.length ? 0.9 : 0.75)];71  },72};73