import { load, type CheerioAPI } from "cheerio"; import type { ExtractedRecord, Parser, RawDocument } from "@dci/connectors"; import { areaHa, areaSqm, bodyText, cleanText, countryFromText, description, firstText, lastSlug, mwAfter, mwWithContext, parseCityLine, parseEuAddress, parseNaAddress, record, statusFromText, usStateCode, type ParsedAddress } from "./shared.js"; /** * QTS (q.com/data-centers//). WordPress + Elementor; two templates: * US campus:

Ashburn 3

… "Campus footprint 25 acre campus" … "Critical campus capacity 80 MW+" (or, figure first, * "210 MW+ of critical campus capacity") …

Ashburn 3 (ASH3)

*

ASH3 DC1: 22291 Shellhorn Rd
Ashburn, VA 20147

; addresses also in Elementor hotspot tooltips. * EU site:

Eemshaven, Netherlands

… "36 MW total power" … "Huibertgatweg 2 9979 XZ Eemshaven, Netherlands". * Language duplicates (-nl, calatorao/vimercate ES/IT originals) are excluded in YAML; "-en" is stripped from the key. */ const 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/; const 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/; const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** * Elementor Google-Maps widget: data-pins='[{"address":", ","position":{"lat":…,"lng":…},"desc":"

2800 S Ashland Ave, Chicago, IL 60608

"}]'. * The widget is copied between page templates (the Phoenix 4 page carried the Chicago pin), so a pin is used only when * its own caption names the facility's postal code, or its city and state. No matching caption → no coordinates: a * facility never inherits another site's point. */ export function mapPin($: CheerioAPI, pa: ParsedAddress | null): { lat: number; lng: number } | null { if (!pa) return null; const pins: Array> = []; $("[data-pins]").each((_, el) => { try { const v = JSON.parse($(el).attr("data-pins") ?? "[]") as unknown; if (Array.isArray(v)) pins.push(...(v as Array>)); } catch { /* not JSON */ } }); for (const pin of pins) { const pos = pin.position as { lat?: unknown; lng?: unknown } | undefined; const fromAddr = typeof pin.address === "string" ? pin.address.match(/^\s*(-?\d{1,2}\.\d{3,}),\s*(-?\d{1,3}\.\d{3,})\s*$/) : null; const lat = Number(pos?.lat ?? fromAddr?.[1]), lng = Number(pos?.lng ?? fromAddr?.[2]); if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue; const caption = cleanText(String(pin.desc ?? "").replace(/<[^>]+>/g, " ")) ?? ""; if (!caption) continue; const samePostal = !!pa.postal && new RegExp(`\\b${escapeRe(pa.postal)}\\b`).test(caption); const sameCity = !!pa.city && !!pa.region && new RegExp(`\\b${escapeRe(pa.city)}\\b[,\\s]+${escapeRe(pa.region)}\\b`, "i").test(caption); if (samePostal || sameCity) return { lat, lng }; } return null; } export const qtsFacility: Parser = { name: "qts_facility_v1", version: "1.1.0", pageTypes: ["facility_page"], parse(doc: RawDocument): ExtractedRecord[] { const url = doc.finalUrl; if (!/\/data-centers\/[a-z0-9-]+\/?$/.test(url)) return []; const $ = load(doc.text); const h1 = firstText($, "h1"); if (!h1) return []; const text = bodyText(doc.text); const h2s = $("h2").map((_, e) => cleanText($(e).text()) ?? "").get(); const codeMatch = h2s.map((t) => t.match(/^(.+?)\s*\(([A-Z]{2,5}\d{0,2})\)$/)).find(Boolean); const code = codeMatch?.[2] ?? null; const slug = lastSlug(url).replace(/-(en|nl|es|it|fi)$/, ""); // label first ("Critical campus capacity 80 MW+") or figure first ("210 MW+ of critical campus capacity", "36 MW total power") 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); const acre = text.match(/(\d+(?:\.\d+)?)\s*[- ]?acre\b/i); const siteAreaHa = acre ? areaHa(`${acre[1]} acres`) : areaHa(text.match(/(\d+(?:\.\d+)?)\s*hectares?\b/i)?.[0]); // addresses: hotspot tooltips first, then text patterns const tooltips = [...doc.text.matchAll(/hotspot_tooltip_content":"(.*?)"/g)].map((m) => cleanText(m[1]!.replace(/\\u00a0/g, " ").replace(/<[^&]*>|\\\/|\\/g, " ").replace(/<[^>]+>/g, " ")) ?? "").filter(Boolean); let addrRaw: string | null = tooltips[0] ?? null; let pa = addrRaw ? parseNaAddress(addrRaw) ?? parseEuAddress(addrRaw) : null; if (!pa) { const na = text.match(NA_ADDR); if (na) { addrRaw = `${na[1]}, ${na[2]}, ${na[3]} ${na[4]}`; pa = parseNaAddress(addrRaw); } } if (!pa) { const eu = text.match(EU_ADDR); if (eu) { addrRaw = `${eu[1]}, ${eu[2]} ${eu[3]}, ${eu[4]}`; pa = parseEuAddress(addrRaw); } } // h1 is "Ashburn 3" / "Miami 1" / "Eemshaven, Netherlands" → the trailing site number is not part of the city const cl = parseCityLine(h1.replace(/\s+\d+$/, "")); const title = cleanText($("title").first().text()) ?? ""; // US campus pages carry a "Proud to call Virginia home" banner → state (and therefore country) when no address parsed const proud = text.match(/Proud to call ([A-Z][a-z]+(?: [A-Z][a-z]+)?) home/); const proudState = proud ? usStateCode(proud[1]) : null; const countryIso2 = pa?.countryIso2 ?? cl.countryIso2 ?? (proudState ? "US" : null) ?? countryFromText(title.replace(/-\s*QTS.*$/, "").replace(/-/g, " ")) ?? null; 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); // Elementor Google-Maps pin, only when its caption is this facility's address (see mapPin) const pin = mapPin($, pa); const lat = pin?.lat ?? null, lng = pin?.lng ?? null; // "200,000 ft2 facility" / "22,000 m2 total" const bld = text.match(/(\d{1,3}(?:,\d{3})+|\d+)\s*(?:ft2|ft²|sq\.?\s?ft|square feet)\s+(?:facility|building|data center)/i); const bldM = text.match(/(\d{1,3}(?:[,.]\d{3})+|\d+)\s*(?:m2|m²|sqm|sq\.?\s?m\.?)\s+(?:total|facility|building|of)/i); const buildingSqm = bld ? areaSqm(`${bld[1]} sq ft`) : bldM ? areaSqm(`${bldM[1]!.replace(/[.,]/g, "")} sqm`) : null; const introP = $("p").filter((_, e) => (cleanText($(e).text())?.length ?? 0) > 80 && !/cookie|subscribe|validation/i.test($(e).text())).first(); const intro = cleanText(introP.text()); const metaDesc = description(doc.text); // Yoast excerpt on q.com concatenates nav/CTA text ("Schedule a tour …") → prefer the intro paragraph then const desc = metaDesc && !/schedule a tour|\[…\]|\[…\]/i.test(metaDesc) ? metaDesc : intro ?? metaDesc; const status = statusFromText(`${h1} ${intro ?? ""}`); const data: Record = { name: `QTS ${h1.split(",")[0]!.trim()}`, code, aliases: [h1, ...(code ? [`QTS ${code}`] : [])], // keep the site number ("QTS Ashburn 3") — several campuses share a city address: pa?.address ?? null, city: pa?.city ?? cl.city, regionName: pa?.region ?? cl.region ?? proudState, postalCode: pa?.postal ?? null, countryIso2, lat, lng, geoPrecision: pin ? "exact" : null, totalPowerMw, siteAreaHa, buildingSqm, status, description: [desc, buildings.length ? `Buildings: ${buildings.join("; ")}.` : null].filter(Boolean).join(" ") || null, externalIds: { qts_slug: slug }, }; const methods: Record = { 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", lat: "elementor:google_maps.pin(caption=address)", lng: "elementor:google_maps.pin(caption=address)", geoPrecision: "elementor:google_maps.pin(caption=address)", 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", }; return [record("facility", `qts:${slug}`, url, data, methods, 0.85)]; }, };