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%
8.6 KB · 115 lines typescript
Raw Blame History
1import { load, type CheerioAPI } from "cheerio";2import type { ExtractedRecord, Parser, RawDocument } from "@dci/connectors";3import { areaHa, areaSqm, bodyText, cleanText, countryFromText, description, firstText, lastSlug, mwAfter, mwWithContext, parseCityLine, parseEuAddress, parseNaAddress, record, statusFromText, usStateCode, type ParsedAddress } from "./shared.js";45/**6 * QTS (q.com/data-centers/<slug>/). WordPress + Elementor; two templates:7 *   US campus:  <h1>Ashburn 3</h1> … "Campus footprint 25 acre campus" … "Critical campus capacity 80 MW+" (or, figure first,8 *               "210 MW+ of critical campus capacity") … <h2>Ashburn 3 (ASH3)</h2>9 *               <p>ASH3 DC1: 22291 Shellhorn Rd<br>Ashburn, VA 20147</p>; addresses also in Elementor hotspot tooltips.10 *   EU site:    <h1>Eemshaven, Netherlands</h1> … "36 MW total power" … "Huibertgatweg 2 9979 XZ Eemshaven, Netherlands".11 * Language duplicates (-nl, calatorao/vimercate ES/IT originals) are excluded in YAML; "-en" is stripped from the key.12 */13const NA_ADDR = /(\d{2,6}\s[A-Za-z0-9 .'-]+?\b(?:Rd|Road|Dr|Drive|Blvd|Boulevard|St|Street|Ave|Avenue|Pkwy|Parkway|Way|Ln|Lane|Ct|Court|Hwy|Highway|Trail|Trl|Pl|Place|Cir|Circle|Loop|Pike|Route|Rte)\.?)\s*,?\s*\n?\s*([A-Za-z .'-]+?),\s*([A-Z]{2})\s+(\d{5})\b/;14const EU_ADDR = /([A-Z][A-Za-z .'-]+?\s\d+[a-z]?)\s*,?\s*\n?\s*((?:[A-Z]{1,2}-)?\d{4,6}(?:\s?[A-Z]{2})?)\s+([A-Za-z .'-]+?)\s*,\s*([A-Za-z ]+)\b/;1516const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");1718/**19 * Elementor Google-Maps widget: data-pins='[{"address":"<lat>, <lng>","position":{"lat":…,"lng":…},"desc":"<p>2800 S Ashland Ave, Chicago, IL 60608</p>"}]'.20 * The widget is copied between page templates (the Phoenix 4 page carried the Chicago pin), so a pin is used only when21 * its own caption names the facility's postal code, or its city and state. No matching caption → no coordinates: a22 * facility never inherits another site's point.23 */24export function mapPin($: CheerioAPI, pa: ParsedAddress | null): { lat: number; lng: number } | null {25  if (!pa) return null;26  const pins: Array<Record<string, unknown>> = [];27  $("[data-pins]").each((_, el) => {28    try { const v = JSON.parse($(el).attr("data-pins") ?? "[]") as unknown; if (Array.isArray(v)) pins.push(...(v as Array<Record<string, unknown>>)); } catch { /* not JSON */ }29  });30  for (const pin of pins) {31    const pos = pin.position as { lat?: unknown; lng?: unknown } | undefined;32    const fromAddr = typeof pin.address === "string" ? pin.address.match(/^\s*(-?\d{1,2}\.\d{3,}),\s*(-?\d{1,3}\.\d{3,})\s*$/) : null;33    const lat = Number(pos?.lat ?? fromAddr?.[1]), lng = Number(pos?.lng ?? fromAddr?.[2]);34    if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue;35    const caption = cleanText(String(pin.desc ?? "").replace(/<[^>]+>/g, " ")) ?? "";36    if (!caption) continue;37    const samePostal = !!pa.postal && new RegExp(`\\b${escapeRe(pa.postal)}\\b`).test(caption);38    const sameCity = !!pa.city && !!pa.region && new RegExp(`\\b${escapeRe(pa.city)}\\b[,\\s]+${escapeRe(pa.region)}\\b`, "i").test(caption);39    if (samePostal || sameCity) return { lat, lng };40  }41  return null;42}4344export const qtsFacility: Parser = {45  name: "qts_facility_v1",46  version: "1.1.0",47  pageTypes: ["facility_page"],48  parse(doc: RawDocument): ExtractedRecord[] {49    const url = doc.finalUrl;50    if (!/\/data-centers\/[a-z0-9-]+\/?$/.test(url)) return [];51    const $ = load(doc.text);52    const h1 = firstText($, "h1");53    if (!h1) return [];54    const text = bodyText(doc.text);55    const h2s = $("h2").map((_, e) => cleanText($(e).text()) ?? "").get();56    const codeMatch = h2s.map((t) => t.match(/^(.+?)\s*\(([A-Z]{2,5}\d{0,2})\)$/)).find(Boolean);57    const code = codeMatch?.[2] ?? null;58    const slug = lastSlug(url).replace(/-(en|nl|es|it|fi)$/, "");5960    // label first ("Critical campus capacity 80 MW+") or figure first ("210 MW+ of critical campus capacity", "36 MW total power")61    const totalPowerMw = mwAfter(text, /Critical campus capacity/i, 80) ?? mwWithContext(text, /\s*(?:of\s+)?(?:total|totaal)\b/i) ?? mwWithContext(text, /\s*(?:aan|of)\s+(?:totaal|total)/i) ?? mwWithContext(text, /\s*(?:of\s+)?(?:critical|total)\s+(?:campus\s+)?(?:power|capacity|IT load)/i);62    const acre = text.match(/(\d+(?:\.\d+)?)\s*[- ]?acre\b/i);63    const siteAreaHa = acre ? areaHa(`${acre[1]} acres`) : areaHa(text.match(/(\d+(?:\.\d+)?)\s*hectares?\b/i)?.[0]);6465    // addresses: hotspot tooltips first, then text patterns66    const tooltips = [...doc.text.matchAll(/hotspot_tooltip_content&quot;:&quot;(.*?)&quot;/g)].map((m) => cleanText(m[1]!.replace(/\\u00a0/g, " ").replace(/&lt;[^&]*&gt;|\\\/|\\/g, " ").replace(/<[^>]+>/g, " ")) ?? "").filter(Boolean);67    let addrRaw: string | null = tooltips[0] ?? null;68    let pa = addrRaw ? parseNaAddress(addrRaw) ?? parseEuAddress(addrRaw) : null;69    if (!pa) {70      const na = text.match(NA_ADDR);71      if (na) { addrRaw = `${na[1]}, ${na[2]}, ${na[3]} ${na[4]}`; pa = parseNaAddress(addrRaw); }72    }73    if (!pa) {74      const eu = text.match(EU_ADDR);75      if (eu) { addrRaw = `${eu[1]}, ${eu[2]} ${eu[3]}, ${eu[4]}`; pa = parseEuAddress(addrRaw); }76    }77    // h1 is "Ashburn 3" / "Miami 1" / "Eemshaven, Netherlands" → the trailing site number is not part of the city78    const cl = parseCityLine(h1.replace(/\s+\d+$/, ""));79    const title = cleanText($("title").first().text()) ?? "";80    // US campus pages carry a "Proud to call Virginia home" banner → state (and therefore country) when no address parsed81    const proud = text.match(/Proud to call ([A-Z][a-z]+(?: [A-Z][a-z]+)?) home/);82    const proudState = proud ? usStateCode(proud[1]) : null;83    const countryIso2 = pa?.countryIso2 ?? cl.countryIso2 ?? (proudState ? "US" : null) ?? countryFromText(title.replace(/-\s*QTS.*$/, "").replace(/-/g, " ")) ?? null;84    const buildings = [...text.matchAll(/\b([A-Z]{2,5}\d{0,2}\s+DC\d+):\s*([^\n]+)\n?([^\n]*\d{5})?/g)].map((m) => cleanText(`${m[1]}: ${m[2]} ${m[3] ?? ""}`) ?? "").filter(Boolean);85    // Elementor Google-Maps pin, only when its caption is this facility's address (see mapPin)86    const pin = mapPin($, pa);87    const lat = pin?.lat ?? null, lng = pin?.lng ?? null;88    // "200,000 ft2 facility" / "22,000 m2 total"89    const bld = text.match(/(\d{1,3}(?:,\d{3})+|\d+)\s*(?:ft2|ft²|sq\.?\s?ft|square feet)\s+(?:facility|building|data center)/i);90    const bldM = text.match(/(\d{1,3}(?:[,.]\d{3})+|\d+)\s*(?:m2|m²|sqm|sq\.?\s?m\.?)\s+(?:total|facility|building|of)/i);91    const buildingSqm = bld ? areaSqm(`${bld[1]} sq ft`) : bldM ? areaSqm(`${bldM[1]!.replace(/[.,]/g, "")} sqm`) : null;92    const introP = $("p").filter((_, e) => (cleanText($(e).text())?.length ?? 0) > 80 && !/cookie|subscribe|validation/i.test($(e).text())).first();93    const intro = cleanText(introP.text());94    const metaDesc = description(doc.text);95    // Yoast excerpt on q.com concatenates nav/CTA text ("Schedule a tour …") → prefer the intro paragraph then96    const desc = metaDesc && !/schedule a tour|\[…\]|\[&hellip;\]/i.test(metaDesc) ? metaDesc : intro ?? metaDesc;97    const status = statusFromText(`${h1} ${intro ?? ""}`);9899    const data: Record<string, unknown> = {100      name: `QTS ${h1.split(",")[0]!.trim()}`, code, aliases: [h1, ...(code ? [`QTS ${code}`] : [])],  // keep the site number ("QTS Ashburn 3") — several campuses share a city101      address: pa?.address ?? null, city: pa?.city ?? cl.city, regionName: pa?.region ?? cl.region ?? proudState, postalCode: pa?.postal ?? null, countryIso2,102      lat, lng, geoPrecision: pin ? "exact" : null,103      totalPowerMw, siteAreaHa, buildingSqm,104      status, description: [desc, buildings.length ? `Buildings: ${buildings.join("; ")}.` : null].filter(Boolean).join(" ") || null,105      externalIds: { qts_slug: slug },106    };107    const methods: Record<string, string> = {108      name: "selector:h1", code: "selector:h2+regex:(CODE)", address: addrRaw && tooltips.length ? "elementor:hotspot_tooltip+parse" : "regex:address_v1", city: pa?.city ? "regex:address_v1" : "selector:h1+parse", regionName: "regex:address_v1", postalCode: "regex:address_v1", countryIso2: "regex:address_v1>country",109      lat: "elementor:google_maps.pin(caption=address)", lng: "elementor:google_maps.pin(caption=address)", geoPrecision: "elementor:google_maps.pin(caption=address)",110      totalPowerMw: "regex:critical_campus_capacity_v1>mw", siteAreaHa: "regex:acre_campus>ha", buildingSqm: "regex:facility_sqft>sqm", status: "regex:status_v1", description: desc === metaDesc ? "meta:description" : "selector:p(intro)", externalIds: "url:slug",111    };112    return [record("facility", `qts:${slug}`, url, data, methods, 0.85)];113  },114};115