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%
6.0 KB · 97 lines typescript
Raw Blame History
1import { load } from "cheerio";2import type { ExtractedRecord, Parser, RawDocument } from "@dci/connectors";3import { certificationsFrom, cleanText, countryFromText, description, lastSlug, record, renewableFrom, statusFromText, usStateCode } from "./shared.js";45/**6 * Equinix IBX pages (www.equinix.com/data-centers/<region>-colocation/<country>-colocation/<metro>-data-centers/<code>).7 * The page embeds plain JS constants for the map (title, metro, address1/2, city, postalCode, country, latitude, longitude)8 * plus a spec table (<td>label</td><td>value</td>) and a certifications list. No MW figure is published per IBX.9 */10function jsVar(html: string, name: string): string | null {11  const m = html.match(new RegExp(`const ${name} = "((?:[^"\\\\]|\\\\.)*)"`));12  if (!m) return null;13  try { return cleanText(JSON.parse(`"${m[1]}"`) as string); } catch { return cleanText(m[1]!); }14}1516export const equinixFacility: Parser = {17  name: "equinix_facility_v1",18  version: "1.0.0",19  pageTypes: ["facility_page"],20  parse(doc: RawDocument): ExtractedRecord[] {21    const html = doc.text;22    if (!html || !/const latitude = /.test(html)) return [];23    const $ = load(html);24    const code = (jsVar(html, "title") ?? lastSlug(doc.finalUrl)).toUpperCase();25    if (!/^[A-Z]{2,3}\d{1,2}[A-Z]?$/.test(code)) return [];26    const h1 = cleanText($("h1#hero-banner-heading").first().text()) ?? cleanText($("h1").first().text());27    const metro = jsVar(html, "metro");28    const city = jsVar(html, "city");29    const address1 = jsVar(html, "address1");30    const address2 = jsVar(html, "address2");31    const postalCode = jsVar(html, "postalCode");32    const country = jsVar(html, "country");33    const ibxType = jsVar(html, "ibxType");34    const lat = Number(jsVar(html, "latitude"));35    const lng = Number(jsVar(html, "longitude"));36    const desc = description(html);3738    // US state from the meta description ("… located at 21715 Filigree Court, Ashburn, VA. See …")39    let regionName: string | null = null;40    const rm = desc?.match(/,\s*([A-Z]{2})\.\s/);41    if (rm && usStateCode(rm[1])) regionName = rm[1]!;4243    // headline metrics: <span data-role="metric">N+1</span><span data-role="qualifier">Power redundancy</span>44    const metrics: Record<string, string> = {};45    $('span[data-role="metric"]').each((_, el) => {46      const q = cleanText($(el).siblings('span[data-role="qualifier"]').first().text());47      const v = cleanText($(el).text());48      if (q && v) metrics[q.toLowerCase()] = v;49    });50    // spec table rows51    const specs: Record<string, string> = {};52    $("table tr").each((_, tr) => {53      const tds = $(tr).find("td");54      if (tds.length >= 2) { const k = cleanText(tds.eq(0).text()); const v = cleanText(tds.eq(1).text()); if (k && v) specs[k.toLowerCase()] = v; }55    });56    const coloSpaceRaw = specs["colocation space"] ?? null;57    const coloM = coloSpaceRaw?.match(/([\d,.]+)\s*m(?:²|2)?\b/);58    const coloSqm = coloM ? Number(coloM[1]!.replace(/,/g, "")) : null;5960    // certifications block61    const certHeader = $("h3").filter((_, e) => /Certified to global standards/i.test($(e).text())).first();62    let certScope = certHeader.closest("article");63    if (!certScope.length) certScope = certHeader.parents().filter((_, e) => $(e).find("ul li").length > 0).first();64    const certItems = certScope.find("li span").filter((_, e) => !$(e).attr("data-role") && !$(e).find("svg").length).map((_, e) => cleanText($(e).text()) ?? "").get().filter(Boolean);65    const certifications = certificationsFrom(certItems);6667    // Sustainability row: keep only the bullet that states the renewable / REC claim68    const sustRow = $("table tr").filter((_, tr) => /^sustainability$/i.test(cleanText($(tr).find("td").first().text()) ?? "")).first();69    const sustItems = sustRow.find("li").map((_, li) => cleanText($(li).text()) ?? "").get().filter(Boolean);70    const sustainability = sustItems.find((t) => /\b(RECs?|renewable|wind|solar|hydro|carbon[- ]free|green[- ]e|PPA)\b/i.test(t)) ?? null;71    const heroP = cleanText($("h1").first().nextAll("p").first().text());72    const status = statusFromText(`${h1 ?? ""} ${heroP ?? ""}`);73    const name = h1 && h1.toUpperCase().includes(code) ? h1 : `${metro ?? city ?? "Equinix"} ${code}`;74    const extraDesc = [coloSpaceRaw ? `Colocation space: ${coloSpaceRaw}.` : null, metrics["power redundancy"] ? `Power redundancy: ${metrics["power redundancy"]}.` : null, specs["ups redundancy"] ? `UPS redundancy: ${specs["ups redundancy"]}.` : null].filter(Boolean).join(" ");7576    const data: Record<string, unknown> = {77      name, code, aliases: [`Equinix ${code}`, `${code} IBX`],78      address: [address1, address2].filter(Boolean).join(", ") || null, city, regionName, postalCode,79      countryIso2: country ? countryFromText(country) : null,80      lat: Number.isFinite(lat) && lat !== 0 ? lat : null, lng: Number.isFinite(lng) && lng !== 0 ? lng : null, geoPrecision: "exact",81      certifications, renewableClaim: renewableFrom(sustainability),82      status, facilityType: ibxType && /xscale/i.test(ibxType) ? "hyperscale" : "colocation",83      description: [desc, extraDesc].filter(Boolean).join(" ") || null,84      campusName: metro && /campus/i.test(heroP ?? "") ? `${metro} campus` : null,85      externalIds: { equinix_ibx: code },86      _coloSqm: coloSqm,87    };88    delete data._coloSqm; // colocation (white) space is not building size — kept in description only89    const methods: Record<string, string> = {90      name: "selector:h1#hero-banner-heading", code: "js-var:title", address: "js-var:address1", city: "js-var:city", regionName: "meta:description+regex:state", postalCode: "js-var:postalCode",91      countryIso2: "js-var:country>country", lat: "js-var:latitude", lng: "js-var:longitude", certifications: "selector:certifications-list", renewableClaim: "table:sustainability+regex:renewable_v1",92      status: "regex:status_v1", facilityType: "js-var:ibxType", description: "meta:description+table:specs", campusName: "regex:campus", externalIds: "js-var:title",93    };94    return [record("facility", `equinix:${code}`, doc.finalUrl, data, methods, 0.95)];95  },96};97