Data-quality hardening pass: matcher rewrite, news/project precision guards, operator inference, quality scripts, mobile a11y sweep fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
165 changed files +7,973 −188
modified
apps/web/next-env.d.ts
+2 −2
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | /// <reference types="next" /> |
| 2 | 2 | /// <reference types="next/image-types/global" /> |
| 3 | −import "./.next/types/routes.d.ts"; | |
| 4 | −import "./.next/types/root-params.d.ts"; | |
| 3 | +import "./.next/dev/types/routes.d.ts"; | |
| 4 | +import "./.next/dev/types/root-params.d.ts"; | |
| 5 | 5 | |
| 6 | 6 | // NOTE: This file should not be edited |
| 7 | 7 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. |
added
apps/web/qa/sweep.mjs
+355 −0
@@ -0,0 +1,355 @@ | ||
| 1 | +// Full UX QA sweep: node apps/web/qa/sweep.mjs (Playwright from ~/Desktop/uqo-eval) | |
| 2 | +// env: BASE (default http://localhost:8314), OUT (default qa/sweep), PAGES=name,name (subset), WIDTHS=…, QUICK=1 (390+1280 only) | |
| 3 | +// Writes screenshots + results.json; prints a page × width matrix and every finding. | |
| 4 | +import { createRequire } from "node:module"; | |
| 5 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 6 | +import { dirname, resolve } from "node:path"; | |
| 7 | +import { fileURLToPath } from "node:url"; | |
| 8 | + | |
| 9 | +const require = createRequire("/Users/simon-pierreboucher/Desktop/uqo-eval/package.json"); | |
| 10 | +const { chromium } = require("playwright"); | |
| 11 | +const here = dirname(fileURLToPath(import.meta.url)); | |
| 12 | +const OUT = process.env.OUT ?? resolve(here, "sweep"); | |
| 13 | +mkdirSync(OUT, { recursive: true }); | |
| 14 | +const BASE = process.env.BASE ?? "http://localhost:8314"; | |
| 15 | + | |
| 16 | +export const PAGES = [ | |
| 17 | + ["home", "/"], | |
| 18 | + ["map", "/map"], | |
| 19 | + ["search", "/search?q=montreal"], | |
| 20 | + ["live", "/live"], | |
| 21 | + ["datacenters", "/datacenters"], | |
| 22 | + ["fac-operator-mw", "/datacenters/databank-downtown-minneapolis-data-center-msp4"], | |
| 23 | + ["fac-osm", "/datacenters/shelter-modernizacion"], | |
| 24 | + ["fac-google", "/datacenters/google-the-dalles-oregon-data-center"], | |
| 25 | + ["fac-wikidata", "/datacenters/foulum-data-center"], | |
| 26 | + ["operators", "/operators"], | |
| 27 | + ["operator-equinix", "/operators/equinix"], | |
| 28 | + ["countries", "/countries"], | |
| 29 | + ["country-us", "/countries/united-states"], | |
| 30 | + ["country-ca", "/countries/canada"], | |
| 31 | + ["markets", "/datacenters/markets"], | |
| 32 | + ["market-nova", "/datacenters/markets/northern-virginia"], | |
| 33 | + ["projects", "/projects"], | |
| 34 | + ["project", "/projects/sydney-data-center-project"], | |
| 35 | + ["rankings", "/rankings"], | |
| 36 | + ["ranking", "/rankings/countries_facilities"], | |
| 37 | + ["cloud-regions", "/cloud-regions"], | |
| 38 | + ["cloud-region", "/cloud-regions/amazon-web-services-ca-central-1"], | |
| 39 | + ["ixps", "/ixps"], | |
| 40 | + ["sources", "/sources"], | |
| 41 | + ["methodology", "/methodology"], | |
| 42 | + ["about", "/about"], | |
| 43 | + ["bot", "/bot"], | |
| 44 | + ["404", "/this-page-does-not-exist"], | |
| 45 | +]; | |
| 46 | +const ONLY = process.env.PAGES ? new Set(process.env.PAGES.split(",")) : null; | |
| 47 | +const LIGHT_W = (process.env.WIDTHS ?? (process.env.QUICK ? "390,1280" : "320,360,375,390,414,430,768,1280")).split(",").map(Number); | |
| 48 | +const VARIANTS = [...LIGHT_W.map((w) => ({ w, theme: "light" })), { w: 390, theme: "dark" }, { w: 1280, theme: "dark" }]; | |
| 49 | +const FULL_SHOT = new Set([390, 1280]); | |
| 50 | + | |
| 51 | +/* ---------------------------------------------------------------- in-page audit */ | |
| 52 | +function audit({ mobile, dark }) { | |
| 53 | + const doc = document.documentElement; | |
| 54 | + const vw = doc.clientWidth; | |
| 55 | + const vis = (el) => { | |
| 56 | + const r = el.getBoundingClientRect(); | |
| 57 | + if (r.width <= 0 || r.height <= 0) return false; | |
| 58 | + const cs = getComputedStyle(el); | |
| 59 | + if (cs.visibility === "hidden" || cs.display === "none") return false; | |
| 60 | + // visually-hidden (sr-only) content is not rendered text | |
| 61 | + return !(cs.clip === "rect(0px, 0px, 0px, 0px)" && cs.position === "absolute"); | |
| 62 | + }; | |
| 63 | + const tag = (el) => `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}.${(typeof el.className === "string" ? el.className : "").split(" ").filter(Boolean).slice(0, 3).join(".")}`; | |
| 64 | + | |
| 65 | + // 1. horizontal overflow | |
| 66 | + const wide = []; | |
| 67 | + for (const el of document.querySelectorAll("body *")) { | |
| 68 | + const r = el.getBoundingClientRect(); | |
| 69 | + if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== "fixed") wide.push(`${tag(el)}:+${Math.round(r.right - vw)}`); | |
| 70 | + } | |
| 71 | + const overflow = doc.scrollWidth > doc.clientWidth ? { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth, wide: wide.slice(0, 6) } : null; | |
| 72 | + | |
| 73 | + // 2. clipped text (overflow hidden without ellipsis) | |
| 74 | + const clipped = []; | |
| 75 | + for (const el of document.querySelectorAll("body *")) { | |
| 76 | + if (!el.childNodes.length || clipped.length > 8) continue; | |
| 77 | + const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim()); | |
| 78 | + if (!own || !vis(el)) continue; | |
| 79 | + const cs = getComputedStyle(el); | |
| 80 | + const hid = ["hidden", "clip"].includes(cs.overflowX) || ["hidden", "clip"].includes(cs.overflowY); | |
| 81 | + if (!hid || cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") continue; | |
| 82 | + if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 4) clipped.push(`${tag(el)} "${el.textContent.trim().slice(0, 40)}" ${el.scrollWidth}>${el.clientWidth}`); | |
| 83 | + } | |
| 84 | + | |
| 85 | + // 3. tap targets (mobile) — non-inline interactive elements below 40 px | |
| 86 | + const small = []; | |
| 87 | + if (mobile) { | |
| 88 | + for (const el of document.querySelectorAll("a, button, [role=button], input, select, [role=tab]")) { | |
| 89 | + if (!vis(el)) continue; | |
| 90 | + const cs = getComputedStyle(el); | |
| 91 | + if (cs.display === "inline" || cs.display === "contents") continue; // inline text links are exempt (WCAG 2.5.8 inline exception) | |
| 92 | + const r = el.getBoundingClientRect(); | |
| 93 | + if (r.height < 40 || r.width < 40) small.push(`${tag(el)} ${Math.round(r.width)}×${Math.round(r.height)} "${(el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 24)}"`); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + // 4. sticky header must not cover the h1 at load | |
| 98 | + const header = document.querySelector("header"); | |
| 99 | + const h1 = document.querySelector("main h1, h1"); | |
| 100 | + const hb = header ? header.getBoundingClientRect().bottom : 0; | |
| 101 | + const h1Top = h1 ? h1.getBoundingClientRect().top : null; | |
| 102 | + const h1Covered = h1 && getComputedStyle(header).position === "sticky" ? h1Top < hb - 1 : false; | |
| 103 | + | |
| 104 | + // 5. a11y basics | |
| 105 | + const lang = doc.getAttribute("lang"); | |
| 106 | + const metaDesc = document.querySelector('meta[name="description"]')?.getAttribute("content") ?? null; | |
| 107 | + const hs = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).filter(vis).map((h) => Number(h.tagName[1])); | |
| 108 | + const h1Count = hs.filter((n) => n === 1).length; | |
| 109 | + let skipped = []; | |
| 110 | + for (let i = 1; i < hs.length; i++) if (hs[i] > hs[i - 1] + 1) skipped.push(`h${hs[i - 1]}→h${hs[i]}`); | |
| 111 | + const unnamed = []; | |
| 112 | + for (const b of document.querySelectorAll("button, a")) { | |
| 113 | + if (!vis(b)) continue; | |
| 114 | + const name = (b.getAttribute("aria-label") || b.getAttribute("title") || b.textContent || "").trim(); | |
| 115 | + const labelled = b.getAttribute("aria-labelledby") || b.querySelector("img[alt], svg[aria-label], [role=img][aria-label]"); | |
| 116 | + if (!name && !labelled) unnamed.push(tag(b)); | |
| 117 | + } | |
| 118 | + const ariaCurrent = document.querySelectorAll("nav a[aria-current=page]").length; | |
| 119 | + const text = document.body.innerText; | |
| 120 | + const footerOk = text.includes("Simon-Pierre Boucher") && text.includes("contact@spboucher.ai") && text.includes("MacLustr"); | |
| 121 | + | |
| 122 | + // 6. contrast (dark only, small text < 18 px) — effective background from the nearest opaque ancestor | |
| 123 | + const contrast = []; | |
| 124 | + let contrastChecked = 0; | |
| 125 | + if (dark) { | |
| 126 | + const parse = (c) => { | |
| 127 | + const m = c.match(/rgba?\(([^)]+)\)/); | |
| 128 | + if (!m) return null; | |
| 129 | + const p = m[1].split(",").map((x) => parseFloat(x)); | |
| 130 | + return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; | |
| 131 | + }; | |
| 132 | + const lum = ({ r, g, b }) => { | |
| 133 | + const f = (v) => { | |
| 134 | + v /= 255; | |
| 135 | + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; | |
| 136 | + }; | |
| 137 | + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); | |
| 138 | + }; | |
| 139 | + const bgOf = (el) => { | |
| 140 | + let e = el; | |
| 141 | + while (e && e !== document.documentElement) { | |
| 142 | + const c = parse(getComputedStyle(e).backgroundColor); | |
| 143 | + if (c && c.a >= 0.9) return c; | |
| 144 | + e = e.parentElement; | |
| 145 | + } | |
| 146 | + return parse(getComputedStyle(document.body).backgroundColor) || { r: 11, g: 15, b: 20, a: 1 }; | |
| 147 | + }; | |
| 148 | + const seen = new Map(); | |
| 149 | + for (const el of document.querySelectorAll("main *, footer *, header *")) { | |
| 150 | + if (contrastChecked > 1500) break; | |
| 151 | + const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim().length > 1); | |
| 152 | + if (!own || !vis(el) || el.closest("[aria-hidden=true], svg, kbd, .maplibregl-map")) continue; | |
| 153 | + const cs = getComputedStyle(el); | |
| 154 | + const fs = parseFloat(cs.fontSize); | |
| 155 | + if (fs >= 18 || (fs >= 14 && parseInt(cs.fontWeight) >= 700)) continue; | |
| 156 | + const fg = parse(cs.color); | |
| 157 | + if (!fg || fg.a < 0.5) continue; | |
| 158 | + const bg = bgOf(el); | |
| 159 | + const l1 = lum(fg), l2 = lum(bg); | |
| 160 | + const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); | |
| 161 | + contrastChecked++; | |
| 162 | + if (ratio < 4.5) { | |
| 163 | + const key = `${cs.color}|${tag(el).split(".").slice(1, 3).join(".")}`; | |
| 164 | + if (!seen.has(key)) seen.set(key, { ratio: Math.round(ratio * 100) / 100, color: cs.color, bg: `rgb(${bg.r},${bg.g},${bg.b})`, sample: `${tag(el)} "${el.textContent.trim().slice(0, 30)}"`, n: 0 }); | |
| 165 | + seen.get(key).n++; | |
| 166 | + } | |
| 167 | + } | |
| 168 | + contrast.push(...seen.values()); | |
| 169 | + } | |
| 170 | + | |
| 171 | + // 7. bottom nav / footer visibility after scrolling to the end (mobile) | |
| 172 | + return { overflow, clipped, small: small.slice(0, 12), smallCount: small.length, h1Covered, h1Top: h1Top === null ? null : Math.round(h1Top), headerBottom: Math.round(hb), lang, metaDesc: metaDesc ? metaDesc.length : null, h1Count, skipped, unnamed: unnamed.slice(0, 6), ariaCurrent, footerOk, contrast: contrast.sort((a, b) => a.ratio - b.ratio).slice(0, 8), contrastChecked, height: doc.scrollHeight, title: document.title }; | |
| 173 | +} | |
| 174 | + | |
| 175 | +function bottomCheck() { | |
| 176 | + window.scrollTo(0, document.documentElement.scrollHeight); | |
| 177 | + const nav = document.querySelector('nav[aria-label="Mobile"]'); | |
| 178 | + const foot = document.querySelector("footer p:last-of-type") || document.querySelector("footer"); | |
| 179 | + if (!nav || !foot) return { navVisible: !!nav && getComputedStyle(nav).display !== "none", covered: false }; | |
| 180 | + const nr = nav.getBoundingClientRect(); | |
| 181 | + const fr = foot.getBoundingClientRect(); | |
| 182 | + const navVisible = getComputedStyle(nav).display !== "none"; | |
| 183 | + return { navVisible, covered: navVisible && fr.bottom > nr.top + 1, gap: Math.round(nr.top - fr.bottom) }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +function mapControls() { | |
| 187 | + const nav = document.querySelector('nav[aria-label="Mobile"]'); | |
| 188 | + const navTop = nav && getComputedStyle(nav).display !== "none" ? nav.getBoundingClientRect().top : window.innerHeight; | |
| 189 | + const header = document.querySelector("header"); | |
| 190 | + const hb = header ? header.getBoundingClientRect().bottom : 0; | |
| 191 | + const btns = Array.from(document.querySelectorAll(".maplibregl-ctrl button, [data-map-control] button, [data-map-control]")).filter((b) => b.getBoundingClientRect().width > 0); | |
| 192 | + const bad = btns.filter((b) => { | |
| 193 | + const r = b.getBoundingClientRect(); | |
| 194 | + return r.bottom > navTop || r.top < hb || r.right > window.innerWidth || r.left < 0; | |
| 195 | + }); | |
| 196 | + return { controls: btns.length, unreachable: bad.length, canvas: !!document.querySelector("canvas.maplibregl-canvas, .maplibregl-canvas") }; | |
| 197 | +} | |
| 198 | + | |
| 199 | +/* ---------------------------------------------------------------- run */ | |
| 200 | +const browser = await chromium.launch(); | |
| 201 | +const results = []; | |
| 202 | +const ttfb = {}; | |
| 203 | +for (const v of VARIANTS) { | |
| 204 | + const mobile = v.w < 700; | |
| 205 | + const ctx = await browser.newContext({ viewport: { width: v.w, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: v.theme, locale: "en-CA", timezoneId: "America/Toronto" }); | |
| 206 | + await ctx.addInitScript((theme) => { | |
| 207 | + try { | |
| 208 | + window.localStorage.setItem("theme", theme); | |
| 209 | + } catch {} | |
| 210 | + }, v.theme); | |
| 211 | + const page = await ctx.newPage(); | |
| 212 | + for (const [name, path] of PAGES) { | |
| 213 | + if (ONLY && !ONLY.has(name)) continue; | |
| 214 | + const errors = []; | |
| 215 | + const bad = []; | |
| 216 | + const onConsole = (m) => { | |
| 217 | + if (m.type() === "error") errors.push(m.text().slice(0, 240)); | |
| 218 | + }; | |
| 219 | + const onErr = (e) => errors.push("pageerror " + String(e).slice(0, 240)); | |
| 220 | + const onResp = (r) => { | |
| 221 | + if (r.status() >= 400 && !(name === "404" && r.request().resourceType() === "document")) bad.push(`${r.status()} ${r.request().resourceType()} ${r.url().replace(BASE, "").slice(0, 100)}`); | |
| 222 | + }; | |
| 223 | + page.on("console", onConsole); | |
| 224 | + page.on("pageerror", onErr); | |
| 225 | + page.on("response", onResp); | |
| 226 | + const row = { name, path, w: v.w, theme: v.theme }; | |
| 227 | + try { | |
| 228 | + const resp = await page.goto(`${BASE}${path}`, { waitUntil: "load", timeout: 90000 }); | |
| 229 | + row.status = resp?.status(); | |
| 230 | + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); | |
| 231 | + await page.waitForTimeout(name === "map" ? 3000 : 700); | |
| 232 | + const nav = await page.evaluate(() => { | |
| 233 | + const n = performance.getEntriesByType("navigation")[0]; | |
| 234 | + return n ? Math.round(n.responseStart - n.requestStart) : null; | |
| 235 | + }); | |
| 236 | + (ttfb[path] ??= []).push(nav); | |
| 237 | + row.ttfb = nav; | |
| 238 | + Object.assign(row, await page.evaluate(audit, { mobile, dark: v.theme === "dark" })); | |
| 239 | + if (name === "map") row.map = await page.evaluate(mapControls); | |
| 240 | + if (mobile && name !== "map") row.bottom = await page.evaluate(bottomCheck); | |
| 241 | + await page.evaluate(() => window.scrollTo(0, 0)); | |
| 242 | + await page.waitForTimeout(150); | |
| 243 | + const shot = resolve(OUT, `${name}-${v.w}-${v.theme}.png`); | |
| 244 | + await page.screenshot({ path: shot, fullPage: FULL_SHOT.has(v.w) && name !== "map" && row.height < 20000 }); | |
| 245 | + | |
| 246 | + // interactions (390 + 1280 light only) | |
| 247 | + if (v.theme === "light" && FULL_SHOT.has(v.w)) { | |
| 248 | + const inter = {}; | |
| 249 | + if (name === "home") { | |
| 250 | + await page.keyboard.press(process.platform === "darwin" ? "Meta+k" : "Control+k"); | |
| 251 | + const dialog = page.locator('[role=dialog][aria-label="Search the index"]'); | |
| 252 | + await dialog.waitFor({ state: "visible", timeout: 2000 }).catch(() => {}); | |
| 253 | + inter.paletteOpens = await dialog.isVisible().catch(() => false); | |
| 254 | + await page.waitForTimeout(150); // input receives focus ~10 ms after open | |
| 255 | + await page.keyboard.press("Escape"); | |
| 256 | + await dialog.waitFor({ state: "hidden", timeout: 2000 }).catch(() => {}); | |
| 257 | + inter.paletteCloses = !(await dialog.isVisible().catch(() => false)); | |
| 258 | + if (!inter.paletteCloses) await page.getByRole("button", { name: "Close search" }).click().catch(() => {}); | |
| 259 | + const before = await page.evaluate(() => document.documentElement.className); | |
| 260 | + await page.locator('button[aria-label*="theme" i]').first().click(); | |
| 261 | + await page.waitForTimeout(200); | |
| 262 | + const after = await page.evaluate(() => document.documentElement.className); | |
| 263 | + inter.themeToggles = before !== after; | |
| 264 | + await page.locator('button[aria-label*="theme" i]').first().click(); | |
| 265 | + // focus ring | |
| 266 | + await page.keyboard.press("Tab"); | |
| 267 | + await page.keyboard.press("Tab"); | |
| 268 | + inter.focusVisible = await page.evaluate(() => { | |
| 269 | + const a = document.activeElement; | |
| 270 | + if (!a || a === document.body) return null; | |
| 271 | + const cs = getComputedStyle(a); | |
| 272 | + return { el: a.tagName.toLowerCase() + (a.getAttribute("aria-label") ? `[${a.getAttribute("aria-label")}]` : ""), outline: cs.outlineStyle !== "none" && parseFloat(cs.outlineWidth) > 0, ring: cs.boxShadow !== "none" }; | |
| 273 | + }); | |
| 274 | + } | |
| 275 | + if (name === "datacenters" || name === "live" || name === "projects") { | |
| 276 | + const btn = page.getByRole("button", { name: /filters/i }).first(); | |
| 277 | + if (await btn.count()) { | |
| 278 | + await btn.click(); | |
| 279 | + inter.filterSheetOpens = await page.locator("[role=dialog]").first().isVisible({ timeout: 2000 }).catch(() => false); | |
| 280 | + await page.keyboard.press("Escape"); | |
| 281 | + await page.waitForTimeout(300); | |
| 282 | + inter.filterSheetCloses = !(await page.locator("[role=dialog]").first().isVisible().catch(() => false)); | |
| 283 | + } | |
| 284 | + } | |
| 285 | + if (name === "datacenters" || name === "operators" || name === "projects") { | |
| 286 | + const next = page.locator('a[aria-label="Next page"]').first(); | |
| 287 | + if (await next.count()) { | |
| 288 | + await next.click(); | |
| 289 | + await page.waitForURL(/page=2/, { timeout: 15000 }).catch(() => {}); | |
| 290 | + inter.paginationPage2 = /page=2/.test(page.url()); | |
| 291 | + await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {}); | |
| 292 | + inter.page2Rows = await page.locator("table tbody tr").count(); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + if (name === "country-us" && mobile) { | |
| 296 | + const show = page.locator("#facilities").getByRole("button", { name: /show all/i }).first(); | |
| 297 | + if (await show.count()) { | |
| 298 | + await show.scrollIntoViewIfNeeded(); | |
| 299 | + const before = await page.locator("#facilities tbody tr:visible").count().catch(() => null); | |
| 300 | + await show.click(); | |
| 301 | + await page.waitForTimeout(200); | |
| 302 | + const after = await page.locator("#facilities tbody tr:visible").count().catch(() => null); | |
| 303 | + inter.showAll = { before, after, expanded: (await show.getAttribute("aria-expanded")) === "true" }; | |
| 304 | + } | |
| 305 | + } | |
| 306 | + const tabs = page.locator("[role=tablist] [role=tab]"); | |
| 307 | + if ((await tabs.count()) >= 2) { | |
| 308 | + await tabs.nth(1).click(); | |
| 309 | + await page.waitForTimeout(200); | |
| 310 | + inter.tabSwitch = (await tabs.nth(1).getAttribute("aria-selected")) === "true"; | |
| 311 | + } | |
| 312 | + if (name === "map") { | |
| 313 | + const zoomIn = page.locator(".maplibregl-ctrl-zoom-in").first(); | |
| 314 | + inter.zoomVisible = (await zoomIn.count()) ? await zoomIn.isVisible() : null; | |
| 315 | + const searchBtn = page.getByRole("button", { name: /search|filter/i }).first(); | |
| 316 | + if (await searchBtn.count()) { | |
| 317 | + await searchBtn.click().catch(() => {}); | |
| 318 | + await page.waitForTimeout(500); | |
| 319 | + inter.mapPanelOpens = await page.locator("[role=dialog]").first().isVisible().catch(() => false); | |
| 320 | + await page.keyboard.press("Escape"); | |
| 321 | + } | |
| 322 | + } | |
| 323 | + if (Object.keys(inter).length) row.inter = inter; | |
| 324 | + } | |
| 325 | + } catch (e) { | |
| 326 | + row.error = String(e).slice(0, 200); | |
| 327 | + } | |
| 328 | + row.errors = [...new Set(errors)]; | |
| 329 | + row.badResponses = [...new Set(bad)]; | |
| 330 | + page.off("console", onConsole); | |
| 331 | + page.off("pageerror", onErr); | |
| 332 | + page.off("response", onResp); | |
| 333 | + results.push(row); | |
| 334 | + const flags = []; | |
| 335 | + if (row.overflow) flags.push("OVERFLOW"); | |
| 336 | + if (row.clipped?.length) flags.push(`clipped:${row.clipped.length}`); | |
| 337 | + if (row.smallCount) flags.push(`tap<40:${row.smallCount}`); | |
| 338 | + if (row.h1Covered) flags.push("h1-covered"); | |
| 339 | + if (row.errors.length) flags.push(`console:${row.errors.length}`); | |
| 340 | + if (row.badResponses.length) flags.push(`http4xx5xx:${row.badResponses.length}`); | |
| 341 | + if (row.bottom?.covered) flags.push("footer-under-nav"); | |
| 342 | + if (row.contrast?.length) flags.push(`contrast<4.5:${row.contrast.length}`); | |
| 343 | + if (row.footerOk === false && name !== "map") flags.push("no-footer-credits"); | |
| 344 | + if (row.h1Count !== 1) flags.push(`h1×${row.h1Count}`); | |
| 345 | + if (row.skipped?.length) flags.push(`heading-skip:${row.skipped.join(",")}`); | |
| 346 | + if (row.unnamed?.length) flags.push(`unnamed:${row.unnamed.length}`); | |
| 347 | + if (row.metaDesc === null) flags.push("no-meta-desc"); | |
| 348 | + if (row.error) flags.push("ERROR"); | |
| 349 | + console.log(`${String(v.w).padStart(4)} ${v.theme.padEnd(5)} ${name.padEnd(16)} ${String(row.status ?? "").padEnd(3)} h=${String(row.height ?? "").padStart(5)} ttfb=${String(row.ttfb ?? "").padStart(4)}ms ${flags.length ? flags.join(" ") : "ok"}`); | |
| 350 | + } | |
| 351 | + await ctx.close(); | |
| 352 | +} | |
| 353 | +await browser.close(); | |
| 354 | +writeFileSync(resolve(OUT, "results.json"), JSON.stringify({ base: BASE, at: new Date().toISOString(), results }, null, 1)); | |
| 355 | +console.log(`\nwrote ${OUT}/results.json (${results.length} rows)`); | |
added
apps/web/qa/sweep/404-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/404-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/404-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/404-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/about-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/about-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/about-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/about-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/bot-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/bot-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/bot-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/bot-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-region-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-region-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-region-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-region-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-regions-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-regions-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-regions-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/cloud-regions-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/countries-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/countries-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/countries-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/countries-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-ca-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-ca-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-ca-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-ca-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-us-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-us-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-us-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/country-us-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/datacenters-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/datacenters-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/datacenters-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/datacenters-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-google-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-google-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-google-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-google-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-operator-mw-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-operator-mw-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-operator-mw-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-operator-mw-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-osm-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-osm-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-osm-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-osm-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-wikidata-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-wikidata-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-wikidata-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/fac-wikidata-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/home-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/home-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/home-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/home-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ixps-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ixps-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ixps-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ixps-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/live-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/live-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/live-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/live-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/map-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/map-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/map-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/map-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/market-nova-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/market-nova-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/market-nova-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/market-nova-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/markets-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/markets-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/markets-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/markets-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/methodology-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/methodology-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/methodology-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/methodology-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operator-equinix-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operator-equinix-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operator-equinix-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operator-equinix-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operators-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operators-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operators-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/operators-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/project-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/project-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/project-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/project-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/projects-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/projects-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/projects-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/projects-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ranking-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ranking-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ranking-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/ranking-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/rankings-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/rankings-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/rankings-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/rankings-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/results.json
+6105 −0
@@ -0,0 +1,6105 @@ | ||
| 1 | +{ | |
| 2 | + "base": "http://localhost:8314", | |
| 3 | + "at": "2026-09-11T14:00:33.338Z", | |
| 4 | + "results": [ | |
| 5 | + { | |
| 6 | + "name": "home", | |
| 7 | + "path": "/", | |
| 8 | + "w": 390, | |
| 9 | + "theme": "light", | |
| 10 | + "status": 200, | |
| 11 | + "ttfb": 7, | |
| 12 | + "overflow": null, | |
| 13 | + "clipped": [ | |
| 14 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 15 | + "span.sr-only \"United States\" 89>1", | |
| 16 | + "span.sr-only \"United States\" 89>1", | |
| 17 | + "span.sr-only \"United States\" 89>1", | |
| 18 | + "span.sr-only \"United States\" 76>1", | |
| 19 | + "span.sr-only \"United States\" 76>1", | |
| 20 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 21 | + "span.sr-only \"France\" 42>1", | |
| 22 | + "span.sr-only \"Singapore\" 62>1" | |
| 23 | + ], | |
| 24 | + "small": [ | |
| 25 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 26 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 27 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 28 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 29 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 30 | + "button.mr-1.5.inline-flex.size-9 36×36 \"Search\"", | |
| 31 | + "button.shrink-0.rounded-sm.border 111×27 \"Northern Virginia\"", | |
| 32 | + "button.shrink-0.rounded-sm.border 131×27 \"hyperscale in Ireland\"", | |
| 33 | + "button.shrink-0.rounded-sm.border 179×27 \"> 100 MW under construct\"", | |
| 34 | + "button.shrink-0.rounded-sm.border 57×27 \"Equinix\"", | |
| 35 | + "button.shrink-0.rounded-sm.border 51×27 \"Japan\"", | |
| 36 | + "a.inline-flex.items-center.gap-1 79×18 \"Open map→\"" | |
| 37 | + ], | |
| 38 | + "smallCount": 94, | |
| 39 | + "h1Covered": false, | |
| 40 | + "h1Top": 100, | |
| 41 | + "headerBottom": 49, | |
| 42 | + "lang": "en", | |
| 43 | + "metaDesc": 159, | |
| 44 | + "h1Count": 1, | |
| 45 | + "skipped": [], | |
| 46 | + "unnamed": [], | |
| 47 | + "ariaCurrent": 1, | |
| 48 | + "footerOk": true, | |
| 49 | + "contrast": [], | |
| 50 | + "contrastChecked": 0, | |
| 51 | + "height": 6300, | |
| 52 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 53 | + "bottom": { | |
| 54 | + "navVisible": true, | |
| 55 | + "covered": false, | |
| 56 | + "gap": 809 | |
| 57 | + }, | |
| 58 | + "error": "TimeoutError: locator.click: Timeout 30000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('button[aria-label*=\"theme\" i]').first()\u001b[22m\n\u001b[2m - locator resolved to <button type=\"button\" title=\"Sw", | |
| 59 | + "errors": [], | |
| 60 | + "badResponses": [] | |
| 61 | + }, | |
| 62 | + { | |
| 63 | + "name": "map", | |
| 64 | + "path": "/map", | |
| 65 | + "w": 390, | |
| 66 | + "theme": "light", | |
| 67 | + "status": 200, | |
| 68 | + "ttfb": 2, | |
| 69 | + "overflow": null, | |
| 70 | + "clipped": [ | |
| 71 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 72 | + ], | |
| 73 | + "small": [ | |
| 74 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 75 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 76 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 77 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 78 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 79 | + "button.maplibregl-ctrl-geolocate 32×32 \"Location not available\"", | |
| 80 | + "button.maplibregl-ctrl-zoom-in 32×32 \"Zoom in\"", | |
| 81 | + "button.maplibregl-ctrl-zoom-out 32×32 \"Zoom out\"", | |
| 82 | + "input.h-full.min-w-0.flex-1 296×38 \"Search the map\"", | |
| 83 | + "button.inline-flex.h-8.items-center 32×32 \"Filters\"", | |
| 84 | + "button.inline-flex.h-8.items-center 93×32 \"Legend\"" | |
| 85 | + ], | |
| 86 | + "smallCount": 11, | |
| 87 | + "h1Covered": false, | |
| 88 | + "h1Top": null, | |
| 89 | + "headerBottom": 49, | |
| 90 | + "lang": "en", | |
| 91 | + "metaDesc": 127, | |
| 92 | + "h1Count": 0, | |
| 93 | + "skipped": [], | |
| 94 | + "unnamed": [], | |
| 95 | + "ariaCurrent": 2, | |
| 96 | + "footerOk": false, | |
| 97 | + "contrast": [], | |
| 98 | + "contrastChecked": 0, | |
| 99 | + "height": 845, | |
| 100 | + "title": "Global data center map · DataCenterIndex", | |
| 101 | + "map": { | |
| 102 | + "controls": 3, | |
| 103 | + "unreachable": 0, | |
| 104 | + "canvas": true | |
| 105 | + }, | |
| 106 | + "inter": { | |
| 107 | + "zoomVisible": true, | |
| 108 | + "mapPanelOpens": true | |
| 109 | + }, | |
| 110 | + "errors": [], | |
| 111 | + "badResponses": [] | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "name": "search", | |
| 115 | + "path": "/search?q=montreal", | |
| 116 | + "w": 390, | |
| 117 | + "theme": "light", | |
| 118 | + "status": 200, | |
| 119 | + "ttfb": 7, | |
| 120 | + "overflow": null, | |
| 121 | + "clipped": [ | |
| 122 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 123 | + ], | |
| 124 | + "small": [ | |
| 125 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 126 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 127 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 128 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 129 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 130 | + "a.inline-flex.items-center.justify-center 122×32 \"Open in map\"", | |
| 131 | + "a.ml-1.text-ink-2.underline 85×17 \"Apply as filters\"", | |
| 132 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 133 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 134 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 135 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 136 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"" | |
| 137 | + ], | |
| 138 | + "smallCount": 22, | |
| 139 | + "h1Covered": false, | |
| 140 | + "h1Top": 90, | |
| 141 | + "headerBottom": 49, | |
| 142 | + "lang": "en", | |
| 143 | + "metaDesc": 159, | |
| 144 | + "h1Count": 1, | |
| 145 | + "skipped": [], | |
| 146 | + "unnamed": [], | |
| 147 | + "ariaCurrent": 0, | |
| 148 | + "footerOk": true, | |
| 149 | + "contrast": [], | |
| 150 | + "contrastChecked": 0, | |
| 151 | + "height": 1871, | |
| 152 | + "title": "“montreal” — search · DataCenterIndex", | |
| 153 | + "bottom": { | |
| 154 | + "navVisible": true, | |
| 155 | + "covered": false, | |
| 156 | + "gap": 810 | |
| 157 | + }, | |
| 158 | + "errors": [], | |
| 159 | + "badResponses": [] | |
| 160 | + }, | |
| 161 | + { | |
| 162 | + "name": "live", | |
| 163 | + "path": "/live", | |
| 164 | + "w": 390, | |
| 165 | + "theme": "light", | |
| 166 | + "status": 200, | |
| 167 | + "ttfb": 6, | |
| 168 | + "overflow": null, | |
| 169 | + "clipped": [ | |
| 170 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 171 | + "span.sr-only \"United States\" 89>1", | |
| 172 | + "span.sr-only \"United States\" 89>1", | |
| 173 | + "span.sr-only \"United States\" 89>1", | |
| 174 | + "span.sr-only \"United States\" 89>1", | |
| 175 | + "span.sr-only \"United States\" 89>1", | |
| 176 | + "span.sr-only \"United States\" 89>1", | |
| 177 | + "span.sr-only \"United States\" 89>1", | |
| 178 | + "span.sr-only \"United States\" 89>1" | |
| 179 | + ], | |
| 180 | + "small": [ | |
| 181 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 182 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 183 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 184 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 185 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 186 | + "button.inline-flex.items-center.justify-center 80×32 \"Filters\"", | |
| 187 | + "a.inline-flex.items-center.gap-1 181×17 \"Duke Energy — news relea\"", | |
| 188 | + "a.inline-flex.items-center.gap-1 245×17 \"American Electric Power \"", | |
| 189 | + "a.hover:text-ink 85×17 \"Element Critical\"", | |
| 190 | + "a.inline-flex.items-center.gap-1 101×17 \"Element Critical\"", | |
| 191 | + "a.hover:text-ink 85×17 \"Element Critical\"", | |
| 192 | + "a.inline-flex.items-center.gap-1 101×17 \"Element Critical\"" | |
| 193 | + ], | |
| 194 | + "smallCount": 120, | |
| 195 | + "h1Covered": false, | |
| 196 | + "h1Top": 91, | |
| 197 | + "headerBottom": 49, | |
| 198 | + "lang": "en", | |
| 199 | + "metaDesc": 152, | |
| 200 | + "h1Count": 1, | |
| 201 | + "skipped": [ | |
| 202 | + "h1→h3" | |
| 203 | + ], | |
| 204 | + "unnamed": [], | |
| 205 | + "ariaCurrent": 1, | |
| 206 | + "footerOk": true, | |
| 207 | + "contrast": [], | |
| 208 | + "contrastChecked": 0, | |
| 209 | + "height": 10195, | |
| 210 | + "title": "Live changes · DataCenterIndex", | |
| 211 | + "bottom": { | |
| 212 | + "navVisible": true, | |
| 213 | + "covered": false, | |
| 214 | + "gap": 809 | |
| 215 | + }, | |
| 216 | + "inter": { | |
| 217 | + "filterSheetOpens": true, | |
| 218 | + "filterSheetCloses": true | |
| 219 | + }, | |
| 220 | + "errors": [], | |
| 221 | + "badResponses": [] | |
| 222 | + }, | |
| 223 | + { | |
| 224 | + "name": "datacenters", | |
| 225 | + "path": "/datacenters", | |
| 226 | + "w": 390, | |
| 227 | + "theme": "light", | |
| 228 | + "status": 200, | |
| 229 | + "ttfb": 4, | |
| 230 | + "overflow": null, | |
| 231 | + "clipped": [ | |
| 232 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 233 | + ], | |
| 234 | + "small": [ | |
| 235 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 236 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 237 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 238 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 239 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 240 | + "a.inline-flex.items-center.justify-center 122×32 \"Open in map\"", | |
| 241 | + "button.inline-flex.items-center.justify-center 80×32 \"Filters\"", | |
| 242 | + "a.font-medium.text-ink.hover:text-accent 186×18 \"Bulk N01 Data Center Cam\"", | |
| 243 | + "a.font-medium.text-ink.hover:text-accent 225×35 \"Downtown Minneapolis Dat\"", | |
| 244 | + "a.font-medium.text-ink.hover:text-accent 225×35 \"Dallas Love Field Data C\"", | |
| 245 | + "a.font-medium.text-ink.hover:text-accent 139×18 \"CloudHQ SAT Campus\"", | |
| 246 | + "a.font-medium.text-ink.hover:text-accent 183×18 \"Skybox PowerCampus Austi\"" | |
| 247 | + ], | |
| 248 | + "smallCount": 33, | |
| 249 | + "h1Covered": false, | |
| 250 | + "h1Top": 90, | |
| 251 | + "headerBottom": 49, | |
| 252 | + "lang": "en", | |
| 253 | + "metaDesc": 119, | |
| 254 | + "h1Count": 1, | |
| 255 | + "skipped": [], | |
| 256 | + "unnamed": [], | |
| 257 | + "ariaCurrent": 2, | |
| 258 | + "footerOk": true, | |
| 259 | + "contrast": [], | |
| 260 | + "contrastChecked": 0, | |
| 261 | + "height": 2286, | |
| 262 | + "title": "Data centers · DataCenterIndex", | |
| 263 | + "bottom": { | |
| 264 | + "navVisible": true, | |
| 265 | + "covered": false, | |
| 266 | + "gap": 810 | |
| 267 | + }, | |
| 268 | + "inter": { | |
| 269 | + "filterSheetOpens": true, | |
| 270 | + "filterSheetCloses": true, | |
| 271 | + "paginationPage2": true, | |
| 272 | + "page2Rows": 50 | |
| 273 | + }, | |
| 274 | + "errors": [ | |
| 275 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 276 | + ], | |
| 277 | + "badResponses": [ | |
| 278 | + "404 fetch /api/v1/docs?_rsc=2QMc8M2_JReeYqv7" | |
| 279 | + ] | |
| 280 | + }, | |
| 281 | + { | |
| 282 | + "name": "fac-operator-mw", | |
| 283 | + "path": "/datacenters/databank-downtown-minneapolis-data-center-msp4", | |
| 284 | + "w": 390, | |
| 285 | + "theme": "light", | |
| 286 | + "status": 200, | |
| 287 | + "ttfb": 12, | |
| 288 | + "overflow": null, | |
| 289 | + "clipped": [ | |
| 290 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 291 | + "span.sr-only \"United States\" 86>1", | |
| 292 | + "span.sr-only \"United States\" 82>1", | |
| 293 | + "span.sr-only \"United States\" 89>1" | |
| 294 | + ], | |
| 295 | + "small": [ | |
| 296 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 297 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 298 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 299 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 300 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 301 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 302 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 303 | + "a.hover:text-ink.hover:underline 117×17 \"Minneapolis–St. Paul\"", | |
| 304 | + "a.normal-case.tracking-normal.text-[12.5px] 57×18 \"DataBank\"", | |
| 305 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 306 | + "a.hover:text-accent.hover:underline 180×20 \"Minneapolis–St. Paul mar\"", | |
| 307 | + "a.hover:text-accent.hover:underline 86×20 \"United States\"" | |
| 308 | + ], | |
| 309 | + "smallCount": 65, | |
| 310 | + "h1Covered": false, | |
| 311 | + "h1Top": 119, | |
| 312 | + "headerBottom": 49, | |
| 313 | + "lang": "en", | |
| 314 | + "metaDesc": 212, | |
| 315 | + "h1Count": 1, | |
| 316 | + "skipped": [], | |
| 317 | + "unnamed": [], | |
| 318 | + "ariaCurrent": 1, | |
| 319 | + "footerOk": true, | |
| 320 | + "contrast": [], | |
| 321 | + "contrastChecked": 0, | |
| 322 | + "height": 6136, | |
| 323 | + "title": "Downtown Minneapolis Data Center (MSP4) (Minneapolis, US) · DataCenterIndex", | |
| 324 | + "bottom": { | |
| 325 | + "navVisible": true, | |
| 326 | + "covered": false, | |
| 327 | + "gap": 1106 | |
| 328 | + }, | |
| 329 | + "errors": [], | |
| 330 | + "badResponses": [] | |
| 331 | + }, | |
| 332 | + { | |
| 333 | + "name": "fac-osm", | |
| 334 | + "path": "/datacenters/shelter-modernizacion", | |
| 335 | + "w": 390, | |
| 336 | + "theme": "light", | |
| 337 | + "status": 200, | |
| 338 | + "ttfb": 12, | |
| 339 | + "overflow": null, | |
| 340 | + "clipped": [ | |
| 341 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 342 | + "span.sr-only \"Argentina\" 62>1", | |
| 343 | + "span.sr-only \"Argentina\" 59>1", | |
| 344 | + "span.sr-only \"Argentina\" 64>1" | |
| 345 | + ], | |
| 346 | + "small": [ | |
| 347 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 348 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 349 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 350 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 351 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 352 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 353 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 354 | + "a.hover:text-ink.hover:underline 55×17 \"Argentina\"", | |
| 355 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 356 | + "a.hover:text-accent.hover:underline 62×20 \"Argentina\"", | |
| 357 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 358 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 359 | + ], | |
| 360 | + "smallCount": 46, | |
| 361 | + "h1Covered": false, | |
| 362 | + "h1Top": 116, | |
| 363 | + "headerBottom": 49, | |
| 364 | + "lang": "en", | |
| 365 | + "metaDesc": 128, | |
| 366 | + "h1Count": 1, | |
| 367 | + "skipped": [], | |
| 368 | + "unnamed": [], | |
| 369 | + "ariaCurrent": 1, | |
| 370 | + "footerOk": true, | |
| 371 | + "contrast": [], | |
| 372 | + "contrastChecked": 0, | |
| 373 | + "height": 4626, | |
| 374 | + "title": "Shelter Modernización · DataCenterIndex", | |
| 375 | + "bottom": { | |
| 376 | + "navVisible": true, | |
| 377 | + "covered": false, | |
| 378 | + "gap": 1106 | |
| 379 | + }, | |
| 380 | + "errors": [], | |
| 381 | + "badResponses": [] | |
| 382 | + }, | |
| 383 | + { | |
| 384 | + "name": "fac-google", | |
| 385 | + "path": "/datacenters/google-the-dalles-oregon-data-center", | |
| 386 | + "w": 390, | |
| 387 | + "theme": "light", | |
| 388 | + "status": 200, | |
| 389 | + "ttfb": 21, | |
| 390 | + "overflow": null, | |
| 391 | + "clipped": [ | |
| 392 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 393 | + "span.sr-only \"United States\" 86>1", | |
| 394 | + "span.sr-only \"United States\" 82>1", | |
| 395 | + "span.sr-only \"United States\" 89>1", | |
| 396 | + "span.sr-only \"United States\" 89>1", | |
| 397 | + "span.sr-only \"United States\" 89>1", | |
| 398 | + "span.sr-only \"United States\" 89>1" | |
| 399 | + ], | |
| 400 | + "small": [ | |
| 401 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 402 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 403 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 404 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 405 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 406 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 407 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 408 | + "a.hover:text-ink.hover:underline 109×17 \"Hillsboro / Portland\"", | |
| 409 | + "a.normal-case.tracking-normal.text-[12.5px] 43×18 \"Google\"", | |
| 410 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (2 sources)\"", | |
| 411 | + "a.hover:text-accent.hover:underline 170×20 \"Hillsboro / Portland mar\"", | |
| 412 | + "a.hover:text-accent.hover:underline 86×20 \"United States\"" | |
| 413 | + ], | |
| 414 | + "smallCount": 101, | |
| 415 | + "h1Covered": false, | |
| 416 | + "h1Top": 119, | |
| 417 | + "headerBottom": 49, | |
| 418 | + "lang": "en", | |
| 419 | + "metaDesc": 203, | |
| 420 | + "h1Count": 1, | |
| 421 | + "skipped": [], | |
| 422 | + "unnamed": [], | |
| 423 | + "ariaCurrent": 1, | |
| 424 | + "footerOk": true, | |
| 425 | + "contrast": [], | |
| 426 | + "contrastChecked": 0, | |
| 427 | + "height": 10760, | |
| 428 | + "title": "Google The Dalles, Oregon data center (The Dalles, US) · DataCenterIndex", | |
| 429 | + "bottom": { | |
| 430 | + "navVisible": true, | |
| 431 | + "covered": false, | |
| 432 | + "gap": 1106 | |
| 433 | + }, | |
| 434 | + "errors": [], | |
| 435 | + "badResponses": [] | |
| 436 | + }, | |
| 437 | + { | |
| 438 | + "name": "fac-wikidata", | |
| 439 | + "path": "/datacenters/foulum-data-center", | |
| 440 | + "w": 390, | |
| 441 | + "theme": "light", | |
| 442 | + "status": 200, | |
| 443 | + "ttfb": 13, | |
| 444 | + "overflow": null, | |
| 445 | + "clipped": [ | |
| 446 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 447 | + "span.sr-only \"Denmark\" 58>1", | |
| 448 | + "span.sr-only \"Denmark\" 56>1", | |
| 449 | + "span.sr-only \"Denmark\" 60>1" | |
| 450 | + ], | |
| 451 | + "small": [ | |
| 452 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 453 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 454 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 455 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 456 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 457 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 458 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 459 | + "a.hover:text-ink.hover:underline 51×17 \"Denmark\"", | |
| 460 | + "a.normal-case.tracking-normal.text-[12.5px] 35×18 \"Apple\"", | |
| 461 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (2 sources)\"", | |
| 462 | + "a.hover:text-accent.hover:underline 58×20 \"Denmark\"", | |
| 463 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 464 | + ], | |
| 465 | + "smallCount": 57, | |
| 466 | + "h1Covered": false, | |
| 467 | + "h1Top": 119, | |
| 468 | + "headerBottom": 49, | |
| 469 | + "lang": "en", | |
| 470 | + "metaDesc": 158, | |
| 471 | + "h1Count": 1, | |
| 472 | + "skipped": [], | |
| 473 | + "unnamed": [], | |
| 474 | + "ariaCurrent": 1, | |
| 475 | + "footerOk": true, | |
| 476 | + "contrast": [], | |
| 477 | + "contrastChecked": 0, | |
| 478 | + "height": 6042, | |
| 479 | + "title": "Foulum Data Center (Viborg Municipality, DK) · DataCenterIndex", | |
| 480 | + "bottom": { | |
| 481 | + "navVisible": true, | |
| 482 | + "covered": false, | |
| 483 | + "gap": 1105 | |
| 484 | + }, | |
| 485 | + "errors": [], | |
| 486 | + "badResponses": [] | |
| 487 | + }, | |
| 488 | + { | |
| 489 | + "name": "operators", | |
| 490 | + "path": "/operators", | |
| 491 | + "w": 390, | |
| 492 | + "theme": "light", | |
| 493 | + "status": 200, | |
| 494 | + "ttfb": 5, | |
| 495 | + "overflow": null, | |
| 496 | + "clipped": [ | |
| 497 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 498 | + "span.sr-only \"Search operators\" 114>1" | |
| 499 | + ], | |
| 500 | + "small": [ | |
| 501 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 502 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 503 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 504 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 505 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 506 | + "input.h-9.min-w-0.rounded-sm 218×36 \"\"", | |
| 507 | + "select.h-9.min-w-0.rounded-sm 140×36 \"Kind\"", | |
| 508 | + "select.h-9.min-w-0.rounded-sm 160×36 \"Headquarters country\"", | |
| 509 | + "button.inline-flex.items-center.justify-center 59×36 \"Filter\"", | |
| 510 | + "a.font-medium.text-ink.hover:text-accent 60×18 \"DataBank\"", | |
| 511 | + "a.font-medium.text-ink.hover:text-accent 81×18 \"EdgeConneX\"", | |
| 512 | + "a.font-medium.text-ink.hover:text-accent 62×18 \"CyrusOne\"" | |
| 513 | + ], | |
| 514 | + "smallCount": 75, | |
| 515 | + "h1Covered": false, | |
| 516 | + "h1Top": 90, | |
| 517 | + "headerBottom": 49, | |
| 518 | + "lang": "en", | |
| 519 | + "metaDesc": 139, | |
| 520 | + "h1Count": 1, | |
| 521 | + "skipped": [], | |
| 522 | + "unnamed": [], | |
| 523 | + "ariaCurrent": 2, | |
| 524 | + "footerOk": true, | |
| 525 | + "contrast": [], | |
| 526 | + "contrastChecked": 0, | |
| 527 | + "height": 4954, | |
| 528 | + "title": "Operators · DataCenterIndex", | |
| 529 | + "bottom": { | |
| 530 | + "navVisible": true, | |
| 531 | + "covered": false, | |
| 532 | + "gap": 810 | |
| 533 | + }, | |
| 534 | + "inter": { | |
| 535 | + "paginationPage2": true, | |
| 536 | + "page2Rows": 0 | |
| 537 | + }, | |
| 538 | + "errors": [], | |
| 539 | + "badResponses": [] | |
| 540 | + }, | |
| 541 | + { | |
| 542 | + "name": "operator-equinix", | |
| 543 | + "path": "/operators/equinix", | |
| 544 | + "w": 390, | |
| 545 | + "theme": "light", | |
| 546 | + "status": 200, | |
| 547 | + "ttfb": 9, | |
| 548 | + "overflow": null, | |
| 549 | + "clipped": [ | |
| 550 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 551 | + ], | |
| 552 | + "small": [ | |
| 553 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 554 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 555 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 556 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 557 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 558 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 559 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 560 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 561 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 562 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"", | |
| 563 | + "a.inline-flex.min-h-7.items-center 60×28 \"Countries\"", | |
| 564 | + "a.inline-flex.min-h-7.items-center 85×28 \"Cloud regions\"" | |
| 565 | + ], | |
| 566 | + "smallCount": 20, | |
| 567 | + "h1Covered": false, | |
| 568 | + "h1Top": null, | |
| 569 | + "headerBottom": 49, | |
| 570 | + "lang": "en", | |
| 571 | + "metaDesc": 159, | |
| 572 | + "h1Count": 0, | |
| 573 | + "skipped": [], | |
| 574 | + "unnamed": [], | |
| 575 | + "ariaCurrent": 1, | |
| 576 | + "footerOk": true, | |
| 577 | + "contrast": [], | |
| 578 | + "contrastChecked": 0, | |
| 579 | + "height": 1467, | |
| 580 | + "title": "Operator · DataCenterIndex", | |
| 581 | + "bottom": { | |
| 582 | + "navVisible": true, | |
| 583 | + "covered": false, | |
| 584 | + "gap": 809 | |
| 585 | + }, | |
| 586 | + "errors": [ | |
| 587 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 588 | + ], | |
| 589 | + "badResponses": [ | |
| 590 | + "404 fetch /api/v1/docs?_rsc=NlEMKmKlhE08_1aP" | |
| 591 | + ] | |
| 592 | + }, | |
| 593 | + { | |
| 594 | + "name": "countries", | |
| 595 | + "path": "/countries", | |
| 596 | + "w": 390, | |
| 597 | + "theme": "light", | |
| 598 | + "status": 200, | |
| 599 | + "ttfb": 24, | |
| 600 | + "overflow": null, | |
| 601 | + "clipped": [ | |
| 602 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 603 | + "span.sr-only \"United States\" 83>1", | |
| 604 | + "span.sr-only \"Brazil\" 35>1", | |
| 605 | + "span.sr-only \"Argentina\" 60>1", | |
| 606 | + "span.sr-only \"Canada\" 48>1", | |
| 607 | + "span.sr-only \"Mexico\" 45>1", | |
| 608 | + "span.sr-only \"Chile\" 32>1", | |
| 609 | + "span.sr-only \"Venezuela\" 65>1", | |
| 610 | + "span.sr-only \"Peru\" 29>1" | |
| 611 | + ], | |
| 612 | + "small": [ | |
| 613 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 614 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 615 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 616 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 617 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 618 | + "a.inline-flex.h-7.shrink-0 78×28 \"All regions\"", | |
| 619 | + "a.inline-flex.h-7.shrink-0 90×28 \"Americas18\"", | |
| 620 | + "a.inline-flex.h-7.shrink-0 77×28 \"Europe38\"", | |
| 621 | + "a.inline-flex.h-7.shrink-0 61×28 \"Asia34\"", | |
| 622 | + "a.inline-flex.h-7.shrink-0 77×28 \"Oceania3\"", | |
| 623 | + "a.inline-flex.h-7.shrink-0 71×28 \"Africa12\"", | |
| 624 | + "a.font-medium.text-ink.hover:text-accent 109×18 \"🇺🇸United StatesUnited \"" | |
| 625 | + ], | |
| 626 | + "smallCount": 71, | |
| 627 | + "h1Covered": false, | |
| 628 | + "h1Top": 90, | |
| 629 | + "headerBottom": 49, | |
| 630 | + "lang": "en", | |
| 631 | + "metaDesc": 117, | |
| 632 | + "h1Count": 1, | |
| 633 | + "skipped": [], | |
| 634 | + "unnamed": [], | |
| 635 | + "ariaCurrent": 2, | |
| 636 | + "footerOk": true, | |
| 637 | + "contrast": [], | |
| 638 | + "contrastChecked": 0, | |
| 639 | + "height": 5206, | |
| 640 | + "title": "Countries · DataCenterIndex", | |
| 641 | + "bottom": { | |
| 642 | + "navVisible": true, | |
| 643 | + "covered": false, | |
| 644 | + "gap": 810 | |
| 645 | + }, | |
| 646 | + "errors": [ | |
| 647 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 648 | + ], | |
| 649 | + "badResponses": [ | |
| 650 | + "404 fetch /api/v1/docs?_rsc=rWPI48G8kG2iJ9dR" | |
| 651 | + ] | |
| 652 | + }, | |
| 653 | + { | |
| 654 | + "name": "country-us", | |
| 655 | + "path": "/countries/united-states", | |
| 656 | + "w": 390, | |
| 657 | + "theme": "light", | |
| 658 | + "status": 200, | |
| 659 | + "ttfb": 30, | |
| 660 | + "overflow": null, | |
| 661 | + "clipped": [ | |
| 662 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 663 | + "span.sr-only \"United States\" 149>1", | |
| 664 | + "span.sr-only \"United States\" 76>1", | |
| 665 | + "span.sr-only \"United States\" 76>1", | |
| 666 | + "span.sr-only \"United States\" 76>1", | |
| 667 | + "span.sr-only \"United States\" 76>1", | |
| 668 | + "span.sr-only \"United States\" 76>1", | |
| 669 | + "span.sr-only \"United States\" 76>1", | |
| 670 | + "span.sr-only \"United States\" 76>1" | |
| 671 | + ], | |
| 672 | + "small": [ | |
| 673 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 674 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 675 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 676 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 677 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 678 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 679 | + "a.hover:text-ink.hover:underline 55×17 \"Countries\"", | |
| 680 | + "a.inline-flex.h-7.shrink-0 211×28 \"#1/46Countries by cloud \"", | |
| 681 | + "a.inline-flex.h-7.shrink-0 214×28 \"#1/103Countries by facil\"", | |
| 682 | + "a.inline-flex.h-7.shrink-0 233×28 \"#24/52Facilities per mil\"", | |
| 683 | + "a.inline-flex.h-7.shrink-0 338×28 \"#14/14Countries by facil\"", | |
| 684 | + "a.inline-flex.h-7.shrink-0 247×28 \"#1/28Countries by hypers\"" | |
| 685 | + ], | |
| 686 | + "smallCount": 106, | |
| 687 | + "h1Covered": false, | |
| 688 | + "h1Top": 120, | |
| 689 | + "headerBottom": 49, | |
| 690 | + "lang": "en", | |
| 691 | + "metaDesc": 258, | |
| 692 | + "h1Count": 1, | |
| 693 | + "skipped": [], | |
| 694 | + "unnamed": [], | |
| 695 | + "ariaCurrent": 2, | |
| 696 | + "footerOk": true, | |
| 697 | + "contrast": [], | |
| 698 | + "contrastChecked": 0, | |
| 699 | + "height": 9191, | |
| 700 | + "title": "Data Centers in United States · DataCenterIndex", | |
| 701 | + "bottom": { | |
| 702 | + "navVisible": true, | |
| 703 | + "covered": false, | |
| 704 | + "gap": 1073 | |
| 705 | + }, | |
| 706 | + "inter": { | |
| 707 | + "showAll": { | |
| 708 | + "before": 10, | |
| 709 | + "after": 10 | |
| 710 | + } | |
| 711 | + }, | |
| 712 | + "errors": [ | |
| 713 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 714 | + ], | |
| 715 | + "badResponses": [ | |
| 716 | + "404 fetch /api/v1/docs?_rsc=mmUTfifLZ71cpSIq" | |
| 717 | + ] | |
| 718 | + }, | |
| 719 | + { | |
| 720 | + "name": "country-ca", | |
| 721 | + "path": "/countries/canada", | |
| 722 | + "w": 390, | |
| 723 | + "theme": "light", | |
| 724 | + "status": 200, | |
| 725 | + "ttfb": 6, | |
| 726 | + "overflow": null, | |
| 727 | + "clipped": [ | |
| 728 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 729 | + "span.sr-only \"Canada\" 86>1", | |
| 730 | + "span.sr-only \"Canada\" 43>1", | |
| 731 | + "span.sr-only \"Canada\" 43>1", | |
| 732 | + "span.sr-only \"Canada\" 51>1", | |
| 733 | + "span.sr-only \"Canada\" 51>1", | |
| 734 | + "span.sr-only \"Canada\" 51>1", | |
| 735 | + "span.sr-only \"Canada\" 51>1", | |
| 736 | + "span.sr-only \"Canada\" 51>1" | |
| 737 | + ], | |
| 738 | + "small": [ | |
| 739 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 740 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 741 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 742 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 743 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 744 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 745 | + "a.hover:text-ink.hover:underline 55×17 \"Countries\"", | |
| 746 | + "a.inline-flex.h-7.shrink-0 211×28 \"#7/46Countries by cloud \"", | |
| 747 | + "a.inline-flex.h-7.shrink-0 221×28 \"#22/103Countries by faci\"", | |
| 748 | + "a.inline-flex.h-7.shrink-0 233×28 \"#33/52Facilities per mil\"", | |
| 749 | + "a.inline-flex.h-7.shrink-0 254×28 \"#12/28Countries by hyper\"", | |
| 750 | + "a.block 366×36 \"Unknown15\"" | |
| 751 | + ], | |
| 752 | + "smallCount": 80, | |
| 753 | + "h1Covered": false, | |
| 754 | + "h1Top": 120, | |
| 755 | + "headerBottom": 49, | |
| 756 | + "lang": "en", | |
| 757 | + "metaDesc": 186, | |
| 758 | + "h1Count": 1, | |
| 759 | + "skipped": [], | |
| 760 | + "unnamed": [], | |
| 761 | + "ariaCurrent": 2, | |
| 762 | + "footerOk": true, | |
| 763 | + "contrast": [], | |
| 764 | + "contrastChecked": 0, | |
| 765 | + "height": 7567, | |
| 766 | + "title": "Data Centers in Canada · DataCenterIndex", | |
| 767 | + "bottom": { | |
| 768 | + "navVisible": true, | |
| 769 | + "covered": false, | |
| 770 | + "gap": 1073 | |
| 771 | + }, | |
| 772 | + "errors": [ | |
| 773 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 774 | + ], | |
| 775 | + "badResponses": [ | |
| 776 | + "404 fetch /api/v1/docs?_rsc=uoChELS4xfChicuP" | |
| 777 | + ] | |
| 778 | + }, | |
| 779 | + { | |
| 780 | + "name": "markets", | |
| 781 | + "path": "/datacenters/markets", | |
| 782 | + "w": 390, | |
| 783 | + "theme": "light", | |
| 784 | + "status": 200, | |
| 785 | + "ttfb": 4, | |
| 786 | + "overflow": null, | |
| 787 | + "clipped": [ | |
| 788 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 789 | + ], | |
| 790 | + "small": [ | |
| 791 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 792 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 793 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 794 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 795 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 796 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 797 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 798 | + "a.inline-flex.items-center.justify-center 86×32 \"Open map\"", | |
| 799 | + "input.h-9.min-w-0.flex-1 366×18 \"Search markets\"", | |
| 800 | + "select.h-9.min-w-0.flex-1 299×36 \"Country\"", | |
| 801 | + "button.inline-flex.items-center.justify-center 59×36 \"Filter\"", | |
| 802 | + "a.inline-flex.size-9.items-center 36×36 \"Next page\"" | |
| 803 | + ], | |
| 804 | + "smallCount": 27, | |
| 805 | + "h1Covered": false, | |
| 806 | + "h1Top": 120, | |
| 807 | + "headerBottom": 49, | |
| 808 | + "lang": "en", | |
| 809 | + "metaDesc": 125, | |
| 810 | + "h1Count": 1, | |
| 811 | + "skipped": [], | |
| 812 | + "unnamed": [], | |
| 813 | + "ariaCurrent": 2, | |
| 814 | + "footerOk": true, | |
| 815 | + "contrast": [], | |
| 816 | + "contrastChecked": 0, | |
| 817 | + "height": 5208, | |
| 818 | + "title": "Data center markets · DataCenterIndex", | |
| 819 | + "bottom": { | |
| 820 | + "navVisible": true, | |
| 821 | + "covered": false, | |
| 822 | + "gap": 810 | |
| 823 | + }, | |
| 824 | + "errors": [ | |
| 825 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 826 | + ], | |
| 827 | + "badResponses": [ | |
| 828 | + "404 fetch /api/v1/docs?_rsc=pvP-qw1_z45AIAvk" | |
| 829 | + ] | |
| 830 | + }, | |
| 831 | + { | |
| 832 | + "name": "market-nova", | |
| 833 | + "path": "/datacenters/markets/northern-virginia", | |
| 834 | + "w": 390, | |
| 835 | + "theme": "light", | |
| 836 | + "status": 200, | |
| 837 | + "ttfb": 277, | |
| 838 | + "overflow": null, | |
| 839 | + "clipped": [ | |
| 840 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 841 | + "span.sr-only \"United States\" 82>1", | |
| 842 | + "span.sr-only \"United States\" 76>1", | |
| 843 | + "span.sr-only \"United States\" 89>1", | |
| 844 | + "span.sr-only \"United States\" 89>1", | |
| 845 | + "span.sr-only \"United States\" 89>1", | |
| 846 | + "span.sr-only \"United States\" 89>1", | |
| 847 | + "span.sr-only \"United States\" 89>1", | |
| 848 | + "span.sr-only \"United States\" 89>1" | |
| 849 | + ], | |
| 850 | + "small": [ | |
| 851 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 852 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 853 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 854 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 855 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 856 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 857 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 858 | + "a.hover:text-ink.hover:underline 46×17 \"Markets\"", | |
| 859 | + "a.text-ink.hover:text-accent.hover:underline 82×19 \"United States\"", | |
| 860 | + "a.inline-flex.h-7.shrink-0 219×28 \"#72/ 152Metros by facili\"", | |
| 861 | + "a.inline-flex.h-7.shrink-0 230×28 \"#44/ 129Metros by operat\"", | |
| 862 | + "a.inline-flex.h-10.items-center 38×40 \"Map\"" | |
| 863 | + ], | |
| 864 | + "smallCount": 97, | |
| 865 | + "h1Covered": false, | |
| 866 | + "h1Top": 120, | |
| 867 | + "headerBottom": 49, | |
| 868 | + "lang": "en", | |
| 869 | + "metaDesc": 250, | |
| 870 | + "h1Count": 1, | |
| 871 | + "skipped": [], | |
| 872 | + "unnamed": [], | |
| 873 | + "ariaCurrent": 2, | |
| 874 | + "footerOk": true, | |
| 875 | + "contrast": [], | |
| 876 | + "contrastChecked": 0, | |
| 877 | + "height": 8563, | |
| 878 | + "title": "Data Centers in Northern Virginia · DataCenterIndex", | |
| 879 | + "bottom": { | |
| 880 | + "navVisible": true, | |
| 881 | + "covered": false, | |
| 882 | + "gap": 1074 | |
| 883 | + }, | |
| 884 | + "errors": [ | |
| 885 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 886 | + ], | |
| 887 | + "badResponses": [ | |
| 888 | + "404 fetch /api/v1/docs?_rsc=kksxSvqB6K9jsp-1" | |
| 889 | + ] | |
| 890 | + }, | |
| 891 | + { | |
| 892 | + "name": "projects", | |
| 893 | + "path": "/projects", | |
| 894 | + "w": 390, | |
| 895 | + "theme": "light", | |
| 896 | + "status": 200, | |
| 897 | + "ttfb": 4, | |
| 898 | + "overflow": null, | |
| 899 | + "clipped": [ | |
| 900 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 901 | + "span.sr-only \"United States\" 76>1", | |
| 902 | + "span.sr-only \"United States\" 76>1", | |
| 903 | + "span.sr-only \"Australia\" 49>1", | |
| 904 | + "span.sr-only \"Canada\" 43>1", | |
| 905 | + "span.sr-only \"United States\" 76>1", | |
| 906 | + "span.sr-only \"Norway\" 44>1" | |
| 907 | + ], | |
| 908 | + "small": [ | |
| 909 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 910 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 911 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 912 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 913 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 914 | + "a.block 366×36 \"1🇺🇸United States≈ 125 \"", | |
| 915 | + "a.block 366×36 \"2🇯🇵Japan≈ 12.7 GW16\"", | |
| 916 | + "a.block 366×36 \"3🇦🇺Australia≈ 26 GW9\"", | |
| 917 | + "a.block 366×36 \"4🇲🇾Malaysia≈ 15.4 GW8\"", | |
| 918 | + "a.block 366×36 \"5🇩🇪Germany≈ 21.1 GW4\"", | |
| 919 | + "a.block 366×36 \"6🇸🇬Singapore≈ 314 MW4\"", | |
| 920 | + "a.block 366×36 \"7🇫🇮Finland≈ 1.79 GW3\"" | |
| 921 | + ], | |
| 922 | + "smallCount": 30, | |
| 923 | + "h1Covered": false, | |
| 924 | + "h1Top": 90, | |
| 925 | + "headerBottom": 49, | |
| 926 | + "lang": "en", | |
| 927 | + "metaDesc": 130, | |
| 928 | + "h1Count": 1, | |
| 929 | + "skipped": [], | |
| 930 | + "unnamed": [], | |
| 931 | + "ariaCurrent": 3, | |
| 932 | + "footerOk": true, | |
| 933 | + "contrast": [], | |
| 934 | + "contrastChecked": 0, | |
| 935 | + "height": 7315, | |
| 936 | + "title": "Data center projects · DataCenterIndex", | |
| 937 | + "bottom": { | |
| 938 | + "navVisible": true, | |
| 939 | + "covered": false, | |
| 940 | + "gap": 810 | |
| 941 | + }, | |
| 942 | + "inter": { | |
| 943 | + "filterSheetOpens": true, | |
| 944 | + "filterSheetCloses": true, | |
| 945 | + "paginationPage2": true, | |
| 946 | + "page2Rows": 50 | |
| 947 | + }, | |
| 948 | + "errors": [ | |
| 949 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 950 | + ], | |
| 951 | + "badResponses": [ | |
| 952 | + "404 fetch /api/v1/docs?_rsc=rVOxZyJZQf-ncF1B" | |
| 953 | + ] | |
| 954 | + }, | |
| 955 | + { | |
| 956 | + "name": "project", | |
| 957 | + "path": "/projects/sydney-data-center-project", | |
| 958 | + "w": 390, | |
| 959 | + "theme": "light", | |
| 960 | + "status": 200, | |
| 961 | + "ttfb": 11, | |
| 962 | + "overflow": null, | |
| 963 | + "clipped": [ | |
| 964 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 965 | + "span.sr-only \"Australia\" 54>1", | |
| 966 | + "span.sr-only \"Australia\" 54>1", | |
| 967 | + "span.sr-only \"Australia\" 58>1" | |
| 968 | + ], | |
| 969 | + "small": [ | |
| 970 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 971 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 972 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 973 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 974 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 975 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 976 | + "a.hover:text-ink.hover:underline 46×17 \"Projects\"", | |
| 977 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 978 | + "a.hover:text-ink.hover:underline 46×19 \"Sydney\"", | |
| 979 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 980 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 981 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 982 | + ], | |
| 983 | + "smallCount": 42, | |
| 984 | + "h1Covered": false, | |
| 985 | + "h1Top": 120, | |
| 986 | + "headerBottom": 49, | |
| 987 | + "lang": "en", | |
| 988 | + "metaDesc": 201, | |
| 989 | + "h1Count": 1, | |
| 990 | + "skipped": [], | |
| 991 | + "unnamed": [], | |
| 992 | + "ariaCurrent": 2, | |
| 993 | + "footerOk": true, | |
| 994 | + "contrast": [], | |
| 995 | + "contrastChecked": 0, | |
| 996 | + "height": 4322, | |
| 997 | + "title": "Sydney data center project · DataCenterIndex", | |
| 998 | + "bottom": { | |
| 999 | + "navVisible": true, | |
| 1000 | + "covered": false, | |
| 1001 | + "gap": 1073 | |
| 1002 | + }, | |
| 1003 | + "errors": [], | |
| 1004 | + "badResponses": [] | |
| 1005 | + }, | |
| 1006 | + { | |
| 1007 | + "name": "rankings", | |
| 1008 | + "path": "/rankings", | |
| 1009 | + "w": 390, | |
| 1010 | + "theme": "light", | |
| 1011 | + "status": 200, | |
| 1012 | + "ttfb": 3, | |
| 1013 | + "overflow": null, | |
| 1014 | + "clipped": [ | |
| 1015 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1016 | + ], | |
| 1017 | + "small": [ | |
| 1018 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1019 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1020 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1021 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1022 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1023 | + "a.text-[14.5px].font-semibold.text-ink 170×21 \"Countries by AI faciliti\"", | |
| 1024 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 1025 | + "a.text-[14.5px].font-semibold.text-ink 188×21 \"Countries by cloud regio\"", | |
| 1026 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 1027 | + "a.text-[14.5px].font-semibold.text-ink 336×21 \"Countries by facilities \"", | |
| 1028 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 1029 | + "a.text-[14.5px].font-semibold.text-ink 184×21 \"Countries by facility co\"" | |
| 1030 | + ], | |
| 1031 | + "smallCount": 60, | |
| 1032 | + "h1Covered": false, | |
| 1033 | + "h1Top": 90, | |
| 1034 | + "headerBottom": 49, | |
| 1035 | + "lang": "en", | |
| 1036 | + "metaDesc": 190, | |
| 1037 | + "h1Count": 1, | |
| 1038 | + "skipped": [], | |
| 1039 | + "unnamed": [], | |
| 1040 | + "ariaCurrent": 2, | |
| 1041 | + "footerOk": true, | |
| 1042 | + "contrast": [], | |
| 1043 | + "contrastChecked": 0, | |
| 1044 | + "height": 4882, | |
| 1045 | + "title": "Rankings · DataCenterIndex", | |
| 1046 | + "bottom": { | |
| 1047 | + "navVisible": true, | |
| 1048 | + "covered": false, | |
| 1049 | + "gap": 809 | |
| 1050 | + }, | |
| 1051 | + "errors": [], | |
| 1052 | + "badResponses": [] | |
| 1053 | + }, | |
| 1054 | + { | |
| 1055 | + "name": "ranking", | |
| 1056 | + "path": "/rankings/countries_facilities", | |
| 1057 | + "w": 390, | |
| 1058 | + "theme": "light", | |
| 1059 | + "status": 200, | |
| 1060 | + "ttfb": 23, | |
| 1061 | + "overflow": null, | |
| 1062 | + "clipped": [ | |
| 1063 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1064 | + "span.sr-only \"United States\" 83>1", | |
| 1065 | + "span.sr-only \"France\" 43>1", | |
| 1066 | + "span.sr-only \"United Kingdom\" 100>1", | |
| 1067 | + "span.sr-only \"Germany\" 57>1", | |
| 1068 | + "span.sr-only \"Australia\" 55>1", | |
| 1069 | + "span.sr-only \"Netherlands\" 76>1", | |
| 1070 | + "span.sr-only \"India\" 30>1", | |
| 1071 | + "span.sr-only \"Singapore\" 63>1" | |
| 1072 | + ], | |
| 1073 | + "small": [ | |
| 1074 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1075 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1076 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1077 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1078 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1079 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 1080 | + "a.hover:text-ink.hover:underline 52×17 \"Rankings\"", | |
| 1081 | + "a.font-medium.text-ink.hover:text-accent 83×18 \"United States\"", | |
| 1082 | + "a.font-medium.text-ink.hover:text-accent 43×18 \"France\"", | |
| 1083 | + "a.font-medium.text-ink.hover:text-accent 100×18 \"United Kingdom\"", | |
| 1084 | + "a.font-medium.text-ink.hover:text-accent 57×18 \"Germany\"", | |
| 1085 | + "a.font-medium.text-ink.hover:text-accent 55×18 \"Australia\"" | |
| 1086 | + ], | |
| 1087 | + "smallCount": 32, | |
| 1088 | + "h1Covered": false, | |
| 1089 | + "h1Top": 120, | |
| 1090 | + "headerBottom": 49, | |
| 1091 | + "lang": "en", | |
| 1092 | + "metaDesc": 161, | |
| 1093 | + "h1Count": 1, | |
| 1094 | + "skipped": [ | |
| 1095 | + "h1→h3" | |
| 1096 | + ], | |
| 1097 | + "unnamed": [], | |
| 1098 | + "ariaCurrent": 2, | |
| 1099 | + "footerOk": true, | |
| 1100 | + "contrast": [], | |
| 1101 | + "contrastChecked": 0, | |
| 1102 | + "height": 2378, | |
| 1103 | + "title": "Countries by facility count · DataCenterIndex", | |
| 1104 | + "bottom": { | |
| 1105 | + "navVisible": true, | |
| 1106 | + "covered": false, | |
| 1107 | + "gap": 809 | |
| 1108 | + }, | |
| 1109 | + "errors": [ | |
| 1110 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1111 | + ], | |
| 1112 | + "badResponses": [ | |
| 1113 | + "404 fetch /api/v1/docs?_rsc=8sIz69C0ujSVTYSL" | |
| 1114 | + ] | |
| 1115 | + }, | |
| 1116 | + { | |
| 1117 | + "name": "cloud-regions", | |
| 1118 | + "path": "/cloud-regions", | |
| 1119 | + "w": 390, | |
| 1120 | + "theme": "light", | |
| 1121 | + "status": 200, | |
| 1122 | + "ttfb": 4, | |
| 1123 | + "overflow": null, | |
| 1124 | + "clipped": [ | |
| 1125 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1126 | + "label.sr-only \"Provider\" 55>1", | |
| 1127 | + "label.sr-only \"Country\" 53>1", | |
| 1128 | + "label.sr-only \"Status\" 42>1" | |
| 1129 | + ], | |
| 1130 | + "small": [ | |
| 1131 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1132 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1133 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1134 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1135 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1136 | + "a.inline-flex.items-center.justify-center 106×32 \"Show on map\"", | |
| 1137 | + "select#cr-provider.h-9.rounded-sm.border 179×36 \"All providersAmazon Web \"", | |
| 1138 | + "select#cr-country.h-9.rounded-sm.border 175×36 \"All countriesArgentinaAu\"", | |
| 1139 | + "select#cr-status.h-9.rounded-sm.border 109×36 \"Any statusOperationalAnn\"", | |
| 1140 | + "button.inline-flex.items-center.justify-center 58×32 \"Apply\"", | |
| 1141 | + "a.ml-auto.text-[12.5px].text-ink-3 106×18 \"Flat sortable table\"", | |
| 1142 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"" | |
| 1143 | + ], | |
| 1144 | + "smallCount": 26, | |
| 1145 | + "h1Covered": false, | |
| 1146 | + "h1Top": 90, | |
| 1147 | + "headerBottom": 49, | |
| 1148 | + "lang": "en", | |
| 1149 | + "metaDesc": 155, | |
| 1150 | + "h1Count": 1, | |
| 1151 | + "skipped": [], | |
| 1152 | + "unnamed": [], | |
| 1153 | + "ariaCurrent": 0, | |
| 1154 | + "footerOk": true, | |
| 1155 | + "contrast": [], | |
| 1156 | + "contrastChecked": 0, | |
| 1157 | + "height": 8052, | |
| 1158 | + "title": "Cloud regions · DataCenterIndex", | |
| 1159 | + "bottom": { | |
| 1160 | + "navVisible": true, | |
| 1161 | + "covered": false, | |
| 1162 | + "gap": 810 | |
| 1163 | + }, | |
| 1164 | + "errors": [], | |
| 1165 | + "badResponses": [] | |
| 1166 | + }, | |
| 1167 | + { | |
| 1168 | + "name": "cloud-region", | |
| 1169 | + "path": "/cloud-regions/amazon-web-services-ca-central-1", | |
| 1170 | + "w": 390, | |
| 1171 | + "theme": "light", | |
| 1172 | + "status": 200, | |
| 1173 | + "ttfb": 7, | |
| 1174 | + "overflow": null, | |
| 1175 | + "clipped": [ | |
| 1176 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1177 | + "span.sr-only \"Canada\" 47>1" | |
| 1178 | + ], | |
| 1179 | + "small": [ | |
| 1180 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1181 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1182 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1183 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1184 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1185 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 1186 | + "a.hover:text-ink.hover:underline 79×17 \"Cloud regions\"", | |
| 1187 | + "a.hover:text-ink.hover:underline 128×17 \"Amazon Web Services\"", | |
| 1188 | + "a.hover:text-ink.hover:underline 47×19 \"Canada\"", | |
| 1189 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1190 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 1191 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 1192 | + ], | |
| 1193 | + "smallCount": 24, | |
| 1194 | + "h1Covered": false, | |
| 1195 | + "h1Top": 120, | |
| 1196 | + "headerBottom": 49, | |
| 1197 | + "lang": "en", | |
| 1198 | + "metaDesc": 165, | |
| 1199 | + "h1Count": 1, | |
| 1200 | + "skipped": [], | |
| 1201 | + "unnamed": [], | |
| 1202 | + "ariaCurrent": 0, | |
| 1203 | + "footerOk": true, | |
| 1204 | + "contrast": [], | |
| 1205 | + "contrastChecked": 0, | |
| 1206 | + "height": 2891, | |
| 1207 | + "title": "Amazon Web Services ca-central-1 — Canada (Central) · DataCenterIndex", | |
| 1208 | + "bottom": { | |
| 1209 | + "navVisible": true, | |
| 1210 | + "covered": false, | |
| 1211 | + "gap": 1073 | |
| 1212 | + }, | |
| 1213 | + "errors": [ | |
| 1214 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1215 | + ], | |
| 1216 | + "badResponses": [ | |
| 1217 | + "404 fetch /api/v1/docs?_rsc=5uclAh_QepNQCyIY" | |
| 1218 | + ] | |
| 1219 | + }, | |
| 1220 | + { | |
| 1221 | + "name": "ixps", | |
| 1222 | + "path": "/ixps", | |
| 1223 | + "w": 390, | |
| 1224 | + "theme": "light", | |
| 1225 | + "status": 200, | |
| 1226 | + "ttfb": 4, | |
| 1227 | + "overflow": null, | |
| 1228 | + "clipped": [ | |
| 1229 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1230 | + "label.sr-only \"Search\" 46>1", | |
| 1231 | + "label.sr-only \"Country\" 53>1" | |
| 1232 | + ], | |
| 1233 | + "small": [ | |
| 1234 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1235 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1236 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1237 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1238 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1239 | + "a.inline-flex.items-center.justify-center 101×32 \"Data centers\"", | |
| 1240 | + "input#ixp-q.h-9.rounded-sm.border 366×36 \"\"", | |
| 1241 | + "select#ixp-country.h-9.rounded-sm.border 234×36 \"All countriesAlbaniaArge\"", | |
| 1242 | + "button.inline-flex.items-center.justify-center 58×32 \"Apply\"", | |
| 1243 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1244 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 1245 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 1246 | + ], | |
| 1247 | + "smallCount": 24, | |
| 1248 | + "h1Covered": false, | |
| 1249 | + "h1Top": 90, | |
| 1250 | + "headerBottom": 49, | |
| 1251 | + "lang": "en", | |
| 1252 | + "metaDesc": 100, | |
| 1253 | + "h1Count": 1, | |
| 1254 | + "skipped": [], | |
| 1255 | + "unnamed": [], | |
| 1256 | + "ariaCurrent": 1, | |
| 1257 | + "footerOk": true, | |
| 1258 | + "contrast": [], | |
| 1259 | + "contrastChecked": 0, | |
| 1260 | + "height": 2057, | |
| 1261 | + "title": "Internet exchanges · DataCenterIndex", | |
| 1262 | + "bottom": { | |
| 1263 | + "navVisible": true, | |
| 1264 | + "covered": false, | |
| 1265 | + "gap": 810 | |
| 1266 | + }, | |
| 1267 | + "errors": [ | |
| 1268 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1269 | + ], | |
| 1270 | + "badResponses": [ | |
| 1271 | + "404 fetch /api/v1/docs?_rsc=LOh4kTrez0j5S-5D" | |
| 1272 | + ] | |
| 1273 | + }, | |
| 1274 | + { | |
| 1275 | + "name": "sources", | |
| 1276 | + "path": "/sources", | |
| 1277 | + "w": 390, | |
| 1278 | + "theme": "light", | |
| 1279 | + "status": 200, | |
| 1280 | + "ttfb": 4, | |
| 1281 | + "overflow": null, | |
| 1282 | + "clipped": [ | |
| 1283 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1284 | + ], | |
| 1285 | + "small": [ | |
| 1286 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1287 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1288 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1289 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1290 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1291 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 1292 | + "input.h-9.min-w-0.rounded-sm 157×36 \"\"", | |
| 1293 | + "select.h-9.rounded-sm.border 129×36 \"All kindsOperatorGovernm\"", | |
| 1294 | + "button.inline-flex.items-center.justify-center 64×36 \"Apply\"", | |
| 1295 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1296 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 1297 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 1298 | + ], | |
| 1299 | + "smallCount": 24, | |
| 1300 | + "h1Covered": false, | |
| 1301 | + "h1Top": 112, | |
| 1302 | + "headerBottom": 49, | |
| 1303 | + "lang": "en", | |
| 1304 | + "metaDesc": 162, | |
| 1305 | + "h1Count": 1, | |
| 1306 | + "skipped": [ | |
| 1307 | + "h1→h3" | |
| 1308 | + ], | |
| 1309 | + "unnamed": [], | |
| 1310 | + "ariaCurrent": 0, | |
| 1311 | + "footerOk": true, | |
| 1312 | + "contrast": [], | |
| 1313 | + "contrastChecked": 0, | |
| 1314 | + "height": 6001, | |
| 1315 | + "title": "Sources · DataCenterIndex", | |
| 1316 | + "bottom": { | |
| 1317 | + "navVisible": true, | |
| 1318 | + "covered": false, | |
| 1319 | + "gap": 810 | |
| 1320 | + }, | |
| 1321 | + "errors": [ | |
| 1322 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1323 | + ], | |
| 1324 | + "badResponses": [ | |
| 1325 | + "404 fetch /api/v1/docs?_rsc=9SQ0ZVjwLU4sIjhN" | |
| 1326 | + ] | |
| 1327 | + }, | |
| 1328 | + { | |
| 1329 | + "name": "methodology", | |
| 1330 | + "path": "/methodology", | |
| 1331 | + "w": 390, | |
| 1332 | + "theme": "light", | |
| 1333 | + "status": 200, | |
| 1334 | + "ttfb": 4, | |
| 1335 | + "overflow": null, | |
| 1336 | + "clipped": [ | |
| 1337 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1338 | + ], | |
| 1339 | + "small": [ | |
| 1340 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1341 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1342 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1343 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1344 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1345 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 1346 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1347 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 1348 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 1349 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 1350 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"", | |
| 1351 | + "a.inline-flex.min-h-7.items-center 60×28 \"Countries\"" | |
| 1352 | + ], | |
| 1353 | + "smallCount": 21, | |
| 1354 | + "h1Covered": false, | |
| 1355 | + "h1Top": 112, | |
| 1356 | + "headerBottom": 49, | |
| 1357 | + "lang": "en", | |
| 1358 | + "metaDesc": 201, | |
| 1359 | + "h1Count": 1, | |
| 1360 | + "skipped": [], | |
| 1361 | + "unnamed": [], | |
| 1362 | + "ariaCurrent": 0, | |
| 1363 | + "footerOk": true, | |
| 1364 | + "contrast": [], | |
| 1365 | + "contrastChecked": 0, | |
| 1366 | + "height": 12570, | |
| 1367 | + "title": "Methodology · DataCenterIndex", | |
| 1368 | + "bottom": { | |
| 1369 | + "navVisible": true, | |
| 1370 | + "covered": false, | |
| 1371 | + "gap": 809 | |
| 1372 | + }, | |
| 1373 | + "errors": [ | |
| 1374 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1375 | + ], | |
| 1376 | + "badResponses": [ | |
| 1377 | + "404 fetch /api/v1/docs?_rsc=HzWsK4yFUoZLFyoa" | |
| 1378 | + ] | |
| 1379 | + }, | |
| 1380 | + { | |
| 1381 | + "name": "about", | |
| 1382 | + "path": "/about", | |
| 1383 | + "w": 390, | |
| 1384 | + "theme": "light", | |
| 1385 | + "status": 200, | |
| 1386 | + "ttfb": 1, | |
| 1387 | + "overflow": null, | |
| 1388 | + "clipped": [ | |
| 1389 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1390 | + ], | |
| 1391 | + "small": [ | |
| 1392 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1393 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1394 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1395 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1396 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1397 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"PeeringDB\"", | |
| 1398 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"OpenStreetMap\"", | |
| 1399 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"Wikidata\"", | |
| 1400 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"World Bank\"", | |
| 1401 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"Operator and government \"", | |
| 1402 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1403 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"" | |
| 1404 | + ], | |
| 1405 | + "smallCount": 25, | |
| 1406 | + "h1Covered": false, | |
| 1407 | + "h1Top": 90, | |
| 1408 | + "headerBottom": 49, | |
| 1409 | + "lang": "en", | |
| 1410 | + "metaDesc": 85, | |
| 1411 | + "h1Count": 1, | |
| 1412 | + "skipped": [], | |
| 1413 | + "unnamed": [], | |
| 1414 | + "ariaCurrent": 0, | |
| 1415 | + "footerOk": true, | |
| 1416 | + "contrast": [], | |
| 1417 | + "contrastChecked": 0, | |
| 1418 | + "height": 3140, | |
| 1419 | + "title": "About · DataCenterIndex", | |
| 1420 | + "bottom": { | |
| 1421 | + "navVisible": true, | |
| 1422 | + "covered": false, | |
| 1423 | + "gap": 809 | |
| 1424 | + }, | |
| 1425 | + "errors": [ | |
| 1426 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1427 | + ], | |
| 1428 | + "badResponses": [ | |
| 1429 | + "404 fetch /api/v1/docs?_rsc=Sdev7PUlRw12F58X" | |
| 1430 | + ] | |
| 1431 | + }, | |
| 1432 | + { | |
| 1433 | + "name": "bot", | |
| 1434 | + "path": "/bot", | |
| 1435 | + "w": 390, | |
| 1436 | + "theme": "light", | |
| 1437 | + "status": 200, | |
| 1438 | + "ttfb": 3, | |
| 1439 | + "overflow": null, | |
| 1440 | + "clipped": [ | |
| 1441 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1442 | + ], | |
| 1443 | + "small": [ | |
| 1444 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1445 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1446 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1447 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1448 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1449 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 1450 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 1451 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 1452 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 1453 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 1454 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"", | |
| 1455 | + "a.inline-flex.min-h-7.items-center 60×28 \"Countries\"" | |
| 1456 | + ], | |
| 1457 | + "smallCount": 21, | |
| 1458 | + "h1Covered": false, | |
| 1459 | + "h1Top": 112, | |
| 1460 | + "headerBottom": 49, | |
| 1461 | + "lang": "en", | |
| 1462 | + "metaDesc": 179, | |
| 1463 | + "h1Count": 1, | |
| 1464 | + "skipped": [], | |
| 1465 | + "unnamed": [], | |
| 1466 | + "ariaCurrent": 0, | |
| 1467 | + "footerOk": true, | |
| 1468 | + "contrast": [], | |
| 1469 | + "contrastChecked": 0, | |
| 1470 | + "height": 4183, | |
| 1471 | + "title": "About our crawler · DataCenterIndex", | |
| 1472 | + "bottom": { | |
| 1473 | + "navVisible": true, | |
| 1474 | + "covered": false, | |
| 1475 | + "gap": 810 | |
| 1476 | + }, | |
| 1477 | + "errors": [], | |
| 1478 | + "badResponses": [] | |
| 1479 | + }, | |
| 1480 | + { | |
| 1481 | + "name": "404", | |
| 1482 | + "path": "/this-page-does-not-exist", | |
| 1483 | + "w": 390, | |
| 1484 | + "theme": "light", | |
| 1485 | + "status": 404, | |
| 1486 | + "ttfb": 1, | |
| 1487 | + "overflow": null, | |
| 1488 | + "clipped": [ | |
| 1489 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1490 | + ], | |
| 1491 | + "small": [ | |
| 1492 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 1493 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 1494 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 1495 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to dark theme\"", | |
| 1496 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 1497 | + "a.inline-flex.items-center.justify-center 134×36 \"Search the index\"", | |
| 1498 | + "a.inline-flex.items-center.justify-center 117×36 \"Open the map\"", | |
| 1499 | + "a.inline-flex.h-9.items-center 52×36 \"Home\"" | |
| 1500 | + ], | |
| 1501 | + "smallCount": 8, | |
| 1502 | + "h1Covered": false, | |
| 1503 | + "h1Top": 231, | |
| 1504 | + "headerBottom": 49, | |
| 1505 | + "lang": "en", | |
| 1506 | + "metaDesc": 159, | |
| 1507 | + "h1Count": 1, | |
| 1508 | + "skipped": [], | |
| 1509 | + "unnamed": [], | |
| 1510 | + "ariaCurrent": 0, | |
| 1511 | + "footerOk": false, | |
| 1512 | + "contrast": [], | |
| 1513 | + "contrastChecked": 0, | |
| 1514 | + "height": 844, | |
| 1515 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 1516 | + "bottom": { | |
| 1517 | + "navVisible": true, | |
| 1518 | + "covered": false | |
| 1519 | + }, | |
| 1520 | + "errors": [ | |
| 1521 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1522 | + ], | |
| 1523 | + "badResponses": [] | |
| 1524 | + }, | |
| 1525 | + { | |
| 1526 | + "name": "home", | |
| 1527 | + "path": "/", | |
| 1528 | + "w": 1280, | |
| 1529 | + "theme": "light", | |
| 1530 | + "status": 200, | |
| 1531 | + "ttfb": 3, | |
| 1532 | + "overflow": null, | |
| 1533 | + "clipped": [ | |
| 1534 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1535 | + "span.sr-only \"United States\" 89>1", | |
| 1536 | + "span.sr-only \"United States\" 89>1", | |
| 1537 | + "span.sr-only \"United States\" 89>1", | |
| 1538 | + "span.sr-only \"United States\" 76>1", | |
| 1539 | + "span.sr-only \"United States\" 76>1", | |
| 1540 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 1541 | + "span.sr-only \"France\" 42>1", | |
| 1542 | + "span.sr-only \"Singapore\" 62>1" | |
| 1543 | + ], | |
| 1544 | + "small": [], | |
| 1545 | + "smallCount": 0, | |
| 1546 | + "h1Covered": false, | |
| 1547 | + "h1Top": 132, | |
| 1548 | + "headerBottom": 57, | |
| 1549 | + "lang": "en", | |
| 1550 | + "metaDesc": 159, | |
| 1551 | + "h1Count": 1, | |
| 1552 | + "skipped": [], | |
| 1553 | + "unnamed": [], | |
| 1554 | + "ariaCurrent": 1, | |
| 1555 | + "footerOk": true, | |
| 1556 | + "contrast": [], | |
| 1557 | + "contrastChecked": 0, | |
| 1558 | + "height": 3750, | |
| 1559 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 1560 | + "error": "TimeoutError: locator.click: Timeout 30000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('button[aria-label*=\"theme\" i]').first()\u001b[22m\n\u001b[2m - locator resolved to <button type=\"button\" title=\"Sw", | |
| 1561 | + "errors": [], | |
| 1562 | + "badResponses": [] | |
| 1563 | + }, | |
| 1564 | + { | |
| 1565 | + "name": "map", | |
| 1566 | + "path": "/map", | |
| 1567 | + "w": 1280, | |
| 1568 | + "theme": "light", | |
| 1569 | + "status": 200, | |
| 1570 | + "ttfb": 1, | |
| 1571 | + "overflow": null, | |
| 1572 | + "clipped": [ | |
| 1573 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1574 | + "span.sr-only \"Minimum MW\" 91>1", | |
| 1575 | + "span.sr-only \"Maximum MW\" 95>1", | |
| 1576 | + "span.sr-only \"Opened from\" 87>1", | |
| 1577 | + "span.sr-only \"Opened to\" 69>1" | |
| 1578 | + ], | |
| 1579 | + "small": [], | |
| 1580 | + "smallCount": 0, | |
| 1581 | + "h1Covered": false, | |
| 1582 | + "h1Top": null, | |
| 1583 | + "headerBottom": 57, | |
| 1584 | + "lang": "en", | |
| 1585 | + "metaDesc": 127, | |
| 1586 | + "h1Count": 0, | |
| 1587 | + "skipped": [], | |
| 1588 | + "unnamed": [], | |
| 1589 | + "ariaCurrent": 2, | |
| 1590 | + "footerOk": false, | |
| 1591 | + "contrast": [], | |
| 1592 | + "contrastChecked": 0, | |
| 1593 | + "height": 901, | |
| 1594 | + "title": "Global data center map · DataCenterIndex", | |
| 1595 | + "map": { | |
| 1596 | + "controls": 3, | |
| 1597 | + "unreachable": 0, | |
| 1598 | + "canvas": true | |
| 1599 | + }, | |
| 1600 | + "inter": { | |
| 1601 | + "zoomVisible": true, | |
| 1602 | + "mapPanelOpens": true | |
| 1603 | + }, | |
| 1604 | + "errors": [], | |
| 1605 | + "badResponses": [] | |
| 1606 | + }, | |
| 1607 | + { | |
| 1608 | + "name": "search", | |
| 1609 | + "path": "/search?q=montreal", | |
| 1610 | + "w": 1280, | |
| 1611 | + "theme": "light", | |
| 1612 | + "status": 200, | |
| 1613 | + "ttfb": 5, | |
| 1614 | + "overflow": null, | |
| 1615 | + "clipped": [ | |
| 1616 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1617 | + ], | |
| 1618 | + "small": [], | |
| 1619 | + "smallCount": 0, | |
| 1620 | + "h1Covered": false, | |
| 1621 | + "h1Top": 106, | |
| 1622 | + "headerBottom": 57, | |
| 1623 | + "lang": "en", | |
| 1624 | + "metaDesc": 159, | |
| 1625 | + "h1Count": 1, | |
| 1626 | + "skipped": [], | |
| 1627 | + "unnamed": [], | |
| 1628 | + "ariaCurrent": 0, | |
| 1629 | + "footerOk": true, | |
| 1630 | + "contrast": [], | |
| 1631 | + "contrastChecked": 0, | |
| 1632 | + "height": 984, | |
| 1633 | + "title": "“montreal” — search · DataCenterIndex", | |
| 1634 | + "errors": [ | |
| 1635 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1636 | + ], | |
| 1637 | + "badResponses": [ | |
| 1638 | + "404 fetch /api/v1/docs?_rsc=6grVij_EUBxTWnLP" | |
| 1639 | + ] | |
| 1640 | + }, | |
| 1641 | + { | |
| 1642 | + "name": "live", | |
| 1643 | + "path": "/live", | |
| 1644 | + "w": 1280, | |
| 1645 | + "theme": "light", | |
| 1646 | + "status": 200, | |
| 1647 | + "ttfb": 12, | |
| 1648 | + "overflow": null, | |
| 1649 | + "clipped": [ | |
| 1650 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1651 | + "span.sr-only \"United States\" 89>1", | |
| 1652 | + "span.sr-only \"United States\" 89>1", | |
| 1653 | + "span.sr-only \"United States\" 89>1", | |
| 1654 | + "span.sr-only \"United States\" 89>1", | |
| 1655 | + "span.sr-only \"United States\" 89>1", | |
| 1656 | + "span.sr-only \"United States\" 89>1", | |
| 1657 | + "span.sr-only \"United States\" 89>1", | |
| 1658 | + "span.sr-only \"United States\" 89>1" | |
| 1659 | + ], | |
| 1660 | + "small": [], | |
| 1661 | + "smallCount": 0, | |
| 1662 | + "h1Covered": false, | |
| 1663 | + "h1Top": 107, | |
| 1664 | + "headerBottom": 57, | |
| 1665 | + "lang": "en", | |
| 1666 | + "metaDesc": 152, | |
| 1667 | + "h1Count": 1, | |
| 1668 | + "skipped": [ | |
| 1669 | + "h1→h3" | |
| 1670 | + ], | |
| 1671 | + "unnamed": [], | |
| 1672 | + "ariaCurrent": 1, | |
| 1673 | + "footerOk": true, | |
| 1674 | + "contrast": [], | |
| 1675 | + "contrastChecked": 0, | |
| 1676 | + "height": 7789, | |
| 1677 | + "title": "Live changes · DataCenterIndex", | |
| 1678 | + "inter": { | |
| 1679 | + "filterSheetOpens": true, | |
| 1680 | + "filterSheetCloses": true | |
| 1681 | + }, | |
| 1682 | + "errors": [], | |
| 1683 | + "badResponses": [] | |
| 1684 | + }, | |
| 1685 | + { | |
| 1686 | + "name": "datacenters", | |
| 1687 | + "path": "/datacenters", | |
| 1688 | + "w": 1280, | |
| 1689 | + "theme": "light", | |
| 1690 | + "status": 200, | |
| 1691 | + "ttfb": 13, | |
| 1692 | + "overflow": null, | |
| 1693 | + "clipped": [ | |
| 1694 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 1695 | + ], | |
| 1696 | + "small": [], | |
| 1697 | + "smallCount": 0, | |
| 1698 | + "h1Covered": false, | |
| 1699 | + "h1Top": 106, | |
| 1700 | + "headerBottom": 57, | |
| 1701 | + "lang": "en", | |
| 1702 | + "metaDesc": 119, | |
| 1703 | + "h1Count": 1, | |
| 1704 | + "skipped": [], | |
| 1705 | + "unnamed": [], | |
| 1706 | + "ariaCurrent": 2, | |
| 1707 | + "footerOk": true, | |
| 1708 | + "contrast": [], | |
| 1709 | + "contrastChecked": 0, | |
| 1710 | + "height": 2652, | |
| 1711 | + "title": "Data centers · DataCenterIndex", | |
| 1712 | + "inter": { | |
| 1713 | + "filterSheetOpens": true, | |
| 1714 | + "filterSheetCloses": true, | |
| 1715 | + "paginationPage2": true, | |
| 1716 | + "page2Rows": 50 | |
| 1717 | + }, | |
| 1718 | + "errors": [ | |
| 1719 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1720 | + ], | |
| 1721 | + "badResponses": [ | |
| 1722 | + "404 fetch /api/v1/docs?_rsc=2QMc8M2_JReeYqv7" | |
| 1723 | + ] | |
| 1724 | + }, | |
| 1725 | + { | |
| 1726 | + "name": "fac-operator-mw", | |
| 1727 | + "path": "/datacenters/databank-downtown-minneapolis-data-center-msp4", | |
| 1728 | + "w": 1280, | |
| 1729 | + "theme": "light", | |
| 1730 | + "status": 200, | |
| 1731 | + "ttfb": 14, | |
| 1732 | + "overflow": null, | |
| 1733 | + "clipped": [ | |
| 1734 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1735 | + "span.sr-only \"United States\" 86>1", | |
| 1736 | + "span.sr-only \"United States\" 82>1", | |
| 1737 | + "span.sr-only \"United States\" 89>1" | |
| 1738 | + ], | |
| 1739 | + "small": [], | |
| 1740 | + "smallCount": 0, | |
| 1741 | + "h1Covered": false, | |
| 1742 | + "h1Top": 135, | |
| 1743 | + "headerBottom": 57, | |
| 1744 | + "lang": "en", | |
| 1745 | + "metaDesc": 212, | |
| 1746 | + "h1Count": 1, | |
| 1747 | + "skipped": [], | |
| 1748 | + "unnamed": [], | |
| 1749 | + "ariaCurrent": 1, | |
| 1750 | + "footerOk": true, | |
| 1751 | + "contrast": [], | |
| 1752 | + "contrastChecked": 0, | |
| 1753 | + "height": 4103, | |
| 1754 | + "title": "Downtown Minneapolis Data Center (MSP4) (Minneapolis, US) · DataCenterIndex", | |
| 1755 | + "errors": [], | |
| 1756 | + "badResponses": [] | |
| 1757 | + }, | |
| 1758 | + { | |
| 1759 | + "name": "fac-osm", | |
| 1760 | + "path": "/datacenters/shelter-modernizacion", | |
| 1761 | + "w": 1280, | |
| 1762 | + "theme": "light", | |
| 1763 | + "status": 200, | |
| 1764 | + "ttfb": 9, | |
| 1765 | + "overflow": null, | |
| 1766 | + "clipped": [ | |
| 1767 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1768 | + "span.sr-only \"Argentina\" 62>1", | |
| 1769 | + "span.sr-only \"Argentina\" 59>1", | |
| 1770 | + "span.sr-only \"Argentina\" 64>1" | |
| 1771 | + ], | |
| 1772 | + "small": [], | |
| 1773 | + "smallCount": 0, | |
| 1774 | + "h1Covered": false, | |
| 1775 | + "h1Top": 132, | |
| 1776 | + "headerBottom": 57, | |
| 1777 | + "lang": "en", | |
| 1778 | + "metaDesc": 128, | |
| 1779 | + "h1Count": 1, | |
| 1780 | + "skipped": [], | |
| 1781 | + "unnamed": [], | |
| 1782 | + "ariaCurrent": 1, | |
| 1783 | + "footerOk": true, | |
| 1784 | + "contrast": [], | |
| 1785 | + "contrastChecked": 0, | |
| 1786 | + "height": 3088, | |
| 1787 | + "title": "Shelter Modernización · DataCenterIndex", | |
| 1788 | + "errors": [], | |
| 1789 | + "badResponses": [] | |
| 1790 | + }, | |
| 1791 | + { | |
| 1792 | + "name": "fac-google", | |
| 1793 | + "path": "/datacenters/google-the-dalles-oregon-data-center", | |
| 1794 | + "w": 1280, | |
| 1795 | + "theme": "light", | |
| 1796 | + "status": 200, | |
| 1797 | + "ttfb": 17, | |
| 1798 | + "overflow": null, | |
| 1799 | + "clipped": [ | |
| 1800 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1801 | + "span.sr-only \"United States\" 86>1", | |
| 1802 | + "span.sr-only \"United States\" 82>1", | |
| 1803 | + "span.sr-only \"United States\" 89>1", | |
| 1804 | + "span.sr-only \"United States\" 89>1", | |
| 1805 | + "span.sr-only \"United States\" 89>1", | |
| 1806 | + "span.sr-only \"United States\" 89>1" | |
| 1807 | + ], | |
| 1808 | + "small": [], | |
| 1809 | + "smallCount": 0, | |
| 1810 | + "h1Covered": false, | |
| 1811 | + "h1Top": 135, | |
| 1812 | + "headerBottom": 57, | |
| 1813 | + "lang": "en", | |
| 1814 | + "metaDesc": 203, | |
| 1815 | + "h1Count": 1, | |
| 1816 | + "skipped": [], | |
| 1817 | + "unnamed": [], | |
| 1818 | + "ariaCurrent": 1, | |
| 1819 | + "footerOk": true, | |
| 1820 | + "contrast": [], | |
| 1821 | + "contrastChecked": 0, | |
| 1822 | + "height": 6882, | |
| 1823 | + "title": "Google The Dalles, Oregon data center (The Dalles, US) · DataCenterIndex", | |
| 1824 | + "errors": [], | |
| 1825 | + "badResponses": [] | |
| 1826 | + }, | |
| 1827 | + { | |
| 1828 | + "name": "fac-wikidata", | |
| 1829 | + "path": "/datacenters/foulum-data-center", | |
| 1830 | + "w": 1280, | |
| 1831 | + "theme": "light", | |
| 1832 | + "status": 200, | |
| 1833 | + "ttfb": 12, | |
| 1834 | + "overflow": null, | |
| 1835 | + "clipped": [ | |
| 1836 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1837 | + "span.sr-only \"Denmark\" 58>1", | |
| 1838 | + "span.sr-only \"Denmark\" 56>1", | |
| 1839 | + "span.sr-only \"Denmark\" 60>1" | |
| 1840 | + ], | |
| 1841 | + "small": [], | |
| 1842 | + "smallCount": 0, | |
| 1843 | + "h1Covered": false, | |
| 1844 | + "h1Top": 135, | |
| 1845 | + "headerBottom": 57, | |
| 1846 | + "lang": "en", | |
| 1847 | + "metaDesc": 158, | |
| 1848 | + "h1Count": 1, | |
| 1849 | + "skipped": [], | |
| 1850 | + "unnamed": [], | |
| 1851 | + "ariaCurrent": 1, | |
| 1852 | + "footerOk": true, | |
| 1853 | + "contrast": [], | |
| 1854 | + "contrastChecked": 0, | |
| 1855 | + "height": 3868, | |
| 1856 | + "title": "Foulum Data Center (Viborg Municipality, DK) · DataCenterIndex", | |
| 1857 | + "errors": [], | |
| 1858 | + "badResponses": [] | |
| 1859 | + }, | |
| 1860 | + { | |
| 1861 | + "name": "operators", | |
| 1862 | + "path": "/operators", | |
| 1863 | + "w": 1280, | |
| 1864 | + "theme": "light", | |
| 1865 | + "status": 200, | |
| 1866 | + "ttfb": 11, | |
| 1867 | + "overflow": null, | |
| 1868 | + "clipped": [ | |
| 1869 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1870 | + "span.sr-only \"Search operators\" 114>1" | |
| 1871 | + ], | |
| 1872 | + "small": [], | |
| 1873 | + "smallCount": 0, | |
| 1874 | + "h1Covered": false, | |
| 1875 | + "h1Top": 106, | |
| 1876 | + "headerBottom": 57, | |
| 1877 | + "lang": "en", | |
| 1878 | + "metaDesc": 139, | |
| 1879 | + "h1Count": 1, | |
| 1880 | + "skipped": [], | |
| 1881 | + "unnamed": [], | |
| 1882 | + "ariaCurrent": 2, | |
| 1883 | + "footerOk": true, | |
| 1884 | + "contrast": [], | |
| 1885 | + "contrastChecked": 0, | |
| 1886 | + "height": 2622, | |
| 1887 | + "title": "Operators · DataCenterIndex", | |
| 1888 | + "inter": { | |
| 1889 | + "paginationPage2": true, | |
| 1890 | + "page2Rows": 50 | |
| 1891 | + }, | |
| 1892 | + "errors": [ | |
| 1893 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 1894 | + ], | |
| 1895 | + "badResponses": [ | |
| 1896 | + "404 fetch /api/v1/docs?_rsc=nAdjiVRfIop78OSR" | |
| 1897 | + ] | |
| 1898 | + }, | |
| 1899 | + { | |
| 1900 | + "name": "operator-equinix", | |
| 1901 | + "path": "/operators/equinix", | |
| 1902 | + "w": 1280, | |
| 1903 | + "theme": "light", | |
| 1904 | + "status": 200, | |
| 1905 | + "ttfb": 19, | |
| 1906 | + "overflow": null, | |
| 1907 | + "clipped": [ | |
| 1908 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1909 | + "span.sr-only \"Finland\" 45>1", | |
| 1910 | + "span.sr-only \"Germany\" 56>1", | |
| 1911 | + "span.sr-only \"Italy\" 26>1", | |
| 1912 | + "span.sr-only \"Switzerland\" 72>1", | |
| 1913 | + "span.sr-only \"United States\" 82>1", | |
| 1914 | + "span.sr-only \"Australia\" 54>1", | |
| 1915 | + "span.sr-only \"Ireland\" 42>1", | |
| 1916 | + "span.sr-only \"Netherlands\" 75>1" | |
| 1917 | + ], | |
| 1918 | + "small": [], | |
| 1919 | + "smallCount": 0, | |
| 1920 | + "h1Covered": false, | |
| 1921 | + "h1Top": 138, | |
| 1922 | + "headerBottom": 57, | |
| 1923 | + "lang": "en", | |
| 1924 | + "metaDesc": 170, | |
| 1925 | + "h1Count": 1, | |
| 1926 | + "skipped": [], | |
| 1927 | + "unnamed": [], | |
| 1928 | + "ariaCurrent": 2, | |
| 1929 | + "footerOk": true, | |
| 1930 | + "contrast": [], | |
| 1931 | + "contrastChecked": 0, | |
| 1932 | + "height": 7121, | |
| 1933 | + "title": "Equinix · DataCenterIndex", | |
| 1934 | + "errors": [], | |
| 1935 | + "badResponses": [] | |
| 1936 | + }, | |
| 1937 | + { | |
| 1938 | + "name": "countries", | |
| 1939 | + "path": "/countries", | |
| 1940 | + "w": 1280, | |
| 1941 | + "theme": "light", | |
| 1942 | + "status": 200, | |
| 1943 | + "ttfb": 20, | |
| 1944 | + "overflow": null, | |
| 1945 | + "clipped": [ | |
| 1946 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1947 | + "span.sr-only \"United States\" 83>1", | |
| 1948 | + "span.sr-only \"Brazil\" 35>1", | |
| 1949 | + "span.sr-only \"Argentina\" 60>1", | |
| 1950 | + "span.sr-only \"Canada\" 48>1", | |
| 1951 | + "span.sr-only \"Mexico\" 45>1", | |
| 1952 | + "span.sr-only \"Chile\" 32>1", | |
| 1953 | + "span.sr-only \"Venezuela\" 65>1", | |
| 1954 | + "span.sr-only \"Peru\" 29>1" | |
| 1955 | + ], | |
| 1956 | + "small": [], | |
| 1957 | + "smallCount": 0, | |
| 1958 | + "h1Covered": false, | |
| 1959 | + "h1Top": 106, | |
| 1960 | + "headerBottom": 57, | |
| 1961 | + "lang": "en", | |
| 1962 | + "metaDesc": 117, | |
| 1963 | + "h1Count": 1, | |
| 1964 | + "skipped": [], | |
| 1965 | + "unnamed": [], | |
| 1966 | + "ariaCurrent": 2, | |
| 1967 | + "footerOk": true, | |
| 1968 | + "contrast": [], | |
| 1969 | + "contrastChecked": 0, | |
| 1970 | + "height": 5414, | |
| 1971 | + "title": "Countries · DataCenterIndex", | |
| 1972 | + "errors": [], | |
| 1973 | + "badResponses": [] | |
| 1974 | + }, | |
| 1975 | + { | |
| 1976 | + "name": "country-us", | |
| 1977 | + "path": "/countries/united-states", | |
| 1978 | + "w": 1280, | |
| 1979 | + "theme": "light", | |
| 1980 | + "status": 200, | |
| 1981 | + "ttfb": 25, | |
| 1982 | + "overflow": null, | |
| 1983 | + "clipped": [ | |
| 1984 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 1985 | + "span.sr-only \"United States\" 187>1", | |
| 1986 | + "span.sr-only \"United States\" 76>1", | |
| 1987 | + "span.sr-only \"United States\" 76>1", | |
| 1988 | + "span.sr-only \"United States\" 76>1", | |
| 1989 | + "span.sr-only \"United States\" 76>1", | |
| 1990 | + "span.sr-only \"United States\" 76>1", | |
| 1991 | + "span.sr-only \"United States\" 76>1", | |
| 1992 | + "span.sr-only \"United States\" 76>1" | |
| 1993 | + ], | |
| 1994 | + "small": [], | |
| 1995 | + "smallCount": 0, | |
| 1996 | + "h1Covered": false, | |
| 1997 | + "h1Top": 136, | |
| 1998 | + "headerBottom": 57, | |
| 1999 | + "lang": "en", | |
| 2000 | + "metaDesc": 258, | |
| 2001 | + "h1Count": 1, | |
| 2002 | + "skipped": [], | |
| 2003 | + "unnamed": [], | |
| 2004 | + "ariaCurrent": 2, | |
| 2005 | + "footerOk": true, | |
| 2006 | + "contrast": [], | |
| 2007 | + "contrastChecked": 0, | |
| 2008 | + "height": 11218, | |
| 2009 | + "title": "Data Centers in United States · DataCenterIndex", | |
| 2010 | + "errors": [], | |
| 2011 | + "badResponses": [] | |
| 2012 | + }, | |
| 2013 | + { | |
| 2014 | + "name": "country-ca", | |
| 2015 | + "path": "/countries/canada", | |
| 2016 | + "w": 1280, | |
| 2017 | + "theme": "light", | |
| 2018 | + "status": 200, | |
| 2019 | + "ttfb": 14, | |
| 2020 | + "overflow": null, | |
| 2021 | + "clipped": [ | |
| 2022 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2023 | + "span.sr-only \"Canada\" 107>1", | |
| 2024 | + "span.sr-only \"Canada\" 43>1", | |
| 2025 | + "span.sr-only \"Canada\" 43>1", | |
| 2026 | + "span.sr-only \"Canada\" 51>1", | |
| 2027 | + "span.sr-only \"Canada\" 51>1", | |
| 2028 | + "span.sr-only \"Canada\" 51>1", | |
| 2029 | + "span.sr-only \"Canada\" 51>1", | |
| 2030 | + "span.sr-only \"Canada\" 51>1" | |
| 2031 | + ], | |
| 2032 | + "small": [], | |
| 2033 | + "smallCount": 0, | |
| 2034 | + "h1Covered": false, | |
| 2035 | + "h1Top": 136, | |
| 2036 | + "headerBottom": 57, | |
| 2037 | + "lang": "en", | |
| 2038 | + "metaDesc": 186, | |
| 2039 | + "h1Count": 1, | |
| 2040 | + "skipped": [], | |
| 2041 | + "unnamed": [], | |
| 2042 | + "ariaCurrent": 2, | |
| 2043 | + "footerOk": true, | |
| 2044 | + "contrast": [], | |
| 2045 | + "contrastChecked": 0, | |
| 2046 | + "height": 5842, | |
| 2047 | + "title": "Data Centers in Canada · DataCenterIndex", | |
| 2048 | + "errors": [], | |
| 2049 | + "badResponses": [] | |
| 2050 | + }, | |
| 2051 | + { | |
| 2052 | + "name": "markets", | |
| 2053 | + "path": "/datacenters/markets", | |
| 2054 | + "w": 1280, | |
| 2055 | + "theme": "light", | |
| 2056 | + "status": 200, | |
| 2057 | + "ttfb": 15, | |
| 2058 | + "overflow": null, | |
| 2059 | + "clipped": [ | |
| 2060 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2061 | + ], | |
| 2062 | + "small": [], | |
| 2063 | + "smallCount": 0, | |
| 2064 | + "h1Covered": false, | |
| 2065 | + "h1Top": 136, | |
| 2066 | + "headerBottom": 57, | |
| 2067 | + "lang": "en", | |
| 2068 | + "metaDesc": 125, | |
| 2069 | + "h1Count": 1, | |
| 2070 | + "skipped": [], | |
| 2071 | + "unnamed": [], | |
| 2072 | + "ariaCurrent": 2, | |
| 2073 | + "footerOk": true, | |
| 2074 | + "contrast": [], | |
| 2075 | + "contrastChecked": 0, | |
| 2076 | + "height": 2693, | |
| 2077 | + "title": "Data center markets · DataCenterIndex", | |
| 2078 | + "errors": [], | |
| 2079 | + "badResponses": [] | |
| 2080 | + }, | |
| 2081 | + { | |
| 2082 | + "name": "market-nova", | |
| 2083 | + "path": "/datacenters/markets/northern-virginia", | |
| 2084 | + "w": 1280, | |
| 2085 | + "theme": "light", | |
| 2086 | + "status": 200, | |
| 2087 | + "ttfb": 13, | |
| 2088 | + "overflow": null, | |
| 2089 | + "clipped": [ | |
| 2090 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2091 | + "span.sr-only \"United States\" 82>1", | |
| 2092 | + "span.sr-only \"United States\" 76>1", | |
| 2093 | + "span.sr-only \"United States\" 89>1", | |
| 2094 | + "span.sr-only \"United States\" 89>1", | |
| 2095 | + "span.sr-only \"United States\" 89>1", | |
| 2096 | + "span.sr-only \"United States\" 89>1", | |
| 2097 | + "span.sr-only \"United States\" 89>1", | |
| 2098 | + "span.sr-only \"United States\" 89>1" | |
| 2099 | + ], | |
| 2100 | + "small": [], | |
| 2101 | + "smallCount": 0, | |
| 2102 | + "h1Covered": false, | |
| 2103 | + "h1Top": 136, | |
| 2104 | + "headerBottom": 57, | |
| 2105 | + "lang": "en", | |
| 2106 | + "metaDesc": 250, | |
| 2107 | + "h1Count": 1, | |
| 2108 | + "skipped": [], | |
| 2109 | + "unnamed": [], | |
| 2110 | + "ariaCurrent": 2, | |
| 2111 | + "footerOk": true, | |
| 2112 | + "contrast": [], | |
| 2113 | + "contrastChecked": 0, | |
| 2114 | + "height": 6519, | |
| 2115 | + "title": "Data Centers in Northern Virginia · DataCenterIndex", | |
| 2116 | + "errors": [], | |
| 2117 | + "badResponses": [] | |
| 2118 | + }, | |
| 2119 | + { | |
| 2120 | + "name": "projects", | |
| 2121 | + "path": "/projects", | |
| 2122 | + "w": 1280, | |
| 2123 | + "theme": "light", | |
| 2124 | + "status": 200, | |
| 2125 | + "ttfb": 14, | |
| 2126 | + "overflow": null, | |
| 2127 | + "clipped": [ | |
| 2128 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2129 | + "span.sr-only \"United States\" 82>1", | |
| 2130 | + "span.sr-only \"United States\" 82>1", | |
| 2131 | + "span.sr-only \"Australia\" 54>1", | |
| 2132 | + "span.sr-only \"Canada\" 47>1", | |
| 2133 | + "span.sr-only \"United States\" 82>1", | |
| 2134 | + "span.sr-only \"Norway\" 48>1" | |
| 2135 | + ], | |
| 2136 | + "small": [], | |
| 2137 | + "smallCount": 0, | |
| 2138 | + "h1Covered": false, | |
| 2139 | + "h1Top": 106, | |
| 2140 | + "headerBottom": 57, | |
| 2141 | + "lang": "en", | |
| 2142 | + "metaDesc": 130, | |
| 2143 | + "h1Count": 1, | |
| 2144 | + "skipped": [], | |
| 2145 | + "unnamed": [], | |
| 2146 | + "ariaCurrent": 3, | |
| 2147 | + "footerOk": true, | |
| 2148 | + "contrast": [], | |
| 2149 | + "contrastChecked": 0, | |
| 2150 | + "height": 3977, | |
| 2151 | + "title": "Data center projects · DataCenterIndex", | |
| 2152 | + "inter": { | |
| 2153 | + "filterSheetOpens": true, | |
| 2154 | + "filterSheetCloses": true, | |
| 2155 | + "paginationPage2": true, | |
| 2156 | + "page2Rows": 50 | |
| 2157 | + }, | |
| 2158 | + "errors": [ | |
| 2159 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 2160 | + ], | |
| 2161 | + "badResponses": [ | |
| 2162 | + "404 fetch /api/v1/docs?_rsc=rVOxZyJZQf-ncF1B" | |
| 2163 | + ] | |
| 2164 | + }, | |
| 2165 | + { | |
| 2166 | + "name": "project", | |
| 2167 | + "path": "/projects/sydney-data-center-project", | |
| 2168 | + "w": 1280, | |
| 2169 | + "theme": "light", | |
| 2170 | + "status": 200, | |
| 2171 | + "ttfb": 7, | |
| 2172 | + "overflow": null, | |
| 2173 | + "clipped": [ | |
| 2174 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2175 | + "span.sr-only \"Australia\" 54>1", | |
| 2176 | + "span.sr-only \"Australia\" 54>1", | |
| 2177 | + "span.sr-only \"Australia\" 58>1" | |
| 2178 | + ], | |
| 2179 | + "small": [], | |
| 2180 | + "smallCount": 0, | |
| 2181 | + "h1Covered": false, | |
| 2182 | + "h1Top": 136, | |
| 2183 | + "headerBottom": 57, | |
| 2184 | + "lang": "en", | |
| 2185 | + "metaDesc": 201, | |
| 2186 | + "h1Count": 1, | |
| 2187 | + "skipped": [], | |
| 2188 | + "unnamed": [], | |
| 2189 | + "ariaCurrent": 2, | |
| 2190 | + "footerOk": true, | |
| 2191 | + "contrast": [], | |
| 2192 | + "contrastChecked": 0, | |
| 2193 | + "height": 2902, | |
| 2194 | + "title": "Sydney data center project · DataCenterIndex", | |
| 2195 | + "errors": [], | |
| 2196 | + "badResponses": [] | |
| 2197 | + }, | |
| 2198 | + { | |
| 2199 | + "name": "rankings", | |
| 2200 | + "path": "/rankings", | |
| 2201 | + "w": 1280, | |
| 2202 | + "theme": "light", | |
| 2203 | + "status": 200, | |
| 2204 | + "ttfb": 3, | |
| 2205 | + "overflow": null, | |
| 2206 | + "clipped": [ | |
| 2207 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2208 | + ], | |
| 2209 | + "small": [], | |
| 2210 | + "smallCount": 0, | |
| 2211 | + "h1Covered": false, | |
| 2212 | + "h1Top": 106, | |
| 2213 | + "headerBottom": 57, | |
| 2214 | + "lang": "en", | |
| 2215 | + "metaDesc": 190, | |
| 2216 | + "h1Count": 1, | |
| 2217 | + "skipped": [], | |
| 2218 | + "unnamed": [], | |
| 2219 | + "ariaCurrent": 2, | |
| 2220 | + "footerOk": true, | |
| 2221 | + "contrast": [], | |
| 2222 | + "contrastChecked": 0, | |
| 2223 | + "height": 3333, | |
| 2224 | + "title": "Rankings · DataCenterIndex", | |
| 2225 | + "errors": [], | |
| 2226 | + "badResponses": [] | |
| 2227 | + }, | |
| 2228 | + { | |
| 2229 | + "name": "ranking", | |
| 2230 | + "path": "/rankings/countries_facilities", | |
| 2231 | + "w": 1280, | |
| 2232 | + "theme": "light", | |
| 2233 | + "status": 200, | |
| 2234 | + "ttfb": 12, | |
| 2235 | + "overflow": null, | |
| 2236 | + "clipped": [ | |
| 2237 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2238 | + "span.sr-only \"United States\" 82>1", | |
| 2239 | + "span.sr-only \"France\" 42>1", | |
| 2240 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 2241 | + "span.sr-only \"Germany\" 56>1", | |
| 2242 | + "span.sr-only \"Australia\" 54>1", | |
| 2243 | + "span.sr-only \"Netherlands\" 75>1", | |
| 2244 | + "span.sr-only \"India\" 30>1", | |
| 2245 | + "span.sr-only \"Singapore\" 62>1" | |
| 2246 | + ], | |
| 2247 | + "small": [], | |
| 2248 | + "smallCount": 0, | |
| 2249 | + "h1Covered": false, | |
| 2250 | + "h1Top": 136, | |
| 2251 | + "headerBottom": 57, | |
| 2252 | + "lang": "en", | |
| 2253 | + "metaDesc": 161, | |
| 2254 | + "h1Count": 1, | |
| 2255 | + "skipped": [ | |
| 2256 | + "h1→h3" | |
| 2257 | + ], | |
| 2258 | + "unnamed": [], | |
| 2259 | + "ariaCurrent": 2, | |
| 2260 | + "footerOk": true, | |
| 2261 | + "contrast": [], | |
| 2262 | + "contrastChecked": 0, | |
| 2263 | + "height": 4578, | |
| 2264 | + "title": "Countries by facility count · DataCenterIndex", | |
| 2265 | + "errors": [], | |
| 2266 | + "badResponses": [] | |
| 2267 | + }, | |
| 2268 | + { | |
| 2269 | + "name": "cloud-regions", | |
| 2270 | + "path": "/cloud-regions", | |
| 2271 | + "w": 1280, | |
| 2272 | + "theme": "light", | |
| 2273 | + "status": 200, | |
| 2274 | + "ttfb": 30, | |
| 2275 | + "overflow": null, | |
| 2276 | + "clipped": [ | |
| 2277 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2278 | + "label.sr-only \"Provider\" 55>1", | |
| 2279 | + "label.sr-only \"Country\" 53>1", | |
| 2280 | + "label.sr-only \"Status\" 42>1" | |
| 2281 | + ], | |
| 2282 | + "small": [], | |
| 2283 | + "smallCount": 0, | |
| 2284 | + "h1Covered": false, | |
| 2285 | + "h1Top": 106, | |
| 2286 | + "headerBottom": 57, | |
| 2287 | + "lang": "en", | |
| 2288 | + "metaDesc": 155, | |
| 2289 | + "h1Count": 1, | |
| 2290 | + "skipped": [], | |
| 2291 | + "unnamed": [], | |
| 2292 | + "ariaCurrent": 0, | |
| 2293 | + "footerOk": true, | |
| 2294 | + "contrast": [], | |
| 2295 | + "contrastChecked": 0, | |
| 2296 | + "height": 12906, | |
| 2297 | + "title": "Cloud regions · DataCenterIndex", | |
| 2298 | + "errors": [], | |
| 2299 | + "badResponses": [] | |
| 2300 | + }, | |
| 2301 | + { | |
| 2302 | + "name": "cloud-region", | |
| 2303 | + "path": "/cloud-regions/amazon-web-services-ca-central-1", | |
| 2304 | + "w": 1280, | |
| 2305 | + "theme": "light", | |
| 2306 | + "status": 200, | |
| 2307 | + "ttfb": 6, | |
| 2308 | + "overflow": null, | |
| 2309 | + "clipped": [ | |
| 2310 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2311 | + "span.sr-only \"Canada\" 47>1" | |
| 2312 | + ], | |
| 2313 | + "small": [], | |
| 2314 | + "smallCount": 0, | |
| 2315 | + "h1Covered": false, | |
| 2316 | + "h1Top": 136, | |
| 2317 | + "headerBottom": 57, | |
| 2318 | + "lang": "en", | |
| 2319 | + "metaDesc": 165, | |
| 2320 | + "h1Count": 1, | |
| 2321 | + "skipped": [], | |
| 2322 | + "unnamed": [], | |
| 2323 | + "ariaCurrent": 0, | |
| 2324 | + "footerOk": true, | |
| 2325 | + "contrast": [], | |
| 2326 | + "contrastChecked": 0, | |
| 2327 | + "height": 1902, | |
| 2328 | + "title": "Amazon Web Services ca-central-1 — Canada (Central) · DataCenterIndex", | |
| 2329 | + "errors": [], | |
| 2330 | + "badResponses": [] | |
| 2331 | + }, | |
| 2332 | + { | |
| 2333 | + "name": "ixps", | |
| 2334 | + "path": "/ixps", | |
| 2335 | + "w": 1280, | |
| 2336 | + "theme": "light", | |
| 2337 | + "status": 200, | |
| 2338 | + "ttfb": 6, | |
| 2339 | + "overflow": null, | |
| 2340 | + "clipped": [ | |
| 2341 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2342 | + "label.sr-only \"Search\" 46>1", | |
| 2343 | + "label.sr-only \"Country\" 53>1" | |
| 2344 | + ], | |
| 2345 | + "small": [], | |
| 2346 | + "smallCount": 0, | |
| 2347 | + "h1Covered": false, | |
| 2348 | + "h1Top": 106, | |
| 2349 | + "headerBottom": 57, | |
| 2350 | + "lang": "en", | |
| 2351 | + "metaDesc": 100, | |
| 2352 | + "h1Count": 1, | |
| 2353 | + "skipped": [], | |
| 2354 | + "unnamed": [], | |
| 2355 | + "ariaCurrent": 1, | |
| 2356 | + "footerOk": true, | |
| 2357 | + "contrast": [], | |
| 2358 | + "contrastChecked": 0, | |
| 2359 | + "height": 1171, | |
| 2360 | + "title": "Internet exchanges · DataCenterIndex", | |
| 2361 | + "errors": [ | |
| 2362 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 2363 | + ], | |
| 2364 | + "badResponses": [ | |
| 2365 | + "404 fetch /api/v1/docs?_rsc=LOh4kTrez0j5S-5D" | |
| 2366 | + ] | |
| 2367 | + }, | |
| 2368 | + { | |
| 2369 | + "name": "sources", | |
| 2370 | + "path": "/sources", | |
| 2371 | + "w": 1280, | |
| 2372 | + "theme": "light", | |
| 2373 | + "status": 200, | |
| 2374 | + "ttfb": 16, | |
| 2375 | + "overflow": null, | |
| 2376 | + "clipped": [ | |
| 2377 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2378 | + ], | |
| 2379 | + "small": [], | |
| 2380 | + "smallCount": 0, | |
| 2381 | + "h1Covered": false, | |
| 2382 | + "h1Top": 136, | |
| 2383 | + "headerBottom": 57, | |
| 2384 | + "lang": "en", | |
| 2385 | + "metaDesc": 162, | |
| 2386 | + "h1Count": 1, | |
| 2387 | + "skipped": [ | |
| 2388 | + "h1→h3" | |
| 2389 | + ], | |
| 2390 | + "unnamed": [], | |
| 2391 | + "ariaCurrent": 0, | |
| 2392 | + "footerOk": true, | |
| 2393 | + "contrast": [], | |
| 2394 | + "contrastChecked": 0, | |
| 2395 | + "height": 8101, | |
| 2396 | + "title": "Sources · DataCenterIndex", | |
| 2397 | + "errors": [], | |
| 2398 | + "badResponses": [] | |
| 2399 | + }, | |
| 2400 | + { | |
| 2401 | + "name": "methodology", | |
| 2402 | + "path": "/methodology", | |
| 2403 | + "w": 1280, | |
| 2404 | + "theme": "light", | |
| 2405 | + "status": 200, | |
| 2406 | + "ttfb": 3, | |
| 2407 | + "overflow": null, | |
| 2408 | + "clipped": [ | |
| 2409 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2410 | + ], | |
| 2411 | + "small": [], | |
| 2412 | + "smallCount": 0, | |
| 2413 | + "h1Covered": false, | |
| 2414 | + "h1Top": 136, | |
| 2415 | + "headerBottom": 57, | |
| 2416 | + "lang": "en", | |
| 2417 | + "metaDesc": 201, | |
| 2418 | + "h1Count": 1, | |
| 2419 | + "skipped": [], | |
| 2420 | + "unnamed": [], | |
| 2421 | + "ariaCurrent": 0, | |
| 2422 | + "footerOk": true, | |
| 2423 | + "contrast": [], | |
| 2424 | + "contrastChecked": 0, | |
| 2425 | + "height": 6645, | |
| 2426 | + "title": "Methodology · DataCenterIndex", | |
| 2427 | + "errors": [], | |
| 2428 | + "badResponses": [] | |
| 2429 | + }, | |
| 2430 | + { | |
| 2431 | + "name": "about", | |
| 2432 | + "path": "/about", | |
| 2433 | + "w": 1280, | |
| 2434 | + "theme": "light", | |
| 2435 | + "status": 200, | |
| 2436 | + "ttfb": 2, | |
| 2437 | + "overflow": null, | |
| 2438 | + "clipped": [ | |
| 2439 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2440 | + ], | |
| 2441 | + "small": [], | |
| 2442 | + "smallCount": 0, | |
| 2443 | + "h1Covered": false, | |
| 2444 | + "h1Top": 106, | |
| 2445 | + "headerBottom": 57, | |
| 2446 | + "lang": "en", | |
| 2447 | + "metaDesc": 85, | |
| 2448 | + "h1Count": 1, | |
| 2449 | + "skipped": [], | |
| 2450 | + "unnamed": [], | |
| 2451 | + "ariaCurrent": 0, | |
| 2452 | + "footerOk": true, | |
| 2453 | + "contrast": [], | |
| 2454 | + "contrastChecked": 0, | |
| 2455 | + "height": 1679, | |
| 2456 | + "title": "About · DataCenterIndex", | |
| 2457 | + "errors": [], | |
| 2458 | + "badResponses": [] | |
| 2459 | + }, | |
| 2460 | + { | |
| 2461 | + "name": "bot", | |
| 2462 | + "path": "/bot", | |
| 2463 | + "w": 1280, | |
| 2464 | + "theme": "light", | |
| 2465 | + "status": 200, | |
| 2466 | + "ttfb": 2, | |
| 2467 | + "overflow": null, | |
| 2468 | + "clipped": [ | |
| 2469 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2470 | + ], | |
| 2471 | + "small": [], | |
| 2472 | + "smallCount": 0, | |
| 2473 | + "h1Covered": false, | |
| 2474 | + "h1Top": 136, | |
| 2475 | + "headerBottom": 57, | |
| 2476 | + "lang": "en", | |
| 2477 | + "metaDesc": 179, | |
| 2478 | + "h1Count": 1, | |
| 2479 | + "skipped": [], | |
| 2480 | + "unnamed": [], | |
| 2481 | + "ariaCurrent": 0, | |
| 2482 | + "footerOk": true, | |
| 2483 | + "contrast": [], | |
| 2484 | + "contrastChecked": 0, | |
| 2485 | + "height": 2261, | |
| 2486 | + "title": "About our crawler · DataCenterIndex", | |
| 2487 | + "errors": [], | |
| 2488 | + "badResponses": [] | |
| 2489 | + }, | |
| 2490 | + { | |
| 2491 | + "name": "404", | |
| 2492 | + "path": "/this-page-does-not-exist", | |
| 2493 | + "w": 1280, | |
| 2494 | + "theme": "light", | |
| 2495 | + "status": 404, | |
| 2496 | + "ttfb": 1, | |
| 2497 | + "overflow": null, | |
| 2498 | + "clipped": [ | |
| 2499 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2500 | + ], | |
| 2501 | + "small": [], | |
| 2502 | + "smallCount": 0, | |
| 2503 | + "h1Covered": false, | |
| 2504 | + "h1Top": 261, | |
| 2505 | + "headerBottom": 57, | |
| 2506 | + "lang": "en", | |
| 2507 | + "metaDesc": 159, | |
| 2508 | + "h1Count": 1, | |
| 2509 | + "skipped": [], | |
| 2510 | + "unnamed": [], | |
| 2511 | + "ariaCurrent": 0, | |
| 2512 | + "footerOk": false, | |
| 2513 | + "contrast": [], | |
| 2514 | + "contrastChecked": 0, | |
| 2515 | + "height": 900, | |
| 2516 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 2517 | + "errors": [ | |
| 2518 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 2519 | + ], | |
| 2520 | + "badResponses": [] | |
| 2521 | + }, | |
| 2522 | + { | |
| 2523 | + "name": "home", | |
| 2524 | + "path": "/", | |
| 2525 | + "w": 390, | |
| 2526 | + "theme": "dark", | |
| 2527 | + "status": 200, | |
| 2528 | + "ttfb": 3, | |
| 2529 | + "overflow": null, | |
| 2530 | + "clipped": [ | |
| 2531 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2532 | + "span.sr-only \"United States\" 89>1", | |
| 2533 | + "span.sr-only \"United States\" 89>1", | |
| 2534 | + "span.sr-only \"United States\" 89>1", | |
| 2535 | + "span.sr-only \"United States\" 76>1", | |
| 2536 | + "span.sr-only \"United States\" 76>1", | |
| 2537 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 2538 | + "span.sr-only \"France\" 42>1", | |
| 2539 | + "span.sr-only \"Singapore\" 62>1" | |
| 2540 | + ], | |
| 2541 | + "small": [ | |
| 2542 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2543 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2544 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2545 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2546 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2547 | + "button.mr-1.5.inline-flex.size-9 36×36 \"Search\"", | |
| 2548 | + "button.shrink-0.rounded-sm.border 111×27 \"Northern Virginia\"", | |
| 2549 | + "button.shrink-0.rounded-sm.border 131×27 \"hyperscale in Ireland\"", | |
| 2550 | + "button.shrink-0.rounded-sm.border 179×27 \"> 100 MW under construct\"", | |
| 2551 | + "button.shrink-0.rounded-sm.border 57×27 \"Equinix\"", | |
| 2552 | + "button.shrink-0.rounded-sm.border 51×27 \"Japan\"", | |
| 2553 | + "a.inline-flex.items-center.gap-1 79×18 \"Open map→\"" | |
| 2554 | + ], | |
| 2555 | + "smallCount": 94, | |
| 2556 | + "h1Covered": false, | |
| 2557 | + "h1Top": 100, | |
| 2558 | + "headerBottom": 49, | |
| 2559 | + "lang": "en", | |
| 2560 | + "metaDesc": 159, | |
| 2561 | + "h1Count": 1, | |
| 2562 | + "skipped": [], | |
| 2563 | + "unnamed": [], | |
| 2564 | + "ariaCurrent": 1, | |
| 2565 | + "footerOk": true, | |
| 2566 | + "contrast": [ | |
| 2567 | + { | |
| 2568 | + "ratio": 3.07, | |
| 2569 | + "color": "rgb(91, 97, 104)", | |
| 2570 | + "bg": "rgb(11,15,20)", | |
| 2571 | + "sample": "span.normal-case.tracking-normal.text-ink-4 \"· updated continuously\"", | |
| 2572 | + "n": 1 | |
| 2573 | + }, | |
| 2574 | + { | |
| 2575 | + "ratio": 3.07, | |
| 2576 | + "color": "rgb(91, 97, 104)", | |
| 2577 | + "bg": "rgb(11,15,20)", | |
| 2578 | + "sample": "span.shrink-0.py-1.text-[11.5px] \"Try\"", | |
| 2579 | + "n": 1 | |
| 2580 | + }, | |
| 2581 | + { | |
| 2582 | + "ratio": 3.07, | |
| 2583 | + "color": "rgb(91, 97, 104)", | |
| 2584 | + "bg": "rgb(11,15,20)", | |
| 2585 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 2586 | + "n": 2 | |
| 2587 | + }, | |
| 2588 | + { | |
| 2589 | + "ratio": 3.07, | |
| 2590 | + "color": "rgb(91, 97, 104)", | |
| 2591 | + "bg": "rgb(11,15,20)", | |
| 2592 | + "sample": "span.figure.text-ink-4 \"30%\"", | |
| 2593 | + "n": 2 | |
| 2594 | + } | |
| 2595 | + ], | |
| 2596 | + "contrastChecked": 306, | |
| 2597 | + "height": 6300, | |
| 2598 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 2599 | + "bottom": { | |
| 2600 | + "navVisible": true, | |
| 2601 | + "covered": false, | |
| 2602 | + "gap": 809 | |
| 2603 | + }, | |
| 2604 | + "errors": [], | |
| 2605 | + "badResponses": [] | |
| 2606 | + }, | |
| 2607 | + { | |
| 2608 | + "name": "map", | |
| 2609 | + "path": "/map", | |
| 2610 | + "w": 390, | |
| 2611 | + "theme": "dark", | |
| 2612 | + "status": 200, | |
| 2613 | + "ttfb": 1, | |
| 2614 | + "overflow": null, | |
| 2615 | + "clipped": [ | |
| 2616 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2617 | + ], | |
| 2618 | + "small": [ | |
| 2619 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2620 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2621 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2622 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2623 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2624 | + "button.maplibregl-ctrl-geolocate 32×32 \"Location not available\"", | |
| 2625 | + "button.maplibregl-ctrl-zoom-in 32×32 \"Zoom in\"", | |
| 2626 | + "button.maplibregl-ctrl-zoom-out 32×32 \"Zoom out\"", | |
| 2627 | + "input.h-full.min-w-0.flex-1 296×38 \"Search the map\"", | |
| 2628 | + "button.inline-flex.h-8.items-center 32×32 \"Filters\"", | |
| 2629 | + "button.inline-flex.h-8.items-center 93×32 \"Legend\"" | |
| 2630 | + ], | |
| 2631 | + "smallCount": 11, | |
| 2632 | + "h1Covered": false, | |
| 2633 | + "h1Top": null, | |
| 2634 | + "headerBottom": 49, | |
| 2635 | + "lang": "en", | |
| 2636 | + "metaDesc": 127, | |
| 2637 | + "h1Count": 0, | |
| 2638 | + "skipped": [], | |
| 2639 | + "unnamed": [], | |
| 2640 | + "ariaCurrent": 2, | |
| 2641 | + "footerOk": false, | |
| 2642 | + "contrast": [], | |
| 2643 | + "contrastChecked": 4, | |
| 2644 | + "height": 845, | |
| 2645 | + "title": "Global data center map · DataCenterIndex", | |
| 2646 | + "map": { | |
| 2647 | + "controls": 3, | |
| 2648 | + "unreachable": 0, | |
| 2649 | + "canvas": true | |
| 2650 | + }, | |
| 2651 | + "errors": [], | |
| 2652 | + "badResponses": [] | |
| 2653 | + }, | |
| 2654 | + { | |
| 2655 | + "name": "search", | |
| 2656 | + "path": "/search?q=montreal", | |
| 2657 | + "w": 390, | |
| 2658 | + "theme": "dark", | |
| 2659 | + "status": 200, | |
| 2660 | + "ttfb": 4, | |
| 2661 | + "overflow": null, | |
| 2662 | + "clipped": [ | |
| 2663 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2664 | + ], | |
| 2665 | + "small": [ | |
| 2666 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2667 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2668 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2669 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2670 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2671 | + "a.inline-flex.items-center.justify-center 122×32 \"Open in map\"", | |
| 2672 | + "a.ml-1.text-ink-2.underline 85×17 \"Apply as filters\"", | |
| 2673 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 2674 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 2675 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 2676 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 2677 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"" | |
| 2678 | + ], | |
| 2679 | + "smallCount": 22, | |
| 2680 | + "h1Covered": false, | |
| 2681 | + "h1Top": 90, | |
| 2682 | + "headerBottom": 49, | |
| 2683 | + "lang": "en", | |
| 2684 | + "metaDesc": 159, | |
| 2685 | + "h1Count": 1, | |
| 2686 | + "skipped": [], | |
| 2687 | + "unnamed": [], | |
| 2688 | + "ariaCurrent": 0, | |
| 2689 | + "footerOk": true, | |
| 2690 | + "contrast": [], | |
| 2691 | + "contrastChecked": 46, | |
| 2692 | + "height": 1871, | |
| 2693 | + "title": "“montreal” — search · DataCenterIndex", | |
| 2694 | + "bottom": { | |
| 2695 | + "navVisible": true, | |
| 2696 | + "covered": false, | |
| 2697 | + "gap": 810 | |
| 2698 | + }, | |
| 2699 | + "errors": [], | |
| 2700 | + "badResponses": [] | |
| 2701 | + }, | |
| 2702 | + { | |
| 2703 | + "name": "live", | |
| 2704 | + "path": "/live", | |
| 2705 | + "w": 390, | |
| 2706 | + "theme": "dark", | |
| 2707 | + "status": 200, | |
| 2708 | + "ttfb": 9, | |
| 2709 | + "overflow": null, | |
| 2710 | + "clipped": [ | |
| 2711 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2712 | + "span.sr-only \"United States\" 89>1", | |
| 2713 | + "span.sr-only \"United States\" 89>1", | |
| 2714 | + "span.sr-only \"United States\" 89>1", | |
| 2715 | + "span.sr-only \"United States\" 89>1", | |
| 2716 | + "span.sr-only \"United States\" 89>1", | |
| 2717 | + "span.sr-only \"United States\" 89>1", | |
| 2718 | + "span.sr-only \"United States\" 89>1", | |
| 2719 | + "span.sr-only \"United States\" 89>1" | |
| 2720 | + ], | |
| 2721 | + "small": [ | |
| 2722 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2723 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2724 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2725 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2726 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2727 | + "button.inline-flex.items-center.justify-center 80×32 \"Filters\"", | |
| 2728 | + "a.inline-flex.items-center.gap-1 181×17 \"Duke Energy — news relea\"", | |
| 2729 | + "a.inline-flex.items-center.gap-1 245×17 \"American Electric Power \"", | |
| 2730 | + "a.hover:text-ink 85×17 \"Element Critical\"", | |
| 2731 | + "a.inline-flex.items-center.gap-1 101×17 \"Element Critical\"", | |
| 2732 | + "a.hover:text-ink 85×17 \"Element Critical\"", | |
| 2733 | + "a.inline-flex.items-center.gap-1 101×17 \"Element Critical\"" | |
| 2734 | + ], | |
| 2735 | + "smallCount": 120, | |
| 2736 | + "h1Covered": false, | |
| 2737 | + "h1Top": 91, | |
| 2738 | + "headerBottom": 49, | |
| 2739 | + "lang": "en", | |
| 2740 | + "metaDesc": 152, | |
| 2741 | + "h1Count": 1, | |
| 2742 | + "skipped": [ | |
| 2743 | + "h1→h3" | |
| 2744 | + ], | |
| 2745 | + "unnamed": [], | |
| 2746 | + "ariaCurrent": 1, | |
| 2747 | + "footerOk": true, | |
| 2748 | + "contrast": [], | |
| 2749 | + "contrastChecked": 506, | |
| 2750 | + "height": 10195, | |
| 2751 | + "title": "Live changes · DataCenterIndex", | |
| 2752 | + "bottom": { | |
| 2753 | + "navVisible": true, | |
| 2754 | + "covered": false, | |
| 2755 | + "gap": 809 | |
| 2756 | + }, | |
| 2757 | + "errors": [], | |
| 2758 | + "badResponses": [] | |
| 2759 | + }, | |
| 2760 | + { | |
| 2761 | + "name": "datacenters", | |
| 2762 | + "path": "/datacenters", | |
| 2763 | + "w": 390, | |
| 2764 | + "theme": "dark", | |
| 2765 | + "status": 200, | |
| 2766 | + "ttfb": 12, | |
| 2767 | + "overflow": null, | |
| 2768 | + "clipped": [ | |
| 2769 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 2770 | + ], | |
| 2771 | + "small": [ | |
| 2772 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2773 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2774 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2775 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2776 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2777 | + "a.inline-flex.items-center.justify-center 122×32 \"Open in map\"", | |
| 2778 | + "button.inline-flex.items-center.justify-center 80×32 \"Filters\"", | |
| 2779 | + "a.font-medium.text-ink.hover:text-accent 186×18 \"Bulk N01 Data Center Cam\"", | |
| 2780 | + "a.font-medium.text-ink.hover:text-accent 225×35 \"Downtown Minneapolis Dat\"", | |
| 2781 | + "a.font-medium.text-ink.hover:text-accent 225×35 \"Dallas Love Field Data C\"", | |
| 2782 | + "a.font-medium.text-ink.hover:text-accent 139×18 \"CloudHQ SAT Campus\"", | |
| 2783 | + "a.font-medium.text-ink.hover:text-accent 183×18 \"Skybox PowerCampus Austi\"" | |
| 2784 | + ], | |
| 2785 | + "smallCount": 33, | |
| 2786 | + "h1Covered": false, | |
| 2787 | + "h1Top": 90, | |
| 2788 | + "headerBottom": 49, | |
| 2789 | + "lang": "en", | |
| 2790 | + "metaDesc": 119, | |
| 2791 | + "h1Count": 1, | |
| 2792 | + "skipped": [], | |
| 2793 | + "unnamed": [], | |
| 2794 | + "ariaCurrent": 2, | |
| 2795 | + "footerOk": true, | |
| 2796 | + "contrast": [], | |
| 2797 | + "contrastChecked": 98, | |
| 2798 | + "height": 2286, | |
| 2799 | + "title": "Data centers · DataCenterIndex", | |
| 2800 | + "bottom": { | |
| 2801 | + "navVisible": true, | |
| 2802 | + "covered": false, | |
| 2803 | + "gap": 810 | |
| 2804 | + }, | |
| 2805 | + "errors": [], | |
| 2806 | + "badResponses": [] | |
| 2807 | + }, | |
| 2808 | + { | |
| 2809 | + "name": "fac-operator-mw", | |
| 2810 | + "path": "/datacenters/databank-downtown-minneapolis-data-center-msp4", | |
| 2811 | + "w": 390, | |
| 2812 | + "theme": "dark", | |
| 2813 | + "status": 200, | |
| 2814 | + "ttfb": 10, | |
| 2815 | + "overflow": null, | |
| 2816 | + "clipped": [ | |
| 2817 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2818 | + "span.sr-only \"United States\" 86>1", | |
| 2819 | + "span.sr-only \"United States\" 82>1", | |
| 2820 | + "span.sr-only \"United States\" 89>1" | |
| 2821 | + ], | |
| 2822 | + "small": [ | |
| 2823 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2824 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2825 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2826 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2827 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2828 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 2829 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 2830 | + "a.hover:text-ink.hover:underline 117×17 \"Minneapolis–St. Paul\"", | |
| 2831 | + "a.normal-case.tracking-normal.text-[12.5px] 57×18 \"DataBank\"", | |
| 2832 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 2833 | + "a.hover:text-accent.hover:underline 180×20 \"Minneapolis–St. Paul mar\"", | |
| 2834 | + "a.hover:text-accent.hover:underline 86×20 \"United States\"" | |
| 2835 | + ], | |
| 2836 | + "smallCount": 65, | |
| 2837 | + "h1Covered": false, | |
| 2838 | + "h1Top": 119, | |
| 2839 | + "headerBottom": 49, | |
| 2840 | + "lang": "en", | |
| 2841 | + "metaDesc": 212, | |
| 2842 | + "h1Count": 1, | |
| 2843 | + "skipped": [], | |
| 2844 | + "unnamed": [], | |
| 2845 | + "ariaCurrent": 1, | |
| 2846 | + "footerOk": true, | |
| 2847 | + "contrast": [ | |
| 2848 | + { | |
| 2849 | + "ratio": 3.07, | |
| 2850 | + "color": "rgb(91, 97, 104)", | |
| 2851 | + "bg": "rgb(11,15,20)", | |
| 2852 | + "sample": "span.figure.text-[11px].text-ink-4 \"16\"", | |
| 2853 | + "n": 2 | |
| 2854 | + }, | |
| 2855 | + { | |
| 2856 | + "ratio": 3.07, | |
| 2857 | + "color": "rgb(91, 97, 104)", | |
| 2858 | + "bg": "rgb(11,15,20)", | |
| 2859 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"High confidence overall — comp\"", | |
| 2860 | + "n": 1 | |
| 2861 | + }, | |
| 2862 | + { | |
| 2863 | + "ratio": 3.07, | |
| 2864 | + "color": "rgb(91, 97, 104)", | |
| 2865 | + "bg": "rgb(11,15,20)", | |
| 2866 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 2867 | + "n": 2 | |
| 2868 | + }, | |
| 2869 | + { | |
| 2870 | + "ratio": 3.07, | |
| 2871 | + "color": "rgb(91, 97, 104)", | |
| 2872 | + "bg": "rgb(11,15,20)", | |
| 2873 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Operator\"", | |
| 2874 | + "n": 1 | |
| 2875 | + }, | |
| 2876 | + { | |
| 2877 | + "ratio": 3.07, | |
| 2878 | + "color": "rgb(91, 97, 104)", | |
| 2879 | + "bg": "rgb(11,15,20)", | |
| 2880 | + "sample": "span.text-ink-4 \"· Publicly accessible operator\"", | |
| 2881 | + "n": 1 | |
| 2882 | + }, | |
| 2883 | + { | |
| 2884 | + "ratio": 3.07, | |
| 2885 | + "color": "rgb(91, 97, 104)", | |
| 2886 | + "bg": "rgb(11,15,20)", | |
| 2887 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 2888 | + "n": 1 | |
| 2889 | + } | |
| 2890 | + ], | |
| 2891 | + "contrastChecked": 286, | |
| 2892 | + "height": 6136, | |
| 2893 | + "title": "Downtown Minneapolis Data Center (MSP4) (Minneapolis, US) · DataCenterIndex", | |
| 2894 | + "bottom": { | |
| 2895 | + "navVisible": true, | |
| 2896 | + "covered": false, | |
| 2897 | + "gap": 1106 | |
| 2898 | + }, | |
| 2899 | + "errors": [ | |
| 2900 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 2901 | + ], | |
| 2902 | + "badResponses": [ | |
| 2903 | + "404 fetch /api/v1/docs?_rsc=LHzgujRx9VSP7Y_f" | |
| 2904 | + ] | |
| 2905 | + }, | |
| 2906 | + { | |
| 2907 | + "name": "fac-osm", | |
| 2908 | + "path": "/datacenters/shelter-modernizacion", | |
| 2909 | + "w": 390, | |
| 2910 | + "theme": "dark", | |
| 2911 | + "status": 200, | |
| 2912 | + "ttfb": 8, | |
| 2913 | + "overflow": null, | |
| 2914 | + "clipped": [ | |
| 2915 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 2916 | + "span.sr-only \"Argentina\" 62>1", | |
| 2917 | + "span.sr-only \"Argentina\" 59>1", | |
| 2918 | + "span.sr-only \"Argentina\" 64>1" | |
| 2919 | + ], | |
| 2920 | + "small": [ | |
| 2921 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 2922 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 2923 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 2924 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 2925 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 2926 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 2927 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 2928 | + "a.hover:text-ink.hover:underline 55×17 \"Argentina\"", | |
| 2929 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 2930 | + "a.hover:text-accent.hover:underline 62×20 \"Argentina\"", | |
| 2931 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 2932 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 2933 | + ], | |
| 2934 | + "smallCount": 46, | |
| 2935 | + "h1Covered": false, | |
| 2936 | + "h1Top": 116, | |
| 2937 | + "headerBottom": 49, | |
| 2938 | + "lang": "en", | |
| 2939 | + "metaDesc": 128, | |
| 2940 | + "h1Count": 1, | |
| 2941 | + "skipped": [], | |
| 2942 | + "unnamed": [], | |
| 2943 | + "ariaCurrent": 1, | |
| 2944 | + "footerOk": true, | |
| 2945 | + "contrast": [ | |
| 2946 | + { | |
| 2947 | + "ratio": 3.07, | |
| 2948 | + "color": "rgb(91, 97, 104)", | |
| 2949 | + "bg": "rgb(11,15,20)", | |
| 2950 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Unverified overall — computed \"", | |
| 2951 | + "n": 1 | |
| 2952 | + }, | |
| 2953 | + { | |
| 2954 | + "ratio": 3.07, | |
| 2955 | + "color": "rgb(91, 97, 104)", | |
| 2956 | + "bg": "rgb(11,15,20)", | |
| 2957 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 2958 | + "n": 2 | |
| 2959 | + }, | |
| 2960 | + { | |
| 2961 | + "ratio": 3.07, | |
| 2962 | + "color": "rgb(91, 97, 104)", | |
| 2963 | + "bg": "rgb(11,15,20)", | |
| 2964 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Community\"", | |
| 2965 | + "n": 1 | |
| 2966 | + }, | |
| 2967 | + { | |
| 2968 | + "ratio": 3.07, | |
| 2969 | + "color": "rgb(91, 97, 104)", | |
| 2970 | + "bg": "rgb(11,15,20)", | |
| 2971 | + "sample": "span.text-ink-4 \"· ODbL 1.0 — © OpenStreetMap c\"", | |
| 2972 | + "n": 1 | |
| 2973 | + }, | |
| 2974 | + { | |
| 2975 | + "ratio": 3.07, | |
| 2976 | + "color": "rgb(91, 97, 104)", | |
| 2977 | + "bg": "rgb(11,15,20)", | |
| 2978 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 2979 | + "n": 1 | |
| 2980 | + } | |
| 2981 | + ], | |
| 2982 | + "contrastChecked": 179, | |
| 2983 | + "height": 4626, | |
| 2984 | + "title": "Shelter Modernización · DataCenterIndex", | |
| 2985 | + "bottom": { | |
| 2986 | + "navVisible": true, | |
| 2987 | + "covered": false, | |
| 2988 | + "gap": 1106 | |
| 2989 | + }, | |
| 2990 | + "errors": [ | |
| 2991 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 2992 | + ], | |
| 2993 | + "badResponses": [ | |
| 2994 | + "404 fetch /api/v1/docs?_rsc=QORYKIJc7fb0QGjs" | |
| 2995 | + ] | |
| 2996 | + }, | |
| 2997 | + { | |
| 2998 | + "name": "fac-google", | |
| 2999 | + "path": "/datacenters/google-the-dalles-oregon-data-center", | |
| 3000 | + "w": 390, | |
| 3001 | + "theme": "dark", | |
| 3002 | + "status": 200, | |
| 3003 | + "ttfb": 13, | |
| 3004 | + "overflow": null, | |
| 3005 | + "clipped": [ | |
| 3006 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3007 | + "span.sr-only \"United States\" 86>1", | |
| 3008 | + "span.sr-only \"United States\" 82>1", | |
| 3009 | + "span.sr-only \"United States\" 89>1", | |
| 3010 | + "span.sr-only \"United States\" 89>1", | |
| 3011 | + "span.sr-only \"United States\" 89>1", | |
| 3012 | + "span.sr-only \"United States\" 89>1" | |
| 3013 | + ], | |
| 3014 | + "small": [ | |
| 3015 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3016 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3017 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3018 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3019 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3020 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3021 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 3022 | + "a.hover:text-ink.hover:underline 109×17 \"Hillsboro / Portland\"", | |
| 3023 | + "a.normal-case.tracking-normal.text-[12.5px] 43×18 \"Google\"", | |
| 3024 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (2 sources)\"", | |
| 3025 | + "a.hover:text-accent.hover:underline 170×20 \"Hillsboro / Portland mar\"", | |
| 3026 | + "a.hover:text-accent.hover:underline 86×20 \"United States\"" | |
| 3027 | + ], | |
| 3028 | + "smallCount": 101, | |
| 3029 | + "h1Covered": false, | |
| 3030 | + "h1Top": 119, | |
| 3031 | + "headerBottom": 49, | |
| 3032 | + "lang": "en", | |
| 3033 | + "metaDesc": 203, | |
| 3034 | + "h1Count": 1, | |
| 3035 | + "skipped": [], | |
| 3036 | + "unnamed": [], | |
| 3037 | + "ariaCurrent": 1, | |
| 3038 | + "footerOk": true, | |
| 3039 | + "contrast": [ | |
| 3040 | + { | |
| 3041 | + "ratio": 3.07, | |
| 3042 | + "color": "rgb(91, 97, 104)", | |
| 3043 | + "bg": "rgb(11,15,20)", | |
| 3044 | + "sample": "span.figure.text-[11px].text-ink-4 \"26\"", | |
| 3045 | + "n": 2 | |
| 3046 | + }, | |
| 3047 | + { | |
| 3048 | + "ratio": 3.07, | |
| 3049 | + "color": "rgb(91, 97, 104)", | |
| 3050 | + "bg": "rgb(11,15,20)", | |
| 3051 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"High confidence overall — comp\"", | |
| 3052 | + "n": 1 | |
| 3053 | + }, | |
| 3054 | + { | |
| 3055 | + "ratio": 3.07, | |
| 3056 | + "color": "rgb(91, 97, 104)", | |
| 3057 | + "bg": "rgb(11,15,20)", | |
| 3058 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 3059 | + "n": 2 | |
| 3060 | + }, | |
| 3061 | + { | |
| 3062 | + "ratio": 3.07, | |
| 3063 | + "color": "rgb(91, 97, 104)", | |
| 3064 | + "bg": "rgb(11,15,20)", | |
| 3065 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Cloud provider\"", | |
| 3066 | + "n": 2 | |
| 3067 | + }, | |
| 3068 | + { | |
| 3069 | + "ratio": 3.07, | |
| 3070 | + "color": "rgb(91, 97, 104)", | |
| 3071 | + "bg": "rgb(11,15,20)", | |
| 3072 | + "sample": "span.text-ink-4 \"· Publicly published operator \"", | |
| 3073 | + "n": 2 | |
| 3074 | + }, | |
| 3075 | + { | |
| 3076 | + "ratio": 3.07, | |
| 3077 | + "color": "rgb(91, 97, 104)", | |
| 3078 | + "bg": "rgb(11,15,20)", | |
| 3079 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 3080 | + "n": 1 | |
| 3081 | + } | |
| 3082 | + ], | |
| 3083 | + "contrastChecked": 502, | |
| 3084 | + "height": 10760, | |
| 3085 | + "title": "Google The Dalles, Oregon data center (The Dalles, US) · DataCenterIndex", | |
| 3086 | + "bottom": { | |
| 3087 | + "navVisible": true, | |
| 3088 | + "covered": false, | |
| 3089 | + "gap": 1106 | |
| 3090 | + }, | |
| 3091 | + "errors": [], | |
| 3092 | + "badResponses": [] | |
| 3093 | + }, | |
| 3094 | + { | |
| 3095 | + "name": "fac-wikidata", | |
| 3096 | + "path": "/datacenters/foulum-data-center", | |
| 3097 | + "w": 390, | |
| 3098 | + "theme": "dark", | |
| 3099 | + "status": 200, | |
| 3100 | + "ttfb": 12, | |
| 3101 | + "overflow": null, | |
| 3102 | + "clipped": [ | |
| 3103 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3104 | + "span.sr-only \"Denmark\" 58>1", | |
| 3105 | + "span.sr-only \"Denmark\" 56>1", | |
| 3106 | + "span.sr-only \"Denmark\" 60>1" | |
| 3107 | + ], | |
| 3108 | + "small": [ | |
| 3109 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3110 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3111 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3112 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3113 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3114 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3115 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 3116 | + "a.hover:text-ink.hover:underline 51×17 \"Denmark\"", | |
| 3117 | + "a.normal-case.tracking-normal.text-[12.5px] 35×18 \"Apple\"", | |
| 3118 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (2 sources)\"", | |
| 3119 | + "a.hover:text-accent.hover:underline 58×20 \"Denmark\"", | |
| 3120 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 3121 | + ], | |
| 3122 | + "smallCount": 57, | |
| 3123 | + "h1Covered": false, | |
| 3124 | + "h1Top": 119, | |
| 3125 | + "headerBottom": 49, | |
| 3126 | + "lang": "en", | |
| 3127 | + "metaDesc": 158, | |
| 3128 | + "h1Count": 1, | |
| 3129 | + "skipped": [], | |
| 3130 | + "unnamed": [], | |
| 3131 | + "ariaCurrent": 1, | |
| 3132 | + "footerOk": true, | |
| 3133 | + "contrast": [ | |
| 3134 | + { | |
| 3135 | + "ratio": 3.07, | |
| 3136 | + "color": "rgb(91, 97, 104)", | |
| 3137 | + "bg": "rgb(11,15,20)", | |
| 3138 | + "sample": "span.figure.text-[11px].text-ink-4 \"15\"", | |
| 3139 | + "n": 2 | |
| 3140 | + }, | |
| 3141 | + { | |
| 3142 | + "ratio": 3.07, | |
| 3143 | + "color": "rgb(91, 97, 104)", | |
| 3144 | + "bg": "rgb(11,15,20)", | |
| 3145 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Moderate confidence overall — \"", | |
| 3146 | + "n": 1 | |
| 3147 | + }, | |
| 3148 | + { | |
| 3149 | + "ratio": 3.07, | |
| 3150 | + "color": "rgb(91, 97, 104)", | |
| 3151 | + "bg": "rgb(11,15,20)", | |
| 3152 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 3153 | + "n": 2 | |
| 3154 | + }, | |
| 3155 | + { | |
| 3156 | + "ratio": 3.07, | |
| 3157 | + "color": "rgb(91, 97, 104)", | |
| 3158 | + "bg": "rgb(11,15,20)", | |
| 3159 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Community\"", | |
| 3160 | + "n": 2 | |
| 3161 | + }, | |
| 3162 | + { | |
| 3163 | + "ratio": 3.07, | |
| 3164 | + "color": "rgb(91, 97, 104)", | |
| 3165 | + "bg": "rgb(11,15,20)", | |
| 3166 | + "sample": "span.text-ink-4 \"· ODbL 1.0 — © OpenStreetMap c\"", | |
| 3167 | + "n": 2 | |
| 3168 | + }, | |
| 3169 | + { | |
| 3170 | + "ratio": 3.07, | |
| 3171 | + "color": "rgb(91, 97, 104)", | |
| 3172 | + "bg": "rgb(11,15,20)", | |
| 3173 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 3174 | + "n": 1 | |
| 3175 | + } | |
| 3176 | + ], | |
| 3177 | + "contrastChecked": 273, | |
| 3178 | + "height": 6042, | |
| 3179 | + "title": "Foulum Data Center (Viborg Municipality, DK) · DataCenterIndex", | |
| 3180 | + "bottom": { | |
| 3181 | + "navVisible": true, | |
| 3182 | + "covered": false, | |
| 3183 | + "gap": 1105 | |
| 3184 | + }, | |
| 3185 | + "errors": [], | |
| 3186 | + "badResponses": [] | |
| 3187 | + }, | |
| 3188 | + { | |
| 3189 | + "name": "operators", | |
| 3190 | + "path": "/operators", | |
| 3191 | + "w": 390, | |
| 3192 | + "theme": "dark", | |
| 3193 | + "status": 200, | |
| 3194 | + "ttfb": 10, | |
| 3195 | + "overflow": null, | |
| 3196 | + "clipped": [ | |
| 3197 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3198 | + "span.sr-only \"Search operators\" 114>1" | |
| 3199 | + ], | |
| 3200 | + "small": [ | |
| 3201 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3202 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3203 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3204 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3205 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3206 | + "input.h-9.min-w-0.rounded-sm 218×36 \"\"", | |
| 3207 | + "select.h-9.min-w-0.rounded-sm 140×36 \"Kind\"", | |
| 3208 | + "select.h-9.min-w-0.rounded-sm 160×36 \"Headquarters country\"", | |
| 3209 | + "button.inline-flex.items-center.justify-center 59×36 \"Filter\"", | |
| 3210 | + "a.font-medium.text-ink.hover:text-accent 60×18 \"DataBank\"", | |
| 3211 | + "a.font-medium.text-ink.hover:text-accent 81×18 \"EdgeConneX\"", | |
| 3212 | + "a.font-medium.text-ink.hover:text-accent 62×18 \"CyrusOne\"" | |
| 3213 | + ], | |
| 3214 | + "smallCount": 75, | |
| 3215 | + "h1Covered": false, | |
| 3216 | + "h1Top": 90, | |
| 3217 | + "headerBottom": 49, | |
| 3218 | + "lang": "en", | |
| 3219 | + "metaDesc": 139, | |
| 3220 | + "h1Count": 1, | |
| 3221 | + "skipped": [], | |
| 3222 | + "unnamed": [], | |
| 3223 | + "ariaCurrent": 2, | |
| 3224 | + "footerOk": true, | |
| 3225 | + "contrast": [], | |
| 3226 | + "contrastChecked": 268, | |
| 3227 | + "height": 4954, | |
| 3228 | + "title": "Operators · DataCenterIndex", | |
| 3229 | + "bottom": { | |
| 3230 | + "navVisible": true, | |
| 3231 | + "covered": false, | |
| 3232 | + "gap": 810 | |
| 3233 | + }, | |
| 3234 | + "errors": [ | |
| 3235 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3236 | + ], | |
| 3237 | + "badResponses": [ | |
| 3238 | + "404 fetch /api/v1/docs?_rsc=nAdjiVRfIop78OSR" | |
| 3239 | + ] | |
| 3240 | + }, | |
| 3241 | + { | |
| 3242 | + "name": "operator-equinix", | |
| 3243 | + "path": "/operators/equinix", | |
| 3244 | + "w": 390, | |
| 3245 | + "theme": "dark", | |
| 3246 | + "status": 200, | |
| 3247 | + "ttfb": 14, | |
| 3248 | + "overflow": null, | |
| 3249 | + "clipped": [ | |
| 3250 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3251 | + "span.sr-only \"Finland\" 45>1", | |
| 3252 | + "span.sr-only \"Germany\" 56>1", | |
| 3253 | + "span.sr-only \"Italy\" 26>1", | |
| 3254 | + "span.sr-only \"Switzerland\" 72>1", | |
| 3255 | + "span.sr-only \"United States\" 82>1", | |
| 3256 | + "span.sr-only \"Australia\" 54>1", | |
| 3257 | + "span.sr-only \"Ireland\" 42>1", | |
| 3258 | + "span.sr-only \"Netherlands\" 75>1" | |
| 3259 | + ], | |
| 3260 | + "small": [ | |
| 3261 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3262 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3263 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3264 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3265 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3266 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3267 | + "a.hover:text-ink.hover:underline 57×17 \"Operators\"", | |
| 3268 | + "a.inline-flex.items-center.gap-1.5 106×19 \"🇺🇸United States\"", | |
| 3269 | + "a.break-all.text-ink.underline 75×19 \"equinix.com\"", | |
| 3270 | + "a.block 366×36 \"1🇫🇮FinlandFinland2\"", | |
| 3271 | + "a.block 366×36 \"2🇩🇪GermanyGermany2\"", | |
| 3272 | + "a.block 366×36 \"3🇮🇹ItalyItaly2\"" | |
| 3273 | + ], | |
| 3274 | + "smallCount": 150, | |
| 3275 | + "h1Covered": false, | |
| 3276 | + "h1Top": 122, | |
| 3277 | + "headerBottom": 49, | |
| 3278 | + "lang": "en", | |
| 3279 | + "metaDesc": 170, | |
| 3280 | + "h1Count": 1, | |
| 3281 | + "skipped": [], | |
| 3282 | + "unnamed": [], | |
| 3283 | + "ariaCurrent": 2, | |
| 3284 | + "footerOk": true, | |
| 3285 | + "contrast": [ | |
| 3286 | + { | |
| 3287 | + "ratio": 3.07, | |
| 3288 | + "color": "rgb(91, 97, 104)", | |
| 3289 | + "bg": "rgb(11,15,20)", | |
| 3290 | + "sample": "span.figure.text-[11px].text-ink-4 \"25\"", | |
| 3291 | + "n": 5 | |
| 3292 | + }, | |
| 3293 | + { | |
| 3294 | + "ratio": 3.07, | |
| 3295 | + "color": "rgb(91, 97, 104)", | |
| 3296 | + "bg": "rgb(11,15,20)", | |
| 3297 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 3298 | + "n": 1 | |
| 3299 | + }, | |
| 3300 | + { | |
| 3301 | + "ratio": 3.07, | |
| 3302 | + "color": "rgb(91, 97, 104)", | |
| 3303 | + "bg": "rgb(11,15,20)", | |
| 3304 | + "sample": "span.figure.text-ink-4 \"93%\"", | |
| 3305 | + "n": 1 | |
| 3306 | + }, | |
| 3307 | + { | |
| 3308 | + "ratio": 3.07, | |
| 3309 | + "color": "rgb(91, 97, 104)", | |
| 3310 | + "bg": "rgb(11,15,20)", | |
| 3311 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Operator\"", | |
| 3312 | + "n": 10 | |
| 3313 | + }, | |
| 3314 | + { | |
| 3315 | + "ratio": 3.07, | |
| 3316 | + "color": "rgb(91, 97, 104)", | |
| 3317 | + "bg": "rgb(11,15,20)", | |
| 3318 | + "sample": "span.text-ink-4 \"· Publicly accessible operator\"", | |
| 3319 | + "n": 10 | |
| 3320 | + }, | |
| 3321 | + { | |
| 3322 | + "ratio": 3.07, | |
| 3323 | + "color": "rgb(91, 97, 104)", | |
| 3324 | + "bg": "rgb(11,15,20)", | |
| 3325 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 3326 | + "n": 1 | |
| 3327 | + }, | |
| 3328 | + { | |
| 3329 | + "ratio": 3.07, | |
| 3330 | + "color": "rgb(91, 97, 104)", | |
| 3331 | + "bg": "rgb(11,15,20)", | |
| 3332 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3333 | + "n": 1 | |
| 3334 | + } | |
| 3335 | + ], | |
| 3336 | + "contrastChecked": 619, | |
| 3337 | + "height": 10329, | |
| 3338 | + "title": "Equinix · DataCenterIndex", | |
| 3339 | + "bottom": { | |
| 3340 | + "navVisible": true, | |
| 3341 | + "covered": false, | |
| 3342 | + "gap": 1073 | |
| 3343 | + }, | |
| 3344 | + "errors": [], | |
| 3345 | + "badResponses": [] | |
| 3346 | + }, | |
| 3347 | + { | |
| 3348 | + "name": "countries", | |
| 3349 | + "path": "/countries", | |
| 3350 | + "w": 390, | |
| 3351 | + "theme": "dark", | |
| 3352 | + "status": 200, | |
| 3353 | + "ttfb": 17, | |
| 3354 | + "overflow": null, | |
| 3355 | + "clipped": [ | |
| 3356 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3357 | + "span.sr-only \"United States\" 83>1", | |
| 3358 | + "span.sr-only \"Brazil\" 35>1", | |
| 3359 | + "span.sr-only \"Argentina\" 60>1", | |
| 3360 | + "span.sr-only \"Canada\" 48>1", | |
| 3361 | + "span.sr-only \"Mexico\" 45>1", | |
| 3362 | + "span.sr-only \"Chile\" 32>1", | |
| 3363 | + "span.sr-only \"Venezuela\" 65>1", | |
| 3364 | + "span.sr-only \"Peru\" 29>1" | |
| 3365 | + ], | |
| 3366 | + "small": [ | |
| 3367 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3368 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3369 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3370 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3371 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3372 | + "a.inline-flex.h-7.shrink-0 78×28 \"All regions\"", | |
| 3373 | + "a.inline-flex.h-7.shrink-0 90×28 \"Americas18\"", | |
| 3374 | + "a.inline-flex.h-7.shrink-0 77×28 \"Europe38\"", | |
| 3375 | + "a.inline-flex.h-7.shrink-0 61×28 \"Asia34\"", | |
| 3376 | + "a.inline-flex.h-7.shrink-0 77×28 \"Oceania3\"", | |
| 3377 | + "a.inline-flex.h-7.shrink-0 71×28 \"Africa12\"", | |
| 3378 | + "a.font-medium.text-ink.hover:text-accent 109×18 \"🇺🇸United StatesUnited \"" | |
| 3379 | + ], | |
| 3380 | + "smallCount": 71, | |
| 3381 | + "h1Covered": false, | |
| 3382 | + "h1Top": 90, | |
| 3383 | + "headerBottom": 49, | |
| 3384 | + "lang": "en", | |
| 3385 | + "metaDesc": 117, | |
| 3386 | + "h1Count": 1, | |
| 3387 | + "skipped": [], | |
| 3388 | + "unnamed": [], | |
| 3389 | + "ariaCurrent": 2, | |
| 3390 | + "footerOk": true, | |
| 3391 | + "contrast": [ | |
| 3392 | + { | |
| 3393 | + "ratio": 3.07, | |
| 3394 | + "color": "rgb(91, 97, 104)", | |
| 3395 | + "bg": "rgb(11,15,20)", | |
| 3396 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Per-million uses the latest Wo\"", | |
| 3397 | + "n": 1 | |
| 3398 | + }, | |
| 3399 | + { | |
| 3400 | + "ratio": 3.07, | |
| 3401 | + "color": "rgb(91, 97, 104)", | |
| 3402 | + "bg": "rgb(11,15,20)", | |
| 3403 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"methodology\"", | |
| 3404 | + "n": 1 | |
| 3405 | + } | |
| 3406 | + ], | |
| 3407 | + "contrastChecked": 304, | |
| 3408 | + "height": 5206, | |
| 3409 | + "title": "Countries · DataCenterIndex", | |
| 3410 | + "bottom": { | |
| 3411 | + "navVisible": true, | |
| 3412 | + "covered": false, | |
| 3413 | + "gap": 810 | |
| 3414 | + }, | |
| 3415 | + "errors": [ | |
| 3416 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3417 | + ], | |
| 3418 | + "badResponses": [ | |
| 3419 | + "404 fetch /api/v1/docs?_rsc=rWPI48G8kG2iJ9dR" | |
| 3420 | + ] | |
| 3421 | + }, | |
| 3422 | + { | |
| 3423 | + "name": "country-us", | |
| 3424 | + "path": "/countries/united-states", | |
| 3425 | + "w": 390, | |
| 3426 | + "theme": "dark", | |
| 3427 | + "status": 200, | |
| 3428 | + "ttfb": 23, | |
| 3429 | + "overflow": null, | |
| 3430 | + "clipped": [ | |
| 3431 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3432 | + "span.sr-only \"United States\" 149>1", | |
| 3433 | + "span.sr-only \"United States\" 76>1", | |
| 3434 | + "span.sr-only \"United States\" 76>1", | |
| 3435 | + "span.sr-only \"United States\" 76>1", | |
| 3436 | + "span.sr-only \"United States\" 76>1", | |
| 3437 | + "span.sr-only \"United States\" 76>1", | |
| 3438 | + "span.sr-only \"United States\" 76>1", | |
| 3439 | + "span.sr-only \"United States\" 76>1" | |
| 3440 | + ], | |
| 3441 | + "small": [ | |
| 3442 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3443 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3444 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3445 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3446 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3447 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3448 | + "a.hover:text-ink.hover:underline 55×17 \"Countries\"", | |
| 3449 | + "a.inline-flex.h-7.shrink-0 211×28 \"#1/46Countries by cloud \"", | |
| 3450 | + "a.inline-flex.h-7.shrink-0 214×28 \"#1/103Countries by facil\"", | |
| 3451 | + "a.inline-flex.h-7.shrink-0 233×28 \"#24/52Facilities per mil\"", | |
| 3452 | + "a.inline-flex.h-7.shrink-0 338×28 \"#14/14Countries by facil\"", | |
| 3453 | + "a.inline-flex.h-7.shrink-0 247×28 \"#1/28Countries by hypers\"" | |
| 3454 | + ], | |
| 3455 | + "smallCount": 106, | |
| 3456 | + "h1Covered": false, | |
| 3457 | + "h1Top": 120, | |
| 3458 | + "headerBottom": 49, | |
| 3459 | + "lang": "en", | |
| 3460 | + "metaDesc": 258, | |
| 3461 | + "h1Count": 1, | |
| 3462 | + "skipped": [], | |
| 3463 | + "unnamed": [], | |
| 3464 | + "ariaCurrent": 2, | |
| 3465 | + "footerOk": true, | |
| 3466 | + "contrast": [ | |
| 3467 | + { | |
| 3468 | + "ratio": 2.91, | |
| 3469 | + "color": "rgb(91, 97, 104)", | |
| 3470 | + "bg": "rgb(16,22,29)", | |
| 3471 | + "sample": "span.text-ink-4 \"/46\"", | |
| 3472 | + "n": 5 | |
| 3473 | + }, | |
| 3474 | + { | |
| 3475 | + "ratio": 3.07, | |
| 3476 | + "color": "rgb(91, 97, 104)", | |
| 3477 | + "bg": "rgb(11,15,20)", | |
| 3478 | + "sample": "span.text-[11px].text-ink-4 \"World Bank\"", | |
| 3479 | + "n": 1 | |
| 3480 | + }, | |
| 3481 | + { | |
| 3482 | + "ratio": 3.07, | |
| 3483 | + "color": "rgb(91, 97, 104)", | |
| 3484 | + "bg": "rgb(11,15,20)", | |
| 3485 | + "sample": "span.figure.text-[11px].text-ink-4 \"460\"", | |
| 3486 | + "n": 9 | |
| 3487 | + }, | |
| 3488 | + { | |
| 3489 | + "ratio": 3.07, | |
| 3490 | + "color": "rgb(91, 97, 104)", | |
| 3491 | + "bg": "rgb(11,15,20)", | |
| 3492 | + "sample": "span.figure.text-ink-4 \"90%\"", | |
| 3493 | + "n": 1 | |
| 3494 | + }, | |
| 3495 | + { | |
| 3496 | + "ratio": 3.07, | |
| 3497 | + "color": "rgb(91, 97, 104)", | |
| 3498 | + "bg": "rgb(11,15,20)", | |
| 3499 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 3500 | + "n": 1 | |
| 3501 | + }, | |
| 3502 | + { | |
| 3503 | + "ratio": 3.07, | |
| 3504 | + "color": "rgb(91, 97, 104)", | |
| 3505 | + "bg": "rgb(11,15,20)", | |
| 3506 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Source: World Bank, World Deve\"", | |
| 3507 | + "n": 1 | |
| 3508 | + }, | |
| 3509 | + { | |
| 3510 | + "ratio": 3.07, | |
| 3511 | + "color": "rgb(91, 97, 104)", | |
| 3512 | + "bg": "rgb(11,15,20)", | |
| 3513 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Country totals are lower bound\"", | |
| 3514 | + "n": 1 | |
| 3515 | + }, | |
| 3516 | + { | |
| 3517 | + "ratio": 3.07, | |
| 3518 | + "color": "rgb(91, 97, 104)", | |
| 3519 | + "bg": "rgb(11,15,20)", | |
| 3520 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3521 | + "n": 1 | |
| 3522 | + } | |
| 3523 | + ], | |
| 3524 | + "contrastChecked": 492, | |
| 3525 | + "height": 9191, | |
| 3526 | + "title": "Data Centers in United States · DataCenterIndex", | |
| 3527 | + "bottom": { | |
| 3528 | + "navVisible": true, | |
| 3529 | + "covered": false, | |
| 3530 | + "gap": 1073 | |
| 3531 | + }, | |
| 3532 | + "errors": [], | |
| 3533 | + "badResponses": [] | |
| 3534 | + }, | |
| 3535 | + { | |
| 3536 | + "name": "country-ca", | |
| 3537 | + "path": "/countries/canada", | |
| 3538 | + "w": 390, | |
| 3539 | + "theme": "dark", | |
| 3540 | + "status": 200, | |
| 3541 | + "ttfb": 12, | |
| 3542 | + "overflow": null, | |
| 3543 | + "clipped": [ | |
| 3544 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3545 | + "span.sr-only \"Canada\" 86>1", | |
| 3546 | + "span.sr-only \"Canada\" 43>1", | |
| 3547 | + "span.sr-only \"Canada\" 43>1", | |
| 3548 | + "span.sr-only \"Canada\" 51>1", | |
| 3549 | + "span.sr-only \"Canada\" 51>1", | |
| 3550 | + "span.sr-only \"Canada\" 51>1", | |
| 3551 | + "span.sr-only \"Canada\" 51>1", | |
| 3552 | + "span.sr-only \"Canada\" 51>1" | |
| 3553 | + ], | |
| 3554 | + "small": [ | |
| 3555 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3556 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3557 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3558 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3559 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3560 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3561 | + "a.hover:text-ink.hover:underline 55×17 \"Countries\"", | |
| 3562 | + "a.inline-flex.h-7.shrink-0 211×28 \"#7/46Countries by cloud \"", | |
| 3563 | + "a.inline-flex.h-7.shrink-0 221×28 \"#22/103Countries by faci\"", | |
| 3564 | + "a.inline-flex.h-7.shrink-0 233×28 \"#33/52Facilities per mil\"", | |
| 3565 | + "a.inline-flex.h-7.shrink-0 254×28 \"#12/28Countries by hyper\"", | |
| 3566 | + "a.block 366×36 \"Unknown15\"" | |
| 3567 | + ], | |
| 3568 | + "smallCount": 80, | |
| 3569 | + "h1Covered": false, | |
| 3570 | + "h1Top": 120, | |
| 3571 | + "headerBottom": 49, | |
| 3572 | + "lang": "en", | |
| 3573 | + "metaDesc": 186, | |
| 3574 | + "h1Count": 1, | |
| 3575 | + "skipped": [], | |
| 3576 | + "unnamed": [], | |
| 3577 | + "ariaCurrent": 2, | |
| 3578 | + "footerOk": true, | |
| 3579 | + "contrast": [ | |
| 3580 | + { | |
| 3581 | + "ratio": 2.91, | |
| 3582 | + "color": "rgb(91, 97, 104)", | |
| 3583 | + "bg": "rgb(16,22,29)", | |
| 3584 | + "sample": "span.text-ink-4 \"/46\"", | |
| 3585 | + "n": 4 | |
| 3586 | + }, | |
| 3587 | + { | |
| 3588 | + "ratio": 3.07, | |
| 3589 | + "color": "rgb(91, 97, 104)", | |
| 3590 | + "bg": "rgb(11,15,20)", | |
| 3591 | + "sample": "span.text-[11px].text-ink-4 \"World Bank\"", | |
| 3592 | + "n": 1 | |
| 3593 | + }, | |
| 3594 | + { | |
| 3595 | + "ratio": 3.07, | |
| 3596 | + "color": "rgb(91, 97, 104)", | |
| 3597 | + "bg": "rgb(11,15,20)", | |
| 3598 | + "sample": "span.figure.text-[11px].text-ink-4 \"21\"", | |
| 3599 | + "n": 5 | |
| 3600 | + }, | |
| 3601 | + { | |
| 3602 | + "ratio": 3.07, | |
| 3603 | + "color": "rgb(91, 97, 104)", | |
| 3604 | + "bg": "rgb(11,15,20)", | |
| 3605 | + "sample": "span.figure.text-ink-4 \"76%\"", | |
| 3606 | + "n": 2 | |
| 3607 | + }, | |
| 3608 | + { | |
| 3609 | + "ratio": 3.07, | |
| 3610 | + "color": "rgb(91, 97, 104)", | |
| 3611 | + "bg": "rgb(11,15,20)", | |
| 3612 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Source: World Bank, World Deve\"", | |
| 3613 | + "n": 1 | |
| 3614 | + }, | |
| 3615 | + { | |
| 3616 | + "ratio": 3.07, | |
| 3617 | + "color": "rgb(91, 97, 104)", | |
| 3618 | + "bg": "rgb(11,15,20)", | |
| 3619 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Country totals are lower bound\"", | |
| 3620 | + "n": 1 | |
| 3621 | + }, | |
| 3622 | + { | |
| 3623 | + "ratio": 3.07, | |
| 3624 | + "color": "rgb(91, 97, 104)", | |
| 3625 | + "bg": "rgb(11,15,20)", | |
| 3626 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3627 | + "n": 1 | |
| 3628 | + } | |
| 3629 | + ], | |
| 3630 | + "contrastChecked": 344, | |
| 3631 | + "height": 7567, | |
| 3632 | + "title": "Data Centers in Canada · DataCenterIndex", | |
| 3633 | + "bottom": { | |
| 3634 | + "navVisible": true, | |
| 3635 | + "covered": false, | |
| 3636 | + "gap": 1073 | |
| 3637 | + }, | |
| 3638 | + "errors": [ | |
| 3639 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3640 | + ], | |
| 3641 | + "badResponses": [ | |
| 3642 | + "404 fetch /api/v1/docs?_rsc=uoChELS4xfChicuP" | |
| 3643 | + ] | |
| 3644 | + }, | |
| 3645 | + { | |
| 3646 | + "name": "markets", | |
| 3647 | + "path": "/datacenters/markets", | |
| 3648 | + "w": 390, | |
| 3649 | + "theme": "dark", | |
| 3650 | + "status": 200, | |
| 3651 | + "ttfb": 13, | |
| 3652 | + "overflow": null, | |
| 3653 | + "clipped": [ | |
| 3654 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 3655 | + ], | |
| 3656 | + "small": [ | |
| 3657 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3658 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3659 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3660 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3661 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3662 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3663 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 3664 | + "a.inline-flex.items-center.justify-center 86×32 \"Open map\"", | |
| 3665 | + "input.h-9.min-w-0.flex-1 366×18 \"Search markets\"", | |
| 3666 | + "select.h-9.min-w-0.flex-1 299×36 \"Country\"", | |
| 3667 | + "button.inline-flex.items-center.justify-center 59×36 \"Filter\"", | |
| 3668 | + "a.inline-flex.size-9.items-center 36×36 \"Next page\"" | |
| 3669 | + ], | |
| 3670 | + "smallCount": 27, | |
| 3671 | + "h1Covered": false, | |
| 3672 | + "h1Top": 120, | |
| 3673 | + "headerBottom": 49, | |
| 3674 | + "lang": "en", | |
| 3675 | + "metaDesc": 125, | |
| 3676 | + "h1Count": 1, | |
| 3677 | + "skipped": [], | |
| 3678 | + "unnamed": [], | |
| 3679 | + "ariaCurrent": 2, | |
| 3680 | + "footerOk": true, | |
| 3681 | + "contrast": [ | |
| 3682 | + { | |
| 3683 | + "ratio": 3.07, | |
| 3684 | + "color": "rgb(91, 97, 104)", | |
| 3685 | + "bg": "rgb(11,15,20)", | |
| 3686 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Facilities are assigned to a m\"", | |
| 3687 | + "n": 1 | |
| 3688 | + }, | |
| 3689 | + { | |
| 3690 | + "ratio": 3.07, | |
| 3691 | + "color": "rgb(91, 97, 104)", | |
| 3692 | + "bg": "rgb(11,15,20)", | |
| 3693 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3694 | + "n": 1 | |
| 3695 | + } | |
| 3696 | + ], | |
| 3697 | + "contrastChecked": 235, | |
| 3698 | + "height": 5208, | |
| 3699 | + "title": "Data center markets · DataCenterIndex", | |
| 3700 | + "bottom": { | |
| 3701 | + "navVisible": true, | |
| 3702 | + "covered": false, | |
| 3703 | + "gap": 810 | |
| 3704 | + }, | |
| 3705 | + "errors": [ | |
| 3706 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3707 | + ], | |
| 3708 | + "badResponses": [ | |
| 3709 | + "404 fetch /api/v1/docs?_rsc=pvP-qw1_z45AIAvk" | |
| 3710 | + ] | |
| 3711 | + }, | |
| 3712 | + { | |
| 3713 | + "name": "market-nova", | |
| 3714 | + "path": "/datacenters/markets/northern-virginia", | |
| 3715 | + "w": 390, | |
| 3716 | + "theme": "dark", | |
| 3717 | + "status": 200, | |
| 3718 | + "ttfb": 11, | |
| 3719 | + "overflow": null, | |
| 3720 | + "clipped": [ | |
| 3721 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3722 | + "span.sr-only \"United States\" 82>1", | |
| 3723 | + "span.sr-only \"United States\" 76>1", | |
| 3724 | + "span.sr-only \"United States\" 89>1", | |
| 3725 | + "span.sr-only \"United States\" 89>1", | |
| 3726 | + "span.sr-only \"United States\" 89>1", | |
| 3727 | + "span.sr-only \"United States\" 89>1", | |
| 3728 | + "span.sr-only \"United States\" 89>1", | |
| 3729 | + "span.sr-only \"United States\" 89>1" | |
| 3730 | + ], | |
| 3731 | + "small": [ | |
| 3732 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3733 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3734 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3735 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3736 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3737 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3738 | + "a.hover:text-ink.hover:underline 72×17 \"Data centers\"", | |
| 3739 | + "a.hover:text-ink.hover:underline 46×17 \"Markets\"", | |
| 3740 | + "a.text-ink.hover:text-accent.hover:underline 82×19 \"United States\"", | |
| 3741 | + "a.inline-flex.h-7.shrink-0 219×28 \"#72/ 152Metros by facili\"", | |
| 3742 | + "a.inline-flex.h-7.shrink-0 230×28 \"#44/ 129Metros by operat\"", | |
| 3743 | + "a.inline-flex.h-10.items-center 38×40 \"Map\"" | |
| 3744 | + ], | |
| 3745 | + "smallCount": 97, | |
| 3746 | + "h1Covered": false, | |
| 3747 | + "h1Top": 120, | |
| 3748 | + "headerBottom": 49, | |
| 3749 | + "lang": "en", | |
| 3750 | + "metaDesc": 250, | |
| 3751 | + "h1Count": 1, | |
| 3752 | + "skipped": [], | |
| 3753 | + "unnamed": [], | |
| 3754 | + "ariaCurrent": 2, | |
| 3755 | + "footerOk": true, | |
| 3756 | + "contrast": [ | |
| 3757 | + { | |
| 3758 | + "ratio": 2.91, | |
| 3759 | + "color": "rgb(91, 97, 104)", | |
| 3760 | + "bg": "rgb(16,22,29)", | |
| 3761 | + "sample": "span.figure.text-ink-4 \"/ 152\"", | |
| 3762 | + "n": 4 | |
| 3763 | + }, | |
| 3764 | + { | |
| 3765 | + "ratio": 3.07, | |
| 3766 | + "color": "rgb(91, 97, 104)", | |
| 3767 | + "bg": "rgb(11,15,20)", | |
| 3768 | + "sample": "span.figure.text-[11px].text-ink-4 \"10\"", | |
| 3769 | + "n": 3 | |
| 3770 | + }, | |
| 3771 | + { | |
| 3772 | + "ratio": 3.07, | |
| 3773 | + "color": "rgb(91, 97, 104)", | |
| 3774 | + "bg": "rgb(11,15,20)", | |
| 3775 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 3776 | + "n": 1 | |
| 3777 | + }, | |
| 3778 | + { | |
| 3779 | + "ratio": 3.07, | |
| 3780 | + "color": "rgb(91, 97, 104)", | |
| 3781 | + "bg": "rgb(11,15,20)", | |
| 3782 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3783 | + "n": 1 | |
| 3784 | + } | |
| 3785 | + ], | |
| 3786 | + "contrastChecked": 462, | |
| 3787 | + "height": 8563, | |
| 3788 | + "title": "Data Centers in Northern Virginia · DataCenterIndex", | |
| 3789 | + "bottom": { | |
| 3790 | + "navVisible": true, | |
| 3791 | + "covered": false, | |
| 3792 | + "gap": 1074 | |
| 3793 | + }, | |
| 3794 | + "errors": [ | |
| 3795 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3796 | + ], | |
| 3797 | + "badResponses": [ | |
| 3798 | + "404 fetch /api/v1/docs?_rsc=kksxSvqB6K9jsp-1" | |
| 3799 | + ] | |
| 3800 | + }, | |
| 3801 | + { | |
| 3802 | + "name": "projects", | |
| 3803 | + "path": "/projects", | |
| 3804 | + "w": 390, | |
| 3805 | + "theme": "dark", | |
| 3806 | + "status": 200, | |
| 3807 | + "ttfb": 12, | |
| 3808 | + "overflow": null, | |
| 3809 | + "clipped": [ | |
| 3810 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3811 | + "span.sr-only \"United States\" 76>1", | |
| 3812 | + "span.sr-only \"United States\" 76>1", | |
| 3813 | + "span.sr-only \"Australia\" 49>1", | |
| 3814 | + "span.sr-only \"Canada\" 43>1", | |
| 3815 | + "span.sr-only \"United States\" 76>1", | |
| 3816 | + "span.sr-only \"Norway\" 44>1" | |
| 3817 | + ], | |
| 3818 | + "small": [ | |
| 3819 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3820 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3821 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3822 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3823 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3824 | + "a.block 366×36 \"1🇺🇸United States≈ 125 \"", | |
| 3825 | + "a.block 366×36 \"2🇯🇵Japan≈ 12.7 GW16\"", | |
| 3826 | + "a.block 366×36 \"3🇦🇺Australia≈ 26 GW9\"", | |
| 3827 | + "a.block 366×36 \"4🇲🇾Malaysia≈ 15.4 GW8\"", | |
| 3828 | + "a.block 366×36 \"5🇩🇪Germany≈ 21.1 GW4\"", | |
| 3829 | + "a.block 366×36 \"6🇸🇬Singapore≈ 314 MW4\"", | |
| 3830 | + "a.block 366×36 \"7🇫🇮Finland≈ 1.79 GW3\"" | |
| 3831 | + ], | |
| 3832 | + "smallCount": 30, | |
| 3833 | + "h1Covered": false, | |
| 3834 | + "h1Top": 90, | |
| 3835 | + "headerBottom": 49, | |
| 3836 | + "lang": "en", | |
| 3837 | + "metaDesc": 130, | |
| 3838 | + "h1Count": 1, | |
| 3839 | + "skipped": [], | |
| 3840 | + "unnamed": [], | |
| 3841 | + "ariaCurrent": 3, | |
| 3842 | + "footerOk": true, | |
| 3843 | + "contrast": [ | |
| 3844 | + { | |
| 3845 | + "ratio": 3.07, | |
| 3846 | + "color": "rgb(91, 97, 104)", | |
| 3847 | + "bg": "rgb(11,15,20)", | |
| 3848 | + "sample": "span.figure.text-ink-4 \"19%\"", | |
| 3849 | + "n": 3 | |
| 3850 | + }, | |
| 3851 | + { | |
| 3852 | + "ratio": 3.07, | |
| 3853 | + "color": "rgb(91, 97, 104)", | |
| 3854 | + "bg": "rgb(11,15,20)", | |
| 3855 | + "sample": "span.text-ink-4 \"TBD\"", | |
| 3856 | + "n": 50 | |
| 3857 | + }, | |
| 3858 | + { | |
| 3859 | + "ratio": 3.07, | |
| 3860 | + "color": "rgb(91, 97, 104)", | |
| 3861 | + "bg": "rgb(11,15,20)", | |
| 3862 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Projects are reconciled from o\"", | |
| 3863 | + "n": 1 | |
| 3864 | + }, | |
| 3865 | + { | |
| 3866 | + "ratio": 3.07, | |
| 3867 | + "color": "rgb(91, 97, 104)", | |
| 3868 | + "bg": "rgb(11,15,20)", | |
| 3869 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"live feed\"", | |
| 3870 | + "n": 1 | |
| 3871 | + } | |
| 3872 | + ], | |
| 3873 | + "contrastChecked": 344, | |
| 3874 | + "height": 7315, | |
| 3875 | + "title": "Data center projects · DataCenterIndex", | |
| 3876 | + "bottom": { | |
| 3877 | + "navVisible": true, | |
| 3878 | + "covered": false, | |
| 3879 | + "gap": 810 | |
| 3880 | + }, | |
| 3881 | + "errors": [ | |
| 3882 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3883 | + ], | |
| 3884 | + "badResponses": [ | |
| 3885 | + "404 fetch /api/v1/docs?_rsc=rVOxZyJZQf-ncF1B" | |
| 3886 | + ] | |
| 3887 | + }, | |
| 3888 | + { | |
| 3889 | + "name": "project", | |
| 3890 | + "path": "/projects/sydney-data-center-project", | |
| 3891 | + "w": 390, | |
| 3892 | + "theme": "dark", | |
| 3893 | + "status": 200, | |
| 3894 | + "ttfb": 7, | |
| 3895 | + "overflow": null, | |
| 3896 | + "clipped": [ | |
| 3897 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 3898 | + "span.sr-only \"Australia\" 54>1", | |
| 3899 | + "span.sr-only \"Australia\" 54>1", | |
| 3900 | + "span.sr-only \"Australia\" 58>1" | |
| 3901 | + ], | |
| 3902 | + "small": [ | |
| 3903 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3904 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3905 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3906 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3907 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3908 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 3909 | + "a.hover:text-ink.hover:underline 46×17 \"Projects\"", | |
| 3910 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 3911 | + "a.hover:text-ink.hover:underline 46×19 \"Sydney\"", | |
| 3912 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 3913 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"", | |
| 3914 | + "button.inline-flex.items-center.rounded-xs 12×12 \"Provenance (1 source)\"" | |
| 3915 | + ], | |
| 3916 | + "smallCount": 42, | |
| 3917 | + "h1Covered": false, | |
| 3918 | + "h1Top": 120, | |
| 3919 | + "headerBottom": 49, | |
| 3920 | + "lang": "en", | |
| 3921 | + "metaDesc": 201, | |
| 3922 | + "h1Count": 1, | |
| 3923 | + "skipped": [], | |
| 3924 | + "unnamed": [], | |
| 3925 | + "ariaCurrent": 2, | |
| 3926 | + "footerOk": true, | |
| 3927 | + "contrast": [ | |
| 3928 | + { | |
| 3929 | + "ratio": 3.07, | |
| 3930 | + "color": "rgb(91, 97, 104)", | |
| 3931 | + "bg": "rgb(11,15,20)", | |
| 3932 | + "sample": "span.figure.text-[11px].text-ink-4 \"10\"", | |
| 3933 | + "n": 2 | |
| 3934 | + }, | |
| 3935 | + { | |
| 3936 | + "ratio": 3.07, | |
| 3937 | + "color": "rgb(91, 97, 104)", | |
| 3938 | + "bg": "rgb(11,15,20)", | |
| 3939 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"News\"", | |
| 3940 | + "n": 1 | |
| 3941 | + }, | |
| 3942 | + { | |
| 3943 | + "ratio": 3.07, | |
| 3944 | + "color": "rgb(91, 97, 104)", | |
| 3945 | + "bg": "rgb(11,15,20)", | |
| 3946 | + "sample": "span.text-ink-4 \"· Headlines, dates, facts and \"", | |
| 3947 | + "n": 1 | |
| 3948 | + }, | |
| 3949 | + { | |
| 3950 | + "ratio": 3.07, | |
| 3951 | + "color": "rgb(91, 97, 104)", | |
| 3952 | + "bg": "rgb(11,15,20)", | |
| 3953 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 3954 | + "n": 1 | |
| 3955 | + }, | |
| 3956 | + { | |
| 3957 | + "ratio": 3.07, | |
| 3958 | + "color": "rgb(91, 97, 104)", | |
| 3959 | + "bg": "rgb(11,15,20)", | |
| 3960 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 3961 | + "n": 1 | |
| 3962 | + } | |
| 3963 | + ], | |
| 3964 | + "contrastChecked": 195, | |
| 3965 | + "height": 4322, | |
| 3966 | + "title": "Sydney data center project · DataCenterIndex", | |
| 3967 | + "bottom": { | |
| 3968 | + "navVisible": true, | |
| 3969 | + "covered": false, | |
| 3970 | + "gap": 1073 | |
| 3971 | + }, | |
| 3972 | + "errors": [ | |
| 3973 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 3974 | + ], | |
| 3975 | + "badResponses": [ | |
| 3976 | + "404 fetch /api/v1/docs?_rsc=ZwbwKyoxiqLR71eh" | |
| 3977 | + ] | |
| 3978 | + }, | |
| 3979 | + { | |
| 3980 | + "name": "rankings", | |
| 3981 | + "path": "/rankings", | |
| 3982 | + "w": 390, | |
| 3983 | + "theme": "dark", | |
| 3984 | + "status": 200, | |
| 3985 | + "ttfb": 2, | |
| 3986 | + "overflow": null, | |
| 3987 | + "clipped": [ | |
| 3988 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 3989 | + ], | |
| 3990 | + "small": [ | |
| 3991 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 3992 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 3993 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 3994 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 3995 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 3996 | + "a.text-[14.5px].font-semibold.text-ink 170×21 \"Countries by AI faciliti\"", | |
| 3997 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 3998 | + "a.text-[14.5px].font-semibold.text-ink 188×21 \"Countries by cloud regio\"", | |
| 3999 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 4000 | + "a.text-[14.5px].font-semibold.text-ink 336×21 \"Countries by facilities \"", | |
| 4001 | + "a.text-ink-2.hover:text-accent 78×17 \"Full ranking →\"", | |
| 4002 | + "a.text-[14.5px].font-semibold.text-ink 184×21 \"Countries by facility co\"" | |
| 4003 | + ], | |
| 4004 | + "smallCount": 60, | |
| 4005 | + "h1Covered": false, | |
| 4006 | + "h1Top": 90, | |
| 4007 | + "headerBottom": 49, | |
| 4008 | + "lang": "en", | |
| 4009 | + "metaDesc": 190, | |
| 4010 | + "h1Count": 1, | |
| 4011 | + "skipped": [], | |
| 4012 | + "unnamed": [], | |
| 4013 | + "ariaCurrent": 2, | |
| 4014 | + "footerOk": true, | |
| 4015 | + "contrast": [ | |
| 4016 | + { | |
| 4017 | + "ratio": 3.07, | |
| 4018 | + "color": "rgb(91, 97, 104)", | |
| 4019 | + "bg": "rgb(11,15,20)", | |
| 4020 | + "sample": "span.figure.text-[11px].text-ink-4 \"11\"", | |
| 4021 | + "n": 1 | |
| 4022 | + }, | |
| 4023 | + { | |
| 4024 | + "ratio": 3.07, | |
| 4025 | + "color": "rgb(91, 97, 104)", | |
| 4026 | + "bg": "rgb(11,15,20)", | |
| 4027 | + "sample": "span.text-ink-4 \"No rows computed yet\"", | |
| 4028 | + "n": 3 | |
| 4029 | + } | |
| 4030 | + ], | |
| 4031 | + "contrastChecked": 173, | |
| 4032 | + "height": 4882, | |
| 4033 | + "title": "Rankings · DataCenterIndex", | |
| 4034 | + "bottom": { | |
| 4035 | + "navVisible": true, | |
| 4036 | + "covered": false, | |
| 4037 | + "gap": 809 | |
| 4038 | + }, | |
| 4039 | + "errors": [ | |
| 4040 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4041 | + ], | |
| 4042 | + "badResponses": [ | |
| 4043 | + "404 fetch /api/v1/docs?_rsc=8uisVwxVtwU7bmA7" | |
| 4044 | + ] | |
| 4045 | + }, | |
| 4046 | + { | |
| 4047 | + "name": "ranking", | |
| 4048 | + "path": "/rankings/countries_facilities", | |
| 4049 | + "w": 390, | |
| 4050 | + "theme": "dark", | |
| 4051 | + "status": 200, | |
| 4052 | + "ttfb": 11, | |
| 4053 | + "overflow": null, | |
| 4054 | + "clipped": [ | |
| 4055 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4056 | + "span.sr-only \"United States\" 83>1", | |
| 4057 | + "span.sr-only \"France\" 43>1", | |
| 4058 | + "span.sr-only \"United Kingdom\" 100>1", | |
| 4059 | + "span.sr-only \"Germany\" 57>1", | |
| 4060 | + "span.sr-only \"Australia\" 55>1", | |
| 4061 | + "span.sr-only \"Netherlands\" 76>1", | |
| 4062 | + "span.sr-only \"India\" 30>1", | |
| 4063 | + "span.sr-only \"Singapore\" 63>1" | |
| 4064 | + ], | |
| 4065 | + "small": [ | |
| 4066 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4067 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4068 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4069 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4070 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4071 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 4072 | + "a.hover:text-ink.hover:underline 52×17 \"Rankings\"", | |
| 4073 | + "a.font-medium.text-ink.hover:text-accent 83×18 \"United States\"", | |
| 4074 | + "a.font-medium.text-ink.hover:text-accent 43×18 \"France\"", | |
| 4075 | + "a.font-medium.text-ink.hover:text-accent 100×18 \"United Kingdom\"", | |
| 4076 | + "a.font-medium.text-ink.hover:text-accent 57×18 \"Germany\"", | |
| 4077 | + "a.font-medium.text-ink.hover:text-accent 55×18 \"Australia\"" | |
| 4078 | + ], | |
| 4079 | + "smallCount": 32, | |
| 4080 | + "h1Covered": false, | |
| 4081 | + "h1Top": 120, | |
| 4082 | + "headerBottom": 49, | |
| 4083 | + "lang": "en", | |
| 4084 | + "metaDesc": 161, | |
| 4085 | + "h1Count": 1, | |
| 4086 | + "skipped": [ | |
| 4087 | + "h1→h3" | |
| 4088 | + ], | |
| 4089 | + "unnamed": [], | |
| 4090 | + "ariaCurrent": 2, | |
| 4091 | + "footerOk": true, | |
| 4092 | + "contrast": [], | |
| 4093 | + "contrastChecked": 95, | |
| 4094 | + "height": 2378, | |
| 4095 | + "title": "Countries by facility count · DataCenterIndex", | |
| 4096 | + "bottom": { | |
| 4097 | + "navVisible": true, | |
| 4098 | + "covered": false, | |
| 4099 | + "gap": 809 | |
| 4100 | + }, | |
| 4101 | + "errors": [ | |
| 4102 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4103 | + ], | |
| 4104 | + "badResponses": [ | |
| 4105 | + "404 fetch /api/v1/docs?_rsc=8sIz69C0ujSVTYSL" | |
| 4106 | + ] | |
| 4107 | + }, | |
| 4108 | + { | |
| 4109 | + "name": "cloud-regions", | |
| 4110 | + "path": "/cloud-regions", | |
| 4111 | + "w": 390, | |
| 4112 | + "theme": "dark", | |
| 4113 | + "status": 200, | |
| 4114 | + "ttfb": 26, | |
| 4115 | + "overflow": null, | |
| 4116 | + "clipped": [ | |
| 4117 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4118 | + "label.sr-only \"Provider\" 55>1", | |
| 4119 | + "label.sr-only \"Country\" 53>1", | |
| 4120 | + "label.sr-only \"Status\" 42>1" | |
| 4121 | + ], | |
| 4122 | + "small": [ | |
| 4123 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4124 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4125 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4126 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4127 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4128 | + "a.inline-flex.items-center.justify-center 106×32 \"Show on map\"", | |
| 4129 | + "select#cr-provider.h-9.rounded-sm.border 179×36 \"All providersAmazon Web \"", | |
| 4130 | + "select#cr-country.h-9.rounded-sm.border 175×36 \"All countriesArgentinaAu\"", | |
| 4131 | + "select#cr-status.h-9.rounded-sm.border 109×36 \"Any statusOperationalAnn\"", | |
| 4132 | + "button.inline-flex.items-center.justify-center 58×32 \"Apply\"", | |
| 4133 | + "a.ml-auto.text-[12.5px].text-ink-3 106×18 \"Flat sortable table\"", | |
| 4134 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"" | |
| 4135 | + ], | |
| 4136 | + "smallCount": 26, | |
| 4137 | + "h1Covered": false, | |
| 4138 | + "h1Top": 90, | |
| 4139 | + "headerBottom": 49, | |
| 4140 | + "lang": "en", | |
| 4141 | + "metaDesc": 155, | |
| 4142 | + "h1Count": 1, | |
| 4143 | + "skipped": [], | |
| 4144 | + "unnamed": [], | |
| 4145 | + "ariaCurrent": 0, | |
| 4146 | + "footerOk": true, | |
| 4147 | + "contrast": [ | |
| 4148 | + { | |
| 4149 | + "ratio": 3.07, | |
| 4150 | + "color": "rgb(91, 97, 104)", | |
| 4151 | + "bg": "rgb(11,15,20)", | |
| 4152 | + "sample": "span.figure.text-[11px].text-ink-4 \"79\"", | |
| 4153 | + "n": 7 | |
| 4154 | + } | |
| 4155 | + ], | |
| 4156 | + "contrastChecked": 443, | |
| 4157 | + "height": 8052, | |
| 4158 | + "title": "Cloud regions · DataCenterIndex", | |
| 4159 | + "bottom": { | |
| 4160 | + "navVisible": true, | |
| 4161 | + "covered": false, | |
| 4162 | + "gap": 810 | |
| 4163 | + }, | |
| 4164 | + "errors": [ | |
| 4165 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4166 | + ], | |
| 4167 | + "badResponses": [ | |
| 4168 | + "404 fetch /api/v1/docs?_rsc=z0v65R_nxS1w6k12" | |
| 4169 | + ] | |
| 4170 | + }, | |
| 4171 | + { | |
| 4172 | + "name": "cloud-region", | |
| 4173 | + "path": "/cloud-regions/amazon-web-services-ca-central-1", | |
| 4174 | + "w": 390, | |
| 4175 | + "theme": "dark", | |
| 4176 | + "status": 200, | |
| 4177 | + "ttfb": 5, | |
| 4178 | + "overflow": null, | |
| 4179 | + "clipped": [ | |
| 4180 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4181 | + "span.sr-only \"Canada\" 47>1" | |
| 4182 | + ], | |
| 4183 | + "small": [ | |
| 4184 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4185 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4186 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4187 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4188 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4189 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 4190 | + "a.hover:text-ink.hover:underline 79×17 \"Cloud regions\"", | |
| 4191 | + "a.hover:text-ink.hover:underline 128×17 \"Amazon Web Services\"", | |
| 4192 | + "a.hover:text-ink.hover:underline 47×19 \"Canada\"", | |
| 4193 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4194 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 4195 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 4196 | + ], | |
| 4197 | + "smallCount": 24, | |
| 4198 | + "h1Covered": false, | |
| 4199 | + "h1Top": 120, | |
| 4200 | + "headerBottom": 49, | |
| 4201 | + "lang": "en", | |
| 4202 | + "metaDesc": 165, | |
| 4203 | + "h1Count": 1, | |
| 4204 | + "skipped": [], | |
| 4205 | + "unnamed": [], | |
| 4206 | + "ariaCurrent": 0, | |
| 4207 | + "footerOk": true, | |
| 4208 | + "contrast": [ | |
| 4209 | + { | |
| 4210 | + "ratio": 3.07, | |
| 4211 | + "color": "rgb(91, 97, 104)", | |
| 4212 | + "bg": "rgb(11,15,20)", | |
| 4213 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Region facts are taken verbati\"", | |
| 4214 | + "n": 1 | |
| 4215 | + }, | |
| 4216 | + { | |
| 4217 | + "ratio": 3.07, | |
| 4218 | + "color": "rgb(91, 97, 104)", | |
| 4219 | + "bg": "rgb(11,15,20)", | |
| 4220 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 4221 | + "n": 1 | |
| 4222 | + } | |
| 4223 | + ], | |
| 4224 | + "contrastChecked": 86, | |
| 4225 | + "height": 2891, | |
| 4226 | + "title": "Amazon Web Services ca-central-1 — Canada (Central) · DataCenterIndex", | |
| 4227 | + "bottom": { | |
| 4228 | + "navVisible": true, | |
| 4229 | + "covered": false, | |
| 4230 | + "gap": 1073 | |
| 4231 | + }, | |
| 4232 | + "errors": [ | |
| 4233 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4234 | + ], | |
| 4235 | + "badResponses": [ | |
| 4236 | + "404 fetch /api/v1/docs?_rsc=5uclAh_QepNQCyIY" | |
| 4237 | + ] | |
| 4238 | + }, | |
| 4239 | + { | |
| 4240 | + "name": "ixps", | |
| 4241 | + "path": "/ixps", | |
| 4242 | + "w": 390, | |
| 4243 | + "theme": "dark", | |
| 4244 | + "status": 200, | |
| 4245 | + "ttfb": 5, | |
| 4246 | + "overflow": null, | |
| 4247 | + "clipped": [ | |
| 4248 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4249 | + "label.sr-only \"Search\" 46>1", | |
| 4250 | + "label.sr-only \"Country\" 53>1" | |
| 4251 | + ], | |
| 4252 | + "small": [ | |
| 4253 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4254 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4255 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4256 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4257 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4258 | + "a.inline-flex.items-center.justify-center 101×32 \"Data centers\"", | |
| 4259 | + "input#ixp-q.h-9.rounded-sm.border 366×36 \"\"", | |
| 4260 | + "select#ixp-country.h-9.rounded-sm.border 234×36 \"All countriesAlbaniaArge\"", | |
| 4261 | + "button.inline-flex.items-center.justify-center 58×32 \"Apply\"", | |
| 4262 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4263 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 4264 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 4265 | + ], | |
| 4266 | + "smallCount": 24, | |
| 4267 | + "h1Covered": false, | |
| 4268 | + "h1Top": 90, | |
| 4269 | + "headerBottom": 49, | |
| 4270 | + "lang": "en", | |
| 4271 | + "metaDesc": 100, | |
| 4272 | + "h1Count": 1, | |
| 4273 | + "skipped": [], | |
| 4274 | + "unnamed": [], | |
| 4275 | + "ariaCurrent": 1, | |
| 4276 | + "footerOk": true, | |
| 4277 | + "contrast": [ | |
| 4278 | + { | |
| 4279 | + "ratio": 3.07, | |
| 4280 | + "color": "rgb(91, 97, 104)", | |
| 4281 | + "bg": "rgb(11,15,20)", | |
| 4282 | + "sample": "p.mt-8.text-[12px].text-ink-4 \"Exchange and network data: Pee\"", | |
| 4283 | + "n": 1 | |
| 4284 | + } | |
| 4285 | + ], | |
| 4286 | + "contrastChecked": 60, | |
| 4287 | + "height": 2057, | |
| 4288 | + "title": "Internet exchanges · DataCenterIndex", | |
| 4289 | + "bottom": { | |
| 4290 | + "navVisible": true, | |
| 4291 | + "covered": false, | |
| 4292 | + "gap": 810 | |
| 4293 | + }, | |
| 4294 | + "errors": [ | |
| 4295 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4296 | + ], | |
| 4297 | + "badResponses": [ | |
| 4298 | + "404 fetch /api/v1/docs?_rsc=LOh4kTrez0j5S-5D" | |
| 4299 | + ] | |
| 4300 | + }, | |
| 4301 | + { | |
| 4302 | + "name": "sources", | |
| 4303 | + "path": "/sources", | |
| 4304 | + "w": 390, | |
| 4305 | + "theme": "dark", | |
| 4306 | + "status": 200, | |
| 4307 | + "ttfb": 19, | |
| 4308 | + "overflow": null, | |
| 4309 | + "clipped": [ | |
| 4310 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4311 | + ], | |
| 4312 | + "small": [ | |
| 4313 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4314 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4315 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4316 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4317 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4318 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 4319 | + "input.h-9.min-w-0.rounded-sm 157×36 \"\"", | |
| 4320 | + "select.h-9.rounded-sm.border 129×36 \"All kindsOperatorGovernm\"", | |
| 4321 | + "button.inline-flex.items-center.justify-center 64×36 \"Apply\"", | |
| 4322 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4323 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 4324 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"" | |
| 4325 | + ], | |
| 4326 | + "smallCount": 24, | |
| 4327 | + "h1Covered": false, | |
| 4328 | + "h1Top": 112, | |
| 4329 | + "headerBottom": 49, | |
| 4330 | + "lang": "en", | |
| 4331 | + "metaDesc": 162, | |
| 4332 | + "h1Count": 1, | |
| 4333 | + "skipped": [ | |
| 4334 | + "h1→h3" | |
| 4335 | + ], | |
| 4336 | + "unnamed": [], | |
| 4337 | + "ariaCurrent": 0, | |
| 4338 | + "footerOk": true, | |
| 4339 | + "contrast": [ | |
| 4340 | + { | |
| 4341 | + "ratio": 3.07, | |
| 4342 | + "color": "rgb(91, 97, 104)", | |
| 4343 | + "bg": "rgb(11,15,20)", | |
| 4344 | + "sample": "span.figure.text-[11px].text-ink-4 \"60\"", | |
| 4345 | + "n": 3 | |
| 4346 | + }, | |
| 4347 | + { | |
| 4348 | + "ratio": 3.07, | |
| 4349 | + "color": "rgb(91, 97, 104)", | |
| 4350 | + "bg": "rgb(11,15,20)", | |
| 4351 | + "sample": "p.mt-6.text-[12px].text-ink-4 \"Kinds rank source authority wh\"", | |
| 4352 | + "n": 1 | |
| 4353 | + }, | |
| 4354 | + { | |
| 4355 | + "ratio": 3.07, | |
| 4356 | + "color": "rgb(91, 97, 104)", | |
| 4357 | + "bg": "rgb(11,15,20)", | |
| 4358 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"reconciliation rules\"", | |
| 4359 | + "n": 2 | |
| 4360 | + } | |
| 4361 | + ], | |
| 4362 | + "contrastChecked": 238, | |
| 4363 | + "height": 6001, | |
| 4364 | + "title": "Sources · DataCenterIndex", | |
| 4365 | + "bottom": { | |
| 4366 | + "navVisible": true, | |
| 4367 | + "covered": false, | |
| 4368 | + "gap": 810 | |
| 4369 | + }, | |
| 4370 | + "errors": [ | |
| 4371 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4372 | + ], | |
| 4373 | + "badResponses": [ | |
| 4374 | + "404 fetch /api/v1/docs?_rsc=9SQ0ZVjwLU4sIjhN" | |
| 4375 | + ] | |
| 4376 | + }, | |
| 4377 | + { | |
| 4378 | + "name": "methodology", | |
| 4379 | + "path": "/methodology", | |
| 4380 | + "w": 390, | |
| 4381 | + "theme": "dark", | |
| 4382 | + "status": 200, | |
| 4383 | + "ttfb": 3, | |
| 4384 | + "overflow": null, | |
| 4385 | + "clipped": [ | |
| 4386 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4387 | + ], | |
| 4388 | + "small": [ | |
| 4389 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4390 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4391 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4392 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4393 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4394 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 4395 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4396 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 4397 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 4398 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 4399 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"", | |
| 4400 | + "a.inline-flex.min-h-7.items-center 60×28 \"Countries\"" | |
| 4401 | + ], | |
| 4402 | + "smallCount": 21, | |
| 4403 | + "h1Covered": false, | |
| 4404 | + "h1Top": 112, | |
| 4405 | + "headerBottom": 49, | |
| 4406 | + "lang": "en", | |
| 4407 | + "metaDesc": 201, | |
| 4408 | + "h1Count": 1, | |
| 4409 | + "skipped": [], | |
| 4410 | + "unnamed": [], | |
| 4411 | + "ariaCurrent": 0, | |
| 4412 | + "footerOk": true, | |
| 4413 | + "contrast": [ | |
| 4414 | + { | |
| 4415 | + "ratio": 3.07, | |
| 4416 | + "color": "rgb(91, 97, 104)", | |
| 4417 | + "bg": "rgb(11,15,20)", | |
| 4418 | + "sample": "div.figure.text-[11px].text-ink-4 \"countries_facilities\"", | |
| 4419 | + "n": 20 | |
| 4420 | + } | |
| 4421 | + ], | |
| 4422 | + "contrastChecked": 234, | |
| 4423 | + "height": 12570, | |
| 4424 | + "title": "Methodology · DataCenterIndex", | |
| 4425 | + "bottom": { | |
| 4426 | + "navVisible": true, | |
| 4427 | + "covered": false, | |
| 4428 | + "gap": 809 | |
| 4429 | + }, | |
| 4430 | + "errors": [ | |
| 4431 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4432 | + ], | |
| 4433 | + "badResponses": [ | |
| 4434 | + "404 fetch /api/v1/docs?_rsc=HzWsK4yFUoZLFyoa" | |
| 4435 | + ] | |
| 4436 | + }, | |
| 4437 | + { | |
| 4438 | + "name": "about", | |
| 4439 | + "path": "/about", | |
| 4440 | + "w": 390, | |
| 4441 | + "theme": "dark", | |
| 4442 | + "status": 200, | |
| 4443 | + "ttfb": 1, | |
| 4444 | + "overflow": null, | |
| 4445 | + "clipped": [ | |
| 4446 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4447 | + ], | |
| 4448 | + "small": [ | |
| 4449 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4450 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4451 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4452 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4453 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4454 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"PeeringDB\"", | |
| 4455 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"OpenStreetMap\"", | |
| 4456 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"Wikidata\"", | |
| 4457 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"World Bank\"", | |
| 4458 | + "a.font-medium.text-ink.hover:text-accent 366×24 \"Operator and government \"", | |
| 4459 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4460 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"" | |
| 4461 | + ], | |
| 4462 | + "smallCount": 25, | |
| 4463 | + "h1Covered": false, | |
| 4464 | + "h1Top": 90, | |
| 4465 | + "headerBottom": 49, | |
| 4466 | + "lang": "en", | |
| 4467 | + "metaDesc": 85, | |
| 4468 | + "h1Count": 1, | |
| 4469 | + "skipped": [], | |
| 4470 | + "unnamed": [], | |
| 4471 | + "ariaCurrent": 0, | |
| 4472 | + "footerOk": true, | |
| 4473 | + "contrast": [], | |
| 4474 | + "contrastChecked": 63, | |
| 4475 | + "height": 3140, | |
| 4476 | + "title": "About · DataCenterIndex", | |
| 4477 | + "bottom": { | |
| 4478 | + "navVisible": true, | |
| 4479 | + "covered": false, | |
| 4480 | + "gap": 809 | |
| 4481 | + }, | |
| 4482 | + "errors": [ | |
| 4483 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4484 | + ], | |
| 4485 | + "badResponses": [ | |
| 4486 | + "404 fetch /api/v1/docs?_rsc=Sdev7PUlRw12F58X" | |
| 4487 | + ] | |
| 4488 | + }, | |
| 4489 | + { | |
| 4490 | + "name": "bot", | |
| 4491 | + "path": "/bot", | |
| 4492 | + "w": 390, | |
| 4493 | + "theme": "dark", | |
| 4494 | + "status": 200, | |
| 4495 | + "ttfb": 1, | |
| 4496 | + "overflow": null, | |
| 4497 | + "clipped": [ | |
| 4498 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4499 | + ], | |
| 4500 | + "small": [ | |
| 4501 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4502 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4503 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4504 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4505 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4506 | + "a.hover:text-ink.hover:underline 34×17 \"Home\"", | |
| 4507 | + "a.inline-flex.items-center.gap-2 160×20 \"DataCenterIndex — home\"", | |
| 4508 | + "a.inline-flex.min-h-7.items-center 27×28 \"Map\"", | |
| 4509 | + "a.inline-flex.min-h-7.items-center 78×28 \"Data centers\"", | |
| 4510 | + "a.inline-flex.min-h-7.items-center 61×28 \"Operators\"", | |
| 4511 | + "a.inline-flex.min-h-7.items-center 50×28 \"Markets\"", | |
| 4512 | + "a.inline-flex.min-h-7.items-center 60×28 \"Countries\"" | |
| 4513 | + ], | |
| 4514 | + "smallCount": 21, | |
| 4515 | + "h1Covered": false, | |
| 4516 | + "h1Top": 112, | |
| 4517 | + "headerBottom": 49, | |
| 4518 | + "lang": "en", | |
| 4519 | + "metaDesc": 179, | |
| 4520 | + "h1Count": 1, | |
| 4521 | + "skipped": [], | |
| 4522 | + "unnamed": [], | |
| 4523 | + "ariaCurrent": 0, | |
| 4524 | + "footerOk": true, | |
| 4525 | + "contrast": [], | |
| 4526 | + "contrastChecked": 80, | |
| 4527 | + "height": 4183, | |
| 4528 | + "title": "About our crawler · DataCenterIndex", | |
| 4529 | + "bottom": { | |
| 4530 | + "navVisible": true, | |
| 4531 | + "covered": false, | |
| 4532 | + "gap": 810 | |
| 4533 | + }, | |
| 4534 | + "errors": [ | |
| 4535 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4536 | + ], | |
| 4537 | + "badResponses": [ | |
| 4538 | + "404 fetch /api/v1/docs?_rsc=8qIO8lIDYC9gUgf-" | |
| 4539 | + ] | |
| 4540 | + }, | |
| 4541 | + { | |
| 4542 | + "name": "404", | |
| 4543 | + "path": "/this-page-does-not-exist", | |
| 4544 | + "w": 390, | |
| 4545 | + "theme": "dark", | |
| 4546 | + "status": 404, | |
| 4547 | + "ttfb": 1, | |
| 4548 | + "overflow": null, | |
| 4549 | + "clipped": [ | |
| 4550 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4551 | + ], | |
| 4552 | + "small": [ | |
| 4553 | + "a.sr-only.focus:not-sr-only.focus:fixed 1×1 \"Skip to content\"", | |
| 4554 | + "a.inline-flex.items-center.gap-2 162×22 \"DataCenterIndex — home\"", | |
| 4555 | + "button.inline-flex.shrink-0.items-center 36×36 \"Search\"", | |
| 4556 | + "button.inline-flex.shrink-0.items-center 36×36 \"Switch to light theme\"", | |
| 4557 | + "button.inline-flex.shrink-0.items-center 36×36 \"Menu\"", | |
| 4558 | + "a.inline-flex.items-center.justify-center 134×36 \"Search the index\"", | |
| 4559 | + "a.inline-flex.items-center.justify-center 117×36 \"Open the map\"", | |
| 4560 | + "a.inline-flex.h-9.items-center 52×36 \"Home\"" | |
| 4561 | + ], | |
| 4562 | + "smallCount": 8, | |
| 4563 | + "h1Covered": false, | |
| 4564 | + "h1Top": 231, | |
| 4565 | + "headerBottom": 49, | |
| 4566 | + "lang": "en", | |
| 4567 | + "metaDesc": 159, | |
| 4568 | + "h1Count": 1, | |
| 4569 | + "skipped": [], | |
| 4570 | + "unnamed": [], | |
| 4571 | + "ariaCurrent": 0, | |
| 4572 | + "footerOk": false, | |
| 4573 | + "contrast": [], | |
| 4574 | + "contrastChecked": 7, | |
| 4575 | + "height": 844, | |
| 4576 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 4577 | + "bottom": { | |
| 4578 | + "navVisible": true, | |
| 4579 | + "covered": false | |
| 4580 | + }, | |
| 4581 | + "errors": [ | |
| 4582 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4583 | + ], | |
| 4584 | + "badResponses": [] | |
| 4585 | + }, | |
| 4586 | + { | |
| 4587 | + "name": "home", | |
| 4588 | + "path": "/", | |
| 4589 | + "w": 1280, | |
| 4590 | + "theme": "dark", | |
| 4591 | + "status": 200, | |
| 4592 | + "ttfb": 3, | |
| 4593 | + "overflow": null, | |
| 4594 | + "clipped": [ | |
| 4595 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4596 | + "span.sr-only \"United States\" 89>1", | |
| 4597 | + "span.sr-only \"United States\" 89>1", | |
| 4598 | + "span.sr-only \"United States\" 89>1", | |
| 4599 | + "span.sr-only \"United States\" 76>1", | |
| 4600 | + "span.sr-only \"United States\" 76>1", | |
| 4601 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 4602 | + "span.sr-only \"France\" 42>1", | |
| 4603 | + "span.sr-only \"Singapore\" 62>1" | |
| 4604 | + ], | |
| 4605 | + "small": [], | |
| 4606 | + "smallCount": 0, | |
| 4607 | + "h1Covered": false, | |
| 4608 | + "h1Top": 132, | |
| 4609 | + "headerBottom": 57, | |
| 4610 | + "lang": "en", | |
| 4611 | + "metaDesc": 159, | |
| 4612 | + "h1Count": 1, | |
| 4613 | + "skipped": [], | |
| 4614 | + "unnamed": [], | |
| 4615 | + "ariaCurrent": 1, | |
| 4616 | + "footerOk": true, | |
| 4617 | + "contrast": [ | |
| 4618 | + { | |
| 4619 | + "ratio": 3.07, | |
| 4620 | + "color": "rgb(91, 97, 104)", | |
| 4621 | + "bg": "rgb(11,15,20)", | |
| 4622 | + "sample": "span.normal-case.tracking-normal.text-ink-4 \"· updated continuously\"", | |
| 4623 | + "n": 1 | |
| 4624 | + }, | |
| 4625 | + { | |
| 4626 | + "ratio": 3.07, | |
| 4627 | + "color": "rgb(91, 97, 104)", | |
| 4628 | + "bg": "rgb(11,15,20)", | |
| 4629 | + "sample": "span.shrink-0.py-1.text-[11.5px] \"Try\"", | |
| 4630 | + "n": 1 | |
| 4631 | + }, | |
| 4632 | + { | |
| 4633 | + "ratio": 3.07, | |
| 4634 | + "color": "rgb(91, 97, 104)", | |
| 4635 | + "bg": "rgb(11,15,20)", | |
| 4636 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 4637 | + "n": 2 | |
| 4638 | + }, | |
| 4639 | + { | |
| 4640 | + "ratio": 3.07, | |
| 4641 | + "color": "rgb(91, 97, 104)", | |
| 4642 | + "bg": "rgb(11,15,20)", | |
| 4643 | + "sample": "span.figure.text-ink-4 \"30%\"", | |
| 4644 | + "n": 2 | |
| 4645 | + } | |
| 4646 | + ], | |
| 4647 | + "contrastChecked": 335, | |
| 4648 | + "height": 3750, | |
| 4649 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 4650 | + "errors": [], | |
| 4651 | + "badResponses": [] | |
| 4652 | + }, | |
| 4653 | + { | |
| 4654 | + "name": "map", | |
| 4655 | + "path": "/map", | |
| 4656 | + "w": 1280, | |
| 4657 | + "theme": "dark", | |
| 4658 | + "status": 200, | |
| 4659 | + "ttfb": 1, | |
| 4660 | + "overflow": null, | |
| 4661 | + "clipped": [ | |
| 4662 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4663 | + "span.sr-only \"Minimum MW\" 91>1", | |
| 4664 | + "span.sr-only \"Maximum MW\" 95>1", | |
| 4665 | + "span.sr-only \"Opened from\" 87>1", | |
| 4666 | + "span.sr-only \"Opened to\" 69>1" | |
| 4667 | + ], | |
| 4668 | + "small": [], | |
| 4669 | + "smallCount": 0, | |
| 4670 | + "h1Covered": false, | |
| 4671 | + "h1Top": null, | |
| 4672 | + "headerBottom": 57, | |
| 4673 | + "lang": "en", | |
| 4674 | + "metaDesc": 127, | |
| 4675 | + "h1Count": 0, | |
| 4676 | + "skipped": [], | |
| 4677 | + "unnamed": [], | |
| 4678 | + "ariaCurrent": 2, | |
| 4679 | + "footerOk": false, | |
| 4680 | + "contrast": [], | |
| 4681 | + "contrastChecked": 14, | |
| 4682 | + "height": 901, | |
| 4683 | + "title": "Global data center map · DataCenterIndex", | |
| 4684 | + "map": { | |
| 4685 | + "controls": 3, | |
| 4686 | + "unreachable": 0, | |
| 4687 | + "canvas": true | |
| 4688 | + }, | |
| 4689 | + "errors": [], | |
| 4690 | + "badResponses": [] | |
| 4691 | + }, | |
| 4692 | + { | |
| 4693 | + "name": "search", | |
| 4694 | + "path": "/search?q=montreal", | |
| 4695 | + "w": 1280, | |
| 4696 | + "theme": "dark", | |
| 4697 | + "status": 200, | |
| 4698 | + "ttfb": 4, | |
| 4699 | + "overflow": null, | |
| 4700 | + "clipped": [ | |
| 4701 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4702 | + ], | |
| 4703 | + "small": [], | |
| 4704 | + "smallCount": 0, | |
| 4705 | + "h1Covered": false, | |
| 4706 | + "h1Top": 106, | |
| 4707 | + "headerBottom": 57, | |
| 4708 | + "lang": "en", | |
| 4709 | + "metaDesc": 159, | |
| 4710 | + "h1Count": 1, | |
| 4711 | + "skipped": [], | |
| 4712 | + "unnamed": [], | |
| 4713 | + "ariaCurrent": 0, | |
| 4714 | + "footerOk": true, | |
| 4715 | + "contrast": [], | |
| 4716 | + "contrastChecked": 55, | |
| 4717 | + "height": 984, | |
| 4718 | + "title": "“montreal” — search · DataCenterIndex", | |
| 4719 | + "errors": [ | |
| 4720 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 4721 | + ], | |
| 4722 | + "badResponses": [ | |
| 4723 | + "404 fetch /api/v1/docs?_rsc=6grVij_EUBxTWnLP" | |
| 4724 | + ] | |
| 4725 | + }, | |
| 4726 | + { | |
| 4727 | + "name": "live", | |
| 4728 | + "path": "/live", | |
| 4729 | + "w": 1280, | |
| 4730 | + "theme": "dark", | |
| 4731 | + "status": 200, | |
| 4732 | + "ttfb": 10, | |
| 4733 | + "overflow": null, | |
| 4734 | + "clipped": [ | |
| 4735 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4736 | + "span.sr-only \"United States\" 89>1", | |
| 4737 | + "span.sr-only \"United States\" 89>1", | |
| 4738 | + "span.sr-only \"United States\" 89>1", | |
| 4739 | + "span.sr-only \"United States\" 89>1", | |
| 4740 | + "span.sr-only \"United States\" 89>1", | |
| 4741 | + "span.sr-only \"United States\" 89>1", | |
| 4742 | + "span.sr-only \"United States\" 89>1", | |
| 4743 | + "span.sr-only \"United States\" 89>1" | |
| 4744 | + ], | |
| 4745 | + "small": [], | |
| 4746 | + "smallCount": 0, | |
| 4747 | + "h1Covered": false, | |
| 4748 | + "h1Top": 107, | |
| 4749 | + "headerBottom": 57, | |
| 4750 | + "lang": "en", | |
| 4751 | + "metaDesc": 152, | |
| 4752 | + "h1Count": 1, | |
| 4753 | + "skipped": [ | |
| 4754 | + "h1→h3" | |
| 4755 | + ], | |
| 4756 | + "unnamed": [], | |
| 4757 | + "ariaCurrent": 1, | |
| 4758 | + "footerOk": true, | |
| 4759 | + "contrast": [], | |
| 4760 | + "contrastChecked": 519, | |
| 4761 | + "height": 7789, | |
| 4762 | + "title": "Live changes · DataCenterIndex", | |
| 4763 | + "errors": [], | |
| 4764 | + "badResponses": [] | |
| 4765 | + }, | |
| 4766 | + { | |
| 4767 | + "name": "datacenters", | |
| 4768 | + "path": "/datacenters", | |
| 4769 | + "w": 1280, | |
| 4770 | + "theme": "dark", | |
| 4771 | + "status": 200, | |
| 4772 | + "ttfb": 11, | |
| 4773 | + "overflow": null, | |
| 4774 | + "clipped": [ | |
| 4775 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 4776 | + ], | |
| 4777 | + "small": [], | |
| 4778 | + "smallCount": 0, | |
| 4779 | + "h1Covered": false, | |
| 4780 | + "h1Top": 106, | |
| 4781 | + "headerBottom": 57, | |
| 4782 | + "lang": "en", | |
| 4783 | + "metaDesc": 119, | |
| 4784 | + "h1Count": 1, | |
| 4785 | + "skipped": [], | |
| 4786 | + "unnamed": [], | |
| 4787 | + "ariaCurrent": 2, | |
| 4788 | + "footerOk": true, | |
| 4789 | + "contrast": [], | |
| 4790 | + "contrastChecked": 521, | |
| 4791 | + "height": 2652, | |
| 4792 | + "title": "Data centers · DataCenterIndex", | |
| 4793 | + "errors": [], | |
| 4794 | + "badResponses": [] | |
| 4795 | + }, | |
| 4796 | + { | |
| 4797 | + "name": "fac-operator-mw", | |
| 4798 | + "path": "/datacenters/databank-downtown-minneapolis-data-center-msp4", | |
| 4799 | + "w": 1280, | |
| 4800 | + "theme": "dark", | |
| 4801 | + "status": 200, | |
| 4802 | + "ttfb": 10, | |
| 4803 | + "overflow": null, | |
| 4804 | + "clipped": [ | |
| 4805 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4806 | + "span.sr-only \"United States\" 86>1", | |
| 4807 | + "span.sr-only \"United States\" 82>1", | |
| 4808 | + "span.sr-only \"United States\" 89>1" | |
| 4809 | + ], | |
| 4810 | + "small": [], | |
| 4811 | + "smallCount": 0, | |
| 4812 | + "h1Covered": false, | |
| 4813 | + "h1Top": 135, | |
| 4814 | + "headerBottom": 57, | |
| 4815 | + "lang": "en", | |
| 4816 | + "metaDesc": 212, | |
| 4817 | + "h1Count": 1, | |
| 4818 | + "skipped": [], | |
| 4819 | + "unnamed": [], | |
| 4820 | + "ariaCurrent": 1, | |
| 4821 | + "footerOk": true, | |
| 4822 | + "contrast": [ | |
| 4823 | + { | |
| 4824 | + "ratio": 3.07, | |
| 4825 | + "color": "rgb(91, 97, 104)", | |
| 4826 | + "bg": "rgb(11,15,20)", | |
| 4827 | + "sample": "span.figure.text-[11px].text-ink-4 \"16\"", | |
| 4828 | + "n": 2 | |
| 4829 | + }, | |
| 4830 | + { | |
| 4831 | + "ratio": 3.07, | |
| 4832 | + "color": "rgb(91, 97, 104)", | |
| 4833 | + "bg": "rgb(11,15,20)", | |
| 4834 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"High confidence overall — comp\"", | |
| 4835 | + "n": 1 | |
| 4836 | + }, | |
| 4837 | + { | |
| 4838 | + "ratio": 3.07, | |
| 4839 | + "color": "rgb(91, 97, 104)", | |
| 4840 | + "bg": "rgb(11,15,20)", | |
| 4841 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 4842 | + "n": 2 | |
| 4843 | + }, | |
| 4844 | + { | |
| 4845 | + "ratio": 3.07, | |
| 4846 | + "color": "rgb(91, 97, 104)", | |
| 4847 | + "bg": "rgb(11,15,20)", | |
| 4848 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Operator\"", | |
| 4849 | + "n": 1 | |
| 4850 | + }, | |
| 4851 | + { | |
| 4852 | + "ratio": 3.07, | |
| 4853 | + "color": "rgb(91, 97, 104)", | |
| 4854 | + "bg": "rgb(11,15,20)", | |
| 4855 | + "sample": "span.text-ink-4 \"· Publicly accessible operator\"", | |
| 4856 | + "n": 1 | |
| 4857 | + }, | |
| 4858 | + { | |
| 4859 | + "ratio": 3.07, | |
| 4860 | + "color": "rgb(91, 97, 104)", | |
| 4861 | + "bg": "rgb(11,15,20)", | |
| 4862 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 4863 | + "n": 1 | |
| 4864 | + } | |
| 4865 | + ], | |
| 4866 | + "contrastChecked": 373, | |
| 4867 | + "height": 4103, | |
| 4868 | + "title": "Downtown Minneapolis Data Center (MSP4) (Minneapolis, US) · DataCenterIndex", | |
| 4869 | + "errors": [], | |
| 4870 | + "badResponses": [] | |
| 4871 | + }, | |
| 4872 | + { | |
| 4873 | + "name": "fac-osm", | |
| 4874 | + "path": "/datacenters/shelter-modernizacion", | |
| 4875 | + "w": 1280, | |
| 4876 | + "theme": "dark", | |
| 4877 | + "status": 200, | |
| 4878 | + "ttfb": 8, | |
| 4879 | + "overflow": null, | |
| 4880 | + "clipped": [ | |
| 4881 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4882 | + "span.sr-only \"Argentina\" 62>1", | |
| 4883 | + "span.sr-only \"Argentina\" 59>1", | |
| 4884 | + "span.sr-only \"Argentina\" 64>1" | |
| 4885 | + ], | |
| 4886 | + "small": [], | |
| 4887 | + "smallCount": 0, | |
| 4888 | + "h1Covered": false, | |
| 4889 | + "h1Top": 132, | |
| 4890 | + "headerBottom": 57, | |
| 4891 | + "lang": "en", | |
| 4892 | + "metaDesc": 128, | |
| 4893 | + "h1Count": 1, | |
| 4894 | + "skipped": [], | |
| 4895 | + "unnamed": [], | |
| 4896 | + "ariaCurrent": 1, | |
| 4897 | + "footerOk": true, | |
| 4898 | + "contrast": [ | |
| 4899 | + { | |
| 4900 | + "ratio": 3.07, | |
| 4901 | + "color": "rgb(91, 97, 104)", | |
| 4902 | + "bg": "rgb(11,15,20)", | |
| 4903 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Unverified overall — computed \"", | |
| 4904 | + "n": 1 | |
| 4905 | + }, | |
| 4906 | + { | |
| 4907 | + "ratio": 3.07, | |
| 4908 | + "color": "rgb(91, 97, 104)", | |
| 4909 | + "bg": "rgb(11,15,20)", | |
| 4910 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 4911 | + "n": 2 | |
| 4912 | + }, | |
| 4913 | + { | |
| 4914 | + "ratio": 3.07, | |
| 4915 | + "color": "rgb(91, 97, 104)", | |
| 4916 | + "bg": "rgb(11,15,20)", | |
| 4917 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Community\"", | |
| 4918 | + "n": 1 | |
| 4919 | + }, | |
| 4920 | + { | |
| 4921 | + "ratio": 3.07, | |
| 4922 | + "color": "rgb(91, 97, 104)", | |
| 4923 | + "bg": "rgb(11,15,20)", | |
| 4924 | + "sample": "span.text-ink-4 \"· ODbL 1.0 — © OpenStreetMap c\"", | |
| 4925 | + "n": 1 | |
| 4926 | + }, | |
| 4927 | + { | |
| 4928 | + "ratio": 3.07, | |
| 4929 | + "color": "rgb(91, 97, 104)", | |
| 4930 | + "bg": "rgb(11,15,20)", | |
| 4931 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 4932 | + "n": 1 | |
| 4933 | + } | |
| 4934 | + ], | |
| 4935 | + "contrastChecked": 220, | |
| 4936 | + "height": 3088, | |
| 4937 | + "title": "Shelter Modernización · DataCenterIndex", | |
| 4938 | + "errors": [], | |
| 4939 | + "badResponses": [] | |
| 4940 | + }, | |
| 4941 | + { | |
| 4942 | + "name": "fac-google", | |
| 4943 | + "path": "/datacenters/google-the-dalles-oregon-data-center", | |
| 4944 | + "w": 1280, | |
| 4945 | + "theme": "dark", | |
| 4946 | + "status": 200, | |
| 4947 | + "ttfb": 14, | |
| 4948 | + "overflow": null, | |
| 4949 | + "clipped": [ | |
| 4950 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 4951 | + "span.sr-only \"United States\" 86>1", | |
| 4952 | + "span.sr-only \"United States\" 82>1", | |
| 4953 | + "span.sr-only \"United States\" 89>1", | |
| 4954 | + "span.sr-only \"United States\" 89>1", | |
| 4955 | + "span.sr-only \"United States\" 89>1", | |
| 4956 | + "span.sr-only \"United States\" 89>1" | |
| 4957 | + ], | |
| 4958 | + "small": [], | |
| 4959 | + "smallCount": 0, | |
| 4960 | + "h1Covered": false, | |
| 4961 | + "h1Top": 135, | |
| 4962 | + "headerBottom": 57, | |
| 4963 | + "lang": "en", | |
| 4964 | + "metaDesc": 203, | |
| 4965 | + "h1Count": 1, | |
| 4966 | + "skipped": [], | |
| 4967 | + "unnamed": [], | |
| 4968 | + "ariaCurrent": 1, | |
| 4969 | + "footerOk": true, | |
| 4970 | + "contrast": [ | |
| 4971 | + { | |
| 4972 | + "ratio": 3.07, | |
| 4973 | + "color": "rgb(91, 97, 104)", | |
| 4974 | + "bg": "rgb(11,15,20)", | |
| 4975 | + "sample": "span.figure.text-[11px].text-ink-4 \"26\"", | |
| 4976 | + "n": 2 | |
| 4977 | + }, | |
| 4978 | + { | |
| 4979 | + "ratio": 3.07, | |
| 4980 | + "color": "rgb(91, 97, 104)", | |
| 4981 | + "bg": "rgb(11,15,20)", | |
| 4982 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"High confidence overall — comp\"", | |
| 4983 | + "n": 1 | |
| 4984 | + }, | |
| 4985 | + { | |
| 4986 | + "ratio": 3.07, | |
| 4987 | + "color": "rgb(91, 97, 104)", | |
| 4988 | + "bg": "rgb(11,15,20)", | |
| 4989 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 4990 | + "n": 2 | |
| 4991 | + }, | |
| 4992 | + { | |
| 4993 | + "ratio": 3.07, | |
| 4994 | + "color": "rgb(91, 97, 104)", | |
| 4995 | + "bg": "rgb(11,15,20)", | |
| 4996 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Cloud provider\"", | |
| 4997 | + "n": 2 | |
| 4998 | + }, | |
| 4999 | + { | |
| 5000 | + "ratio": 3.07, | |
| 5001 | + "color": "rgb(91, 97, 104)", | |
| 5002 | + "bg": "rgb(11,15,20)", | |
| 5003 | + "sample": "span.text-ink-4 \"· Publicly published operator \"", | |
| 5004 | + "n": 2 | |
| 5005 | + }, | |
| 5006 | + { | |
| 5007 | + "ratio": 3.07, | |
| 5008 | + "color": "rgb(91, 97, 104)", | |
| 5009 | + "bg": "rgb(11,15,20)", | |
| 5010 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 5011 | + "n": 1 | |
| 5012 | + } | |
| 5013 | + ], | |
| 5014 | + "contrastChecked": 629, | |
| 5015 | + "height": 6882, | |
| 5016 | + "title": "Google The Dalles, Oregon data center (The Dalles, US) · DataCenterIndex", | |
| 5017 | + "errors": [], | |
| 5018 | + "badResponses": [] | |
| 5019 | + }, | |
| 5020 | + { | |
| 5021 | + "name": "fac-wikidata", | |
| 5022 | + "path": "/datacenters/foulum-data-center", | |
| 5023 | + "w": 1280, | |
| 5024 | + "theme": "dark", | |
| 5025 | + "status": 200, | |
| 5026 | + "ttfb": 9, | |
| 5027 | + "overflow": null, | |
| 5028 | + "clipped": [ | |
| 5029 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5030 | + "span.sr-only \"Denmark\" 58>1", | |
| 5031 | + "span.sr-only \"Denmark\" 56>1", | |
| 5032 | + "span.sr-only \"Denmark\" 60>1" | |
| 5033 | + ], | |
| 5034 | + "small": [], | |
| 5035 | + "smallCount": 0, | |
| 5036 | + "h1Covered": false, | |
| 5037 | + "h1Top": 135, | |
| 5038 | + "headerBottom": 57, | |
| 5039 | + "lang": "en", | |
| 5040 | + "metaDesc": 158, | |
| 5041 | + "h1Count": 1, | |
| 5042 | + "skipped": [], | |
| 5043 | + "unnamed": [], | |
| 5044 | + "ariaCurrent": 1, | |
| 5045 | + "footerOk": true, | |
| 5046 | + "contrast": [ | |
| 5047 | + { | |
| 5048 | + "ratio": 3.07, | |
| 5049 | + "color": "rgb(91, 97, 104)", | |
| 5050 | + "bg": "rgb(11,15,20)", | |
| 5051 | + "sample": "span.figure.text-[11px].text-ink-4 \"15\"", | |
| 5052 | + "n": 2 | |
| 5053 | + }, | |
| 5054 | + { | |
| 5055 | + "ratio": 3.07, | |
| 5056 | + "color": "rgb(91, 97, 104)", | |
| 5057 | + "bg": "rgb(11,15,20)", | |
| 5058 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Moderate confidence overall — \"", | |
| 5059 | + "n": 1 | |
| 5060 | + }, | |
| 5061 | + { | |
| 5062 | + "ratio": 3.07, | |
| 5063 | + "color": "rgb(91, 97, 104)", | |
| 5064 | + "bg": "rgb(11,15,20)", | |
| 5065 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"How confidence is computed\"", | |
| 5066 | + "n": 2 | |
| 5067 | + }, | |
| 5068 | + { | |
| 5069 | + "ratio": 3.07, | |
| 5070 | + "color": "rgb(91, 97, 104)", | |
| 5071 | + "bg": "rgb(11,15,20)", | |
| 5072 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Community\"", | |
| 5073 | + "n": 2 | |
| 5074 | + }, | |
| 5075 | + { | |
| 5076 | + "ratio": 3.07, | |
| 5077 | + "color": "rgb(91, 97, 104)", | |
| 5078 | + "bg": "rgb(11,15,20)", | |
| 5079 | + "sample": "span.text-ink-4 \"· ODbL 1.0 — © OpenStreetMap c\"", | |
| 5080 | + "n": 2 | |
| 5081 | + }, | |
| 5082 | + { | |
| 5083 | + "ratio": 3.07, | |
| 5084 | + "color": "rgb(91, 97, 104)", | |
| 5085 | + "bg": "rgb(11,15,20)", | |
| 5086 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 5087 | + "n": 1 | |
| 5088 | + } | |
| 5089 | + ], | |
| 5090 | + "contrastChecked": 350, | |
| 5091 | + "height": 3868, | |
| 5092 | + "title": "Foulum Data Center (Viborg Municipality, DK) · DataCenterIndex", | |
| 5093 | + "errors": [], | |
| 5094 | + "badResponses": [] | |
| 5095 | + }, | |
| 5096 | + { | |
| 5097 | + "name": "operators", | |
| 5098 | + "path": "/operators", | |
| 5099 | + "w": 1280, | |
| 5100 | + "theme": "dark", | |
| 5101 | + "status": 200, | |
| 5102 | + "ttfb": 10, | |
| 5103 | + "overflow": null, | |
| 5104 | + "clipped": [ | |
| 5105 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5106 | + "span.sr-only \"Search operators\" 114>1" | |
| 5107 | + ], | |
| 5108 | + "small": [], | |
| 5109 | + "smallCount": 0, | |
| 5110 | + "h1Covered": false, | |
| 5111 | + "h1Top": 106, | |
| 5112 | + "headerBottom": 57, | |
| 5113 | + "lang": "en", | |
| 5114 | + "metaDesc": 139, | |
| 5115 | + "h1Count": 1, | |
| 5116 | + "skipped": [], | |
| 5117 | + "unnamed": [], | |
| 5118 | + "ariaCurrent": 2, | |
| 5119 | + "footerOk": true, | |
| 5120 | + "contrast": [], | |
| 5121 | + "contrastChecked": 300, | |
| 5122 | + "height": 2622, | |
| 5123 | + "title": "Operators · DataCenterIndex", | |
| 5124 | + "errors": [], | |
| 5125 | + "badResponses": [] | |
| 5126 | + }, | |
| 5127 | + { | |
| 5128 | + "name": "operator-equinix", | |
| 5129 | + "path": "/operators/equinix", | |
| 5130 | + "w": 1280, | |
| 5131 | + "theme": "dark", | |
| 5132 | + "status": 200, | |
| 5133 | + "ttfb": 15, | |
| 5134 | + "overflow": null, | |
| 5135 | + "clipped": [ | |
| 5136 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5137 | + "span.sr-only \"Finland\" 45>1", | |
| 5138 | + "span.sr-only \"Germany\" 56>1", | |
| 5139 | + "span.sr-only \"Italy\" 26>1", | |
| 5140 | + "span.sr-only \"Switzerland\" 72>1", | |
| 5141 | + "span.sr-only \"United States\" 82>1", | |
| 5142 | + "span.sr-only \"Australia\" 54>1", | |
| 5143 | + "span.sr-only \"Ireland\" 42>1", | |
| 5144 | + "span.sr-only \"Netherlands\" 75>1" | |
| 5145 | + ], | |
| 5146 | + "small": [], | |
| 5147 | + "smallCount": 0, | |
| 5148 | + "h1Covered": false, | |
| 5149 | + "h1Top": 138, | |
| 5150 | + "headerBottom": 57, | |
| 5151 | + "lang": "en", | |
| 5152 | + "metaDesc": 170, | |
| 5153 | + "h1Count": 1, | |
| 5154 | + "skipped": [], | |
| 5155 | + "unnamed": [], | |
| 5156 | + "ariaCurrent": 2, | |
| 5157 | + "footerOk": true, | |
| 5158 | + "contrast": [ | |
| 5159 | + { | |
| 5160 | + "ratio": 3.07, | |
| 5161 | + "color": "rgb(91, 97, 104)", | |
| 5162 | + "bg": "rgb(11,15,20)", | |
| 5163 | + "sample": "span.figure.text-[11px].text-ink-4 \"25\"", | |
| 5164 | + "n": 5 | |
| 5165 | + }, | |
| 5166 | + { | |
| 5167 | + "ratio": 3.07, | |
| 5168 | + "color": "rgb(91, 97, 104)", | |
| 5169 | + "bg": "rgb(11,15,20)", | |
| 5170 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 5171 | + "n": 1 | |
| 5172 | + }, | |
| 5173 | + { | |
| 5174 | + "ratio": 3.07, | |
| 5175 | + "color": "rgb(91, 97, 104)", | |
| 5176 | + "bg": "rgb(11,15,20)", | |
| 5177 | + "sample": "span.figure.text-ink-4 \"93%\"", | |
| 5178 | + "n": 1 | |
| 5179 | + }, | |
| 5180 | + { | |
| 5181 | + "ratio": 3.07, | |
| 5182 | + "color": "rgb(91, 97, 104)", | |
| 5183 | + "bg": "rgb(11,15,20)", | |
| 5184 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"Operator\"", | |
| 5185 | + "n": 10 | |
| 5186 | + }, | |
| 5187 | + { | |
| 5188 | + "ratio": 3.07, | |
| 5189 | + "color": "rgb(91, 97, 104)", | |
| 5190 | + "bg": "rgb(11,15,20)", | |
| 5191 | + "sample": "span.text-ink-4 \"· Publicly accessible operator\"", | |
| 5192 | + "n": 10 | |
| 5193 | + }, | |
| 5194 | + { | |
| 5195 | + "ratio": 3.07, | |
| 5196 | + "color": "rgb(91, 97, 104)", | |
| 5197 | + "bg": "rgb(11,15,20)", | |
| 5198 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 5199 | + "n": 1 | |
| 5200 | + }, | |
| 5201 | + { | |
| 5202 | + "ratio": 3.07, | |
| 5203 | + "color": "rgb(91, 97, 104)", | |
| 5204 | + "bg": "rgb(11,15,20)", | |
| 5205 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5206 | + "n": 1 | |
| 5207 | + } | |
| 5208 | + ], | |
| 5209 | + "contrastChecked": 693, | |
| 5210 | + "height": 7121, | |
| 5211 | + "title": "Equinix · DataCenterIndex", | |
| 5212 | + "errors": [], | |
| 5213 | + "badResponses": [] | |
| 5214 | + }, | |
| 5215 | + { | |
| 5216 | + "name": "countries", | |
| 5217 | + "path": "/countries", | |
| 5218 | + "w": 1280, | |
| 5219 | + "theme": "dark", | |
| 5220 | + "status": 200, | |
| 5221 | + "ttfb": 17, | |
| 5222 | + "overflow": null, | |
| 5223 | + "clipped": [ | |
| 5224 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5225 | + "span.sr-only \"United States\" 83>1", | |
| 5226 | + "span.sr-only \"Brazil\" 35>1", | |
| 5227 | + "span.sr-only \"Argentina\" 60>1", | |
| 5228 | + "span.sr-only \"Canada\" 48>1", | |
| 5229 | + "span.sr-only \"Mexico\" 45>1", | |
| 5230 | + "span.sr-only \"Chile\" 32>1", | |
| 5231 | + "span.sr-only \"Venezuela\" 65>1", | |
| 5232 | + "span.sr-only \"Peru\" 29>1" | |
| 5233 | + ], | |
| 5234 | + "small": [], | |
| 5235 | + "smallCount": 0, | |
| 5236 | + "h1Covered": false, | |
| 5237 | + "h1Top": 106, | |
| 5238 | + "headerBottom": 57, | |
| 5239 | + "lang": "en", | |
| 5240 | + "metaDesc": 117, | |
| 5241 | + "h1Count": 1, | |
| 5242 | + "skipped": [], | |
| 5243 | + "unnamed": [], | |
| 5244 | + "ariaCurrent": 2, | |
| 5245 | + "footerOk": true, | |
| 5246 | + "contrast": [ | |
| 5247 | + { | |
| 5248 | + "ratio": 3.07, | |
| 5249 | + "color": "rgb(91, 97, 104)", | |
| 5250 | + "bg": "rgb(11,15,20)", | |
| 5251 | + "sample": "span.hidden.text-[10.5px].text-ink-4 \"30% coverage\"", | |
| 5252 | + "n": 28 | |
| 5253 | + }, | |
| 5254 | + { | |
| 5255 | + "ratio": 3.07, | |
| 5256 | + "color": "rgb(91, 97, 104)", | |
| 5257 | + "bg": "rgb(11,15,20)", | |
| 5258 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Per-million uses the latest Wo\"", | |
| 5259 | + "n": 1 | |
| 5260 | + }, | |
| 5261 | + { | |
| 5262 | + "ratio": 3.07, | |
| 5263 | + "color": "rgb(91, 97, 104)", | |
| 5264 | + "bg": "rgb(11,15,20)", | |
| 5265 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"methodology\"", | |
| 5266 | + "n": 1 | |
| 5267 | + } | |
| 5268 | + ], | |
| 5269 | + "contrastChecked": 612, | |
| 5270 | + "height": 5414, | |
| 5271 | + "title": "Countries · DataCenterIndex", | |
| 5272 | + "errors": [], | |
| 5273 | + "badResponses": [] | |
| 5274 | + }, | |
| 5275 | + { | |
| 5276 | + "name": "country-us", | |
| 5277 | + "path": "/countries/united-states", | |
| 5278 | + "w": 1280, | |
| 5279 | + "theme": "dark", | |
| 5280 | + "status": 200, | |
| 5281 | + "ttfb": 23, | |
| 5282 | + "overflow": null, | |
| 5283 | + "clipped": [ | |
| 5284 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5285 | + "span.sr-only \"United States\" 187>1", | |
| 5286 | + "span.sr-only \"United States\" 76>1", | |
| 5287 | + "span.sr-only \"United States\" 76>1", | |
| 5288 | + "span.sr-only \"United States\" 76>1", | |
| 5289 | + "span.sr-only \"United States\" 76>1", | |
| 5290 | + "span.sr-only \"United States\" 76>1", | |
| 5291 | + "span.sr-only \"United States\" 76>1", | |
| 5292 | + "span.sr-only \"United States\" 76>1" | |
| 5293 | + ], | |
| 5294 | + "small": [], | |
| 5295 | + "smallCount": 0, | |
| 5296 | + "h1Covered": false, | |
| 5297 | + "h1Top": 136, | |
| 5298 | + "headerBottom": 57, | |
| 5299 | + "lang": "en", | |
| 5300 | + "metaDesc": 258, | |
| 5301 | + "h1Count": 1, | |
| 5302 | + "skipped": [], | |
| 5303 | + "unnamed": [], | |
| 5304 | + "ariaCurrent": 2, | |
| 5305 | + "footerOk": true, | |
| 5306 | + "contrast": [ | |
| 5307 | + { | |
| 5308 | + "ratio": 2.91, | |
| 5309 | + "color": "rgb(91, 97, 104)", | |
| 5310 | + "bg": "rgb(16,22,29)", | |
| 5311 | + "sample": "span.text-ink-4 \"/46\"", | |
| 5312 | + "n": 5 | |
| 5313 | + }, | |
| 5314 | + { | |
| 5315 | + "ratio": 3.07, | |
| 5316 | + "color": "rgb(91, 97, 104)", | |
| 5317 | + "bg": "rgb(11,15,20)", | |
| 5318 | + "sample": "span.text-[11px].text-ink-4 \"World Bank\"", | |
| 5319 | + "n": 1 | |
| 5320 | + }, | |
| 5321 | + { | |
| 5322 | + "ratio": 3.07, | |
| 5323 | + "color": "rgb(91, 97, 104)", | |
| 5324 | + "bg": "rgb(11,15,20)", | |
| 5325 | + "sample": "span.figure.text-[11px].text-ink-4 \"460\"", | |
| 5326 | + "n": 9 | |
| 5327 | + }, | |
| 5328 | + { | |
| 5329 | + "ratio": 3.07, | |
| 5330 | + "color": "rgb(91, 97, 104)", | |
| 5331 | + "bg": "rgb(11,15,20)", | |
| 5332 | + "sample": "span.figure.text-ink-4 \"90%\"", | |
| 5333 | + "n": 1 | |
| 5334 | + }, | |
| 5335 | + { | |
| 5336 | + "ratio": 3.07, | |
| 5337 | + "color": "rgb(91, 97, 104)", | |
| 5338 | + "bg": "rgb(11,15,20)", | |
| 5339 | + "sample": "span.figure.w-5.text-right \"10\"", | |
| 5340 | + "n": 1 | |
| 5341 | + }, | |
| 5342 | + { | |
| 5343 | + "ratio": 3.07, | |
| 5344 | + "color": "rgb(91, 97, 104)", | |
| 5345 | + "bg": "rgb(11,15,20)", | |
| 5346 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Source: World Bank, World Deve\"", | |
| 5347 | + "n": 1 | |
| 5348 | + }, | |
| 5349 | + { | |
| 5350 | + "ratio": 3.07, | |
| 5351 | + "color": "rgb(91, 97, 104)", | |
| 5352 | + "bg": "rgb(11,15,20)", | |
| 5353 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Country totals are lower bound\"", | |
| 5354 | + "n": 1 | |
| 5355 | + }, | |
| 5356 | + { | |
| 5357 | + "ratio": 3.07, | |
| 5358 | + "color": "rgb(91, 97, 104)", | |
| 5359 | + "bg": "rgb(11,15,20)", | |
| 5360 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5361 | + "n": 1 | |
| 5362 | + } | |
| 5363 | + ], | |
| 5364 | + "contrastChecked": 1218, | |
| 5365 | + "height": 11218, | |
| 5366 | + "title": "Data Centers in United States · DataCenterIndex", | |
| 5367 | + "errors": [], | |
| 5368 | + "badResponses": [] | |
| 5369 | + }, | |
| 5370 | + { | |
| 5371 | + "name": "country-ca", | |
| 5372 | + "path": "/countries/canada", | |
| 5373 | + "w": 1280, | |
| 5374 | + "theme": "dark", | |
| 5375 | + "status": 200, | |
| 5376 | + "ttfb": 12, | |
| 5377 | + "overflow": null, | |
| 5378 | + "clipped": [ | |
| 5379 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5380 | + "span.sr-only \"Canada\" 107>1", | |
| 5381 | + "span.sr-only \"Canada\" 43>1", | |
| 5382 | + "span.sr-only \"Canada\" 43>1", | |
| 5383 | + "span.sr-only \"Canada\" 51>1", | |
| 5384 | + "span.sr-only \"Canada\" 51>1", | |
| 5385 | + "span.sr-only \"Canada\" 51>1", | |
| 5386 | + "span.sr-only \"Canada\" 51>1", | |
| 5387 | + "span.sr-only \"Canada\" 51>1" | |
| 5388 | + ], | |
| 5389 | + "small": [], | |
| 5390 | + "smallCount": 0, | |
| 5391 | + "h1Covered": false, | |
| 5392 | + "h1Top": 136, | |
| 5393 | + "headerBottom": 57, | |
| 5394 | + "lang": "en", | |
| 5395 | + "metaDesc": 186, | |
| 5396 | + "h1Count": 1, | |
| 5397 | + "skipped": [], | |
| 5398 | + "unnamed": [], | |
| 5399 | + "ariaCurrent": 2, | |
| 5400 | + "footerOk": true, | |
| 5401 | + "contrast": [ | |
| 5402 | + { | |
| 5403 | + "ratio": 2.91, | |
| 5404 | + "color": "rgb(91, 97, 104)", | |
| 5405 | + "bg": "rgb(16,22,29)", | |
| 5406 | + "sample": "span.text-ink-4 \"/46\"", | |
| 5407 | + "n": 4 | |
| 5408 | + }, | |
| 5409 | + { | |
| 5410 | + "ratio": 3.07, | |
| 5411 | + "color": "rgb(91, 97, 104)", | |
| 5412 | + "bg": "rgb(11,15,20)", | |
| 5413 | + "sample": "span.text-[11px].text-ink-4 \"World Bank\"", | |
| 5414 | + "n": 1 | |
| 5415 | + }, | |
| 5416 | + { | |
| 5417 | + "ratio": 3.07, | |
| 5418 | + "color": "rgb(91, 97, 104)", | |
| 5419 | + "bg": "rgb(11,15,20)", | |
| 5420 | + "sample": "span.figure.text-[11px].text-ink-4 \"21\"", | |
| 5421 | + "n": 5 | |
| 5422 | + }, | |
| 5423 | + { | |
| 5424 | + "ratio": 3.07, | |
| 5425 | + "color": "rgb(91, 97, 104)", | |
| 5426 | + "bg": "rgb(11,15,20)", | |
| 5427 | + "sample": "span.figure.text-ink-4 \"76%\"", | |
| 5428 | + "n": 2 | |
| 5429 | + }, | |
| 5430 | + { | |
| 5431 | + "ratio": 3.07, | |
| 5432 | + "color": "rgb(91, 97, 104)", | |
| 5433 | + "bg": "rgb(11,15,20)", | |
| 5434 | + "sample": "p.mt-2.text-[11.5px].text-ink-4 \"Source: World Bank, World Deve\"", | |
| 5435 | + "n": 1 | |
| 5436 | + }, | |
| 5437 | + { | |
| 5438 | + "ratio": 3.07, | |
| 5439 | + "color": "rgb(91, 97, 104)", | |
| 5440 | + "bg": "rgb(11,15,20)", | |
| 5441 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Country totals are lower bound\"", | |
| 5442 | + "n": 1 | |
| 5443 | + }, | |
| 5444 | + { | |
| 5445 | + "ratio": 3.07, | |
| 5446 | + "color": "rgb(91, 97, 104)", | |
| 5447 | + "bg": "rgb(11,15,20)", | |
| 5448 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5449 | + "n": 1 | |
| 5450 | + } | |
| 5451 | + ], | |
| 5452 | + "contrastChecked": 471, | |
| 5453 | + "height": 5842, | |
| 5454 | + "title": "Data Centers in Canada · DataCenterIndex", | |
| 5455 | + "errors": [], | |
| 5456 | + "badResponses": [] | |
| 5457 | + }, | |
| 5458 | + { | |
| 5459 | + "name": "markets", | |
| 5460 | + "path": "/datacenters/markets", | |
| 5461 | + "w": 1280, | |
| 5462 | + "theme": "dark", | |
| 5463 | + "status": 200, | |
| 5464 | + "ttfb": 13, | |
| 5465 | + "overflow": null, | |
| 5466 | + "clipped": [ | |
| 5467 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 5468 | + ], | |
| 5469 | + "small": [], | |
| 5470 | + "smallCount": 0, | |
| 5471 | + "h1Covered": false, | |
| 5472 | + "h1Top": 136, | |
| 5473 | + "headerBottom": 57, | |
| 5474 | + "lang": "en", | |
| 5475 | + "metaDesc": 125, | |
| 5476 | + "h1Count": 1, | |
| 5477 | + "skipped": [], | |
| 5478 | + "unnamed": [], | |
| 5479 | + "ariaCurrent": 2, | |
| 5480 | + "footerOk": true, | |
| 5481 | + "contrast": [ | |
| 5482 | + { | |
| 5483 | + "ratio": 3.07, | |
| 5484 | + "color": "rgb(91, 97, 104)", | |
| 5485 | + "bg": "rgb(11,15,20)", | |
| 5486 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Facilities are assigned to a m\"", | |
| 5487 | + "n": 1 | |
| 5488 | + }, | |
| 5489 | + { | |
| 5490 | + "ratio": 3.07, | |
| 5491 | + "color": "rgb(91, 97, 104)", | |
| 5492 | + "bg": "rgb(11,15,20)", | |
| 5493 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5494 | + "n": 1 | |
| 5495 | + } | |
| 5496 | + ], | |
| 5497 | + "contrastChecked": 374, | |
| 5498 | + "height": 2693, | |
| 5499 | + "title": "Data center markets · DataCenterIndex", | |
| 5500 | + "errors": [], | |
| 5501 | + "badResponses": [] | |
| 5502 | + }, | |
| 5503 | + { | |
| 5504 | + "name": "market-nova", | |
| 5505 | + "path": "/datacenters/markets/northern-virginia", | |
| 5506 | + "w": 1280, | |
| 5507 | + "theme": "dark", | |
| 5508 | + "status": 200, | |
| 5509 | + "ttfb": 14, | |
| 5510 | + "overflow": null, | |
| 5511 | + "clipped": [ | |
| 5512 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5513 | + "span.sr-only \"United States\" 82>1", | |
| 5514 | + "span.sr-only \"United States\" 76>1", | |
| 5515 | + "span.sr-only \"United States\" 89>1", | |
| 5516 | + "span.sr-only \"United States\" 89>1", | |
| 5517 | + "span.sr-only \"United States\" 89>1", | |
| 5518 | + "span.sr-only \"United States\" 89>1", | |
| 5519 | + "span.sr-only \"United States\" 89>1", | |
| 5520 | + "span.sr-only \"United States\" 89>1" | |
| 5521 | + ], | |
| 5522 | + "small": [], | |
| 5523 | + "smallCount": 0, | |
| 5524 | + "h1Covered": false, | |
| 5525 | + "h1Top": 136, | |
| 5526 | + "headerBottom": 57, | |
| 5527 | + "lang": "en", | |
| 5528 | + "metaDesc": 250, | |
| 5529 | + "h1Count": 1, | |
| 5530 | + "skipped": [], | |
| 5531 | + "unnamed": [], | |
| 5532 | + "ariaCurrent": 2, | |
| 5533 | + "footerOk": true, | |
| 5534 | + "contrast": [ | |
| 5535 | + { | |
| 5536 | + "ratio": 2.91, | |
| 5537 | + "color": "rgb(91, 97, 104)", | |
| 5538 | + "bg": "rgb(16,22,29)", | |
| 5539 | + "sample": "span.figure.text-ink-4 \"/ 152\"", | |
| 5540 | + "n": 4 | |
| 5541 | + }, | |
| 5542 | + { | |
| 5543 | + "ratio": 3.07, | |
| 5544 | + "color": "rgb(91, 97, 104)", | |
| 5545 | + "bg": "rgb(11,15,20)", | |
| 5546 | + "sample": "span.figure.text-[11px].text-ink-4 \"10\"", | |
| 5547 | + "n": 3 | |
| 5548 | + }, | |
| 5549 | + { | |
| 5550 | + "ratio": 3.07, | |
| 5551 | + "color": "rgb(91, 97, 104)", | |
| 5552 | + "bg": "rgb(11,15,20)", | |
| 5553 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 5554 | + "n": 1 | |
| 5555 | + }, | |
| 5556 | + { | |
| 5557 | + "ratio": 3.07, | |
| 5558 | + "color": "rgb(91, 97, 104)", | |
| 5559 | + "bg": "rgb(11,15,20)", | |
| 5560 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5561 | + "n": 1 | |
| 5562 | + } | |
| 5563 | + ], | |
| 5564 | + "contrastChecked": 565, | |
| 5565 | + "height": 6519, | |
| 5566 | + "title": "Data Centers in Northern Virginia · DataCenterIndex", | |
| 5567 | + "errors": [], | |
| 5568 | + "badResponses": [] | |
| 5569 | + }, | |
| 5570 | + { | |
| 5571 | + "name": "projects", | |
| 5572 | + "path": "/projects", | |
| 5573 | + "w": 1280, | |
| 5574 | + "theme": "dark", | |
| 5575 | + "status": 200, | |
| 5576 | + "ttfb": 15, | |
| 5577 | + "overflow": null, | |
| 5578 | + "clipped": [ | |
| 5579 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5580 | + "span.sr-only \"United States\" 82>1", | |
| 5581 | + "span.sr-only \"United States\" 82>1", | |
| 5582 | + "span.sr-only \"Australia\" 54>1", | |
| 5583 | + "span.sr-only \"Canada\" 47>1", | |
| 5584 | + "span.sr-only \"United States\" 82>1", | |
| 5585 | + "span.sr-only \"Norway\" 48>1" | |
| 5586 | + ], | |
| 5587 | + "small": [], | |
| 5588 | + "smallCount": 0, | |
| 5589 | + "h1Covered": false, | |
| 5590 | + "h1Top": 106, | |
| 5591 | + "headerBottom": 57, | |
| 5592 | + "lang": "en", | |
| 5593 | + "metaDesc": 130, | |
| 5594 | + "h1Count": 1, | |
| 5595 | + "skipped": [], | |
| 5596 | + "unnamed": [], | |
| 5597 | + "ariaCurrent": 3, | |
| 5598 | + "footerOk": true, | |
| 5599 | + "contrast": [ | |
| 5600 | + { | |
| 5601 | + "ratio": 3.07, | |
| 5602 | + "color": "rgb(91, 97, 104)", | |
| 5603 | + "bg": "rgb(11,15,20)", | |
| 5604 | + "sample": "span.figure.text-ink-4 \"19%\"", | |
| 5605 | + "n": 3 | |
| 5606 | + }, | |
| 5607 | + { | |
| 5608 | + "ratio": 3.07, | |
| 5609 | + "color": "rgb(91, 97, 104)", | |
| 5610 | + "bg": "rgb(11,15,20)", | |
| 5611 | + "sample": "span.text-ink-4 \"TBD\"", | |
| 5612 | + "n": 50 | |
| 5613 | + }, | |
| 5614 | + { | |
| 5615 | + "ratio": 3.07, | |
| 5616 | + "color": "rgb(91, 97, 104)", | |
| 5617 | + "bg": "rgb(11,15,20)", | |
| 5618 | + "sample": "p.mt-6.text-[11.5px].text-ink-4 \"Projects are reconciled from o\"", | |
| 5619 | + "n": 1 | |
| 5620 | + }, | |
| 5621 | + { | |
| 5622 | + "ratio": 3.07, | |
| 5623 | + "color": "rgb(91, 97, 104)", | |
| 5624 | + "bg": "rgb(11,15,20)", | |
| 5625 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"live feed\"", | |
| 5626 | + "n": 1 | |
| 5627 | + } | |
| 5628 | + ], | |
| 5629 | + "contrastChecked": 473, | |
| 5630 | + "height": 3977, | |
| 5631 | + "title": "Data center projects · DataCenterIndex", | |
| 5632 | + "errors": [], | |
| 5633 | + "badResponses": [] | |
| 5634 | + }, | |
| 5635 | + { | |
| 5636 | + "name": "project", | |
| 5637 | + "path": "/projects/sydney-data-center-project", | |
| 5638 | + "w": 1280, | |
| 5639 | + "theme": "dark", | |
| 5640 | + "status": 200, | |
| 5641 | + "ttfb": 7, | |
| 5642 | + "overflow": null, | |
| 5643 | + "clipped": [ | |
| 5644 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5645 | + "span.sr-only \"Australia\" 54>1", | |
| 5646 | + "span.sr-only \"Australia\" 54>1", | |
| 5647 | + "span.sr-only \"Australia\" 58>1" | |
| 5648 | + ], | |
| 5649 | + "small": [], | |
| 5650 | + "smallCount": 0, | |
| 5651 | + "h1Covered": false, | |
| 5652 | + "h1Top": 136, | |
| 5653 | + "headerBottom": 57, | |
| 5654 | + "lang": "en", | |
| 5655 | + "metaDesc": 201, | |
| 5656 | + "h1Count": 1, | |
| 5657 | + "skipped": [], | |
| 5658 | + "unnamed": [], | |
| 5659 | + "ariaCurrent": 2, | |
| 5660 | + "footerOk": true, | |
| 5661 | + "contrast": [ | |
| 5662 | + { | |
| 5663 | + "ratio": 3.07, | |
| 5664 | + "color": "rgb(91, 97, 104)", | |
| 5665 | + "bg": "rgb(11,15,20)", | |
| 5666 | + "sample": "span.figure.text-[11px].text-ink-4 \"10\"", | |
| 5667 | + "n": 2 | |
| 5668 | + }, | |
| 5669 | + { | |
| 5670 | + "ratio": 3.07, | |
| 5671 | + "color": "rgb(91, 97, 104)", | |
| 5672 | + "bg": "rgb(11,15,20)", | |
| 5673 | + "sample": "span.shrink-0.text-[11px].text-ink-4 \"News\"", | |
| 5674 | + "n": 1 | |
| 5675 | + }, | |
| 5676 | + { | |
| 5677 | + "ratio": 3.07, | |
| 5678 | + "color": "rgb(91, 97, 104)", | |
| 5679 | + "bg": "rgb(11,15,20)", | |
| 5680 | + "sample": "span.text-ink-4 \"· Headlines, dates, facts and \"", | |
| 5681 | + "n": 1 | |
| 5682 | + }, | |
| 5683 | + { | |
| 5684 | + "ratio": 3.07, | |
| 5685 | + "color": "rgb(91, 97, 104)", | |
| 5686 | + "bg": "rgb(11,15,20)", | |
| 5687 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Figures are lower bounds compu\"", | |
| 5688 | + "n": 1 | |
| 5689 | + }, | |
| 5690 | + { | |
| 5691 | + "ratio": 3.07, | |
| 5692 | + "color": "rgb(91, 97, 104)", | |
| 5693 | + "bg": "rgb(11,15,20)", | |
| 5694 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5695 | + "n": 1 | |
| 5696 | + } | |
| 5697 | + ], | |
| 5698 | + "contrastChecked": 252, | |
| 5699 | + "height": 2902, | |
| 5700 | + "title": "Sydney data center project · DataCenterIndex", | |
| 5701 | + "errors": [], | |
| 5702 | + "badResponses": [] | |
| 5703 | + }, | |
| 5704 | + { | |
| 5705 | + "name": "rankings", | |
| 5706 | + "path": "/rankings", | |
| 5707 | + "w": 1280, | |
| 5708 | + "theme": "dark", | |
| 5709 | + "status": 200, | |
| 5710 | + "ttfb": 3, | |
| 5711 | + "overflow": null, | |
| 5712 | + "clipped": [ | |
| 5713 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 5714 | + ], | |
| 5715 | + "small": [], | |
| 5716 | + "smallCount": 0, | |
| 5717 | + "h1Covered": false, | |
| 5718 | + "h1Top": 106, | |
| 5719 | + "headerBottom": 57, | |
| 5720 | + "lang": "en", | |
| 5721 | + "metaDesc": 190, | |
| 5722 | + "h1Count": 1, | |
| 5723 | + "skipped": [], | |
| 5724 | + "unnamed": [], | |
| 5725 | + "ariaCurrent": 2, | |
| 5726 | + "footerOk": true, | |
| 5727 | + "contrast": [ | |
| 5728 | + { | |
| 5729 | + "ratio": 3.07, | |
| 5730 | + "color": "rgb(91, 97, 104)", | |
| 5731 | + "bg": "rgb(11,15,20)", | |
| 5732 | + "sample": "span.figure.text-[11px].text-ink-4 \"11\"", | |
| 5733 | + "n": 1 | |
| 5734 | + }, | |
| 5735 | + { | |
| 5736 | + "ratio": 3.07, | |
| 5737 | + "color": "rgb(91, 97, 104)", | |
| 5738 | + "bg": "rgb(11,15,20)", | |
| 5739 | + "sample": "span.text-ink-4 \"No rows computed yet\"", | |
| 5740 | + "n": 3 | |
| 5741 | + } | |
| 5742 | + ], | |
| 5743 | + "contrastChecked": 182, | |
| 5744 | + "height": 3333, | |
| 5745 | + "title": "Rankings · DataCenterIndex", | |
| 5746 | + "errors": [], | |
| 5747 | + "badResponses": [] | |
| 5748 | + }, | |
| 5749 | + { | |
| 5750 | + "name": "ranking", | |
| 5751 | + "path": "/rankings/countries_facilities", | |
| 5752 | + "w": 1280, | |
| 5753 | + "theme": "dark", | |
| 5754 | + "status": 200, | |
| 5755 | + "ttfb": 13, | |
| 5756 | + "overflow": null, | |
| 5757 | + "clipped": [ | |
| 5758 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5759 | + "span.sr-only \"United States\" 82>1", | |
| 5760 | + "span.sr-only \"France\" 42>1", | |
| 5761 | + "span.sr-only \"United Kingdom\" 99>1", | |
| 5762 | + "span.sr-only \"Germany\" 56>1", | |
| 5763 | + "span.sr-only \"Australia\" 54>1", | |
| 5764 | + "span.sr-only \"Netherlands\" 75>1", | |
| 5765 | + "span.sr-only \"India\" 30>1", | |
| 5766 | + "span.sr-only \"Singapore\" 62>1" | |
| 5767 | + ], | |
| 5768 | + "small": [], | |
| 5769 | + "smallCount": 0, | |
| 5770 | + "h1Covered": false, | |
| 5771 | + "h1Top": 136, | |
| 5772 | + "headerBottom": 57, | |
| 5773 | + "lang": "en", | |
| 5774 | + "metaDesc": 161, | |
| 5775 | + "h1Count": 1, | |
| 5776 | + "skipped": [ | |
| 5777 | + "h1→h3" | |
| 5778 | + ], | |
| 5779 | + "unnamed": [], | |
| 5780 | + "ariaCurrent": 2, | |
| 5781 | + "footerOk": true, | |
| 5782 | + "contrast": [], | |
| 5783 | + "contrastChecked": 626, | |
| 5784 | + "height": 4578, | |
| 5785 | + "title": "Countries by facility count · DataCenterIndex", | |
| 5786 | + "errors": [], | |
| 5787 | + "badResponses": [] | |
| 5788 | + }, | |
| 5789 | + { | |
| 5790 | + "name": "cloud-regions", | |
| 5791 | + "path": "/cloud-regions", | |
| 5792 | + "w": 1280, | |
| 5793 | + "theme": "dark", | |
| 5794 | + "status": 200, | |
| 5795 | + "ttfb": 30, | |
| 5796 | + "overflow": null, | |
| 5797 | + "clipped": [ | |
| 5798 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5799 | + "label.sr-only \"Provider\" 55>1", | |
| 5800 | + "label.sr-only \"Country\" 53>1", | |
| 5801 | + "label.sr-only \"Status\" 42>1" | |
| 5802 | + ], | |
| 5803 | + "small": [], | |
| 5804 | + "smallCount": 0, | |
| 5805 | + "h1Covered": false, | |
| 5806 | + "h1Top": 106, | |
| 5807 | + "headerBottom": 57, | |
| 5808 | + "lang": "en", | |
| 5809 | + "metaDesc": 155, | |
| 5810 | + "h1Count": 1, | |
| 5811 | + "skipped": [], | |
| 5812 | + "unnamed": [], | |
| 5813 | + "ariaCurrent": 0, | |
| 5814 | + "footerOk": true, | |
| 5815 | + "contrast": [ | |
| 5816 | + { | |
| 5817 | + "ratio": 3.07, | |
| 5818 | + "color": "rgb(91, 97, 104)", | |
| 5819 | + "bg": "rgb(11,15,20)", | |
| 5820 | + "sample": "span.figure.text-[11px].text-ink-4 \"79\"", | |
| 5821 | + "n": 7 | |
| 5822 | + } | |
| 5823 | + ], | |
| 5824 | + "contrastChecked": 1501, | |
| 5825 | + "height": 12906, | |
| 5826 | + "title": "Cloud regions · DataCenterIndex", | |
| 5827 | + "errors": [], | |
| 5828 | + "badResponses": [] | |
| 5829 | + }, | |
| 5830 | + { | |
| 5831 | + "name": "cloud-region", | |
| 5832 | + "path": "/cloud-regions/amazon-web-services-ca-central-1", | |
| 5833 | + "w": 1280, | |
| 5834 | + "theme": "dark", | |
| 5835 | + "status": 200, | |
| 5836 | + "ttfb": 5, | |
| 5837 | + "overflow": null, | |
| 5838 | + "clipped": [ | |
| 5839 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5840 | + "span.sr-only \"Canada\" 47>1" | |
| 5841 | + ], | |
| 5842 | + "small": [], | |
| 5843 | + "smallCount": 0, | |
| 5844 | + "h1Covered": false, | |
| 5845 | + "h1Top": 136, | |
| 5846 | + "headerBottom": 57, | |
| 5847 | + "lang": "en", | |
| 5848 | + "metaDesc": 165, | |
| 5849 | + "h1Count": 1, | |
| 5850 | + "skipped": [], | |
| 5851 | + "unnamed": [], | |
| 5852 | + "ariaCurrent": 0, | |
| 5853 | + "footerOk": true, | |
| 5854 | + "contrast": [ | |
| 5855 | + { | |
| 5856 | + "ratio": 3.07, | |
| 5857 | + "color": "rgb(91, 97, 104)", | |
| 5858 | + "bg": "rgb(11,15,20)", | |
| 5859 | + "sample": "p.mt-3.text-[11.5px].text-ink-4 \"Region facts are taken verbati\"", | |
| 5860 | + "n": 1 | |
| 5861 | + }, | |
| 5862 | + { | |
| 5863 | + "ratio": 3.07, | |
| 5864 | + "color": "rgb(91, 97, 104)", | |
| 5865 | + "bg": "rgb(11,15,20)", | |
| 5866 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"Methodology\"", | |
| 5867 | + "n": 1 | |
| 5868 | + } | |
| 5869 | + ], | |
| 5870 | + "contrastChecked": 102, | |
| 5871 | + "height": 1902, | |
| 5872 | + "title": "Amazon Web Services ca-central-1 — Canada (Central) · DataCenterIndex", | |
| 5873 | + "errors": [], | |
| 5874 | + "badResponses": [] | |
| 5875 | + }, | |
| 5876 | + { | |
| 5877 | + "name": "ixps", | |
| 5878 | + "path": "/ixps", | |
| 5879 | + "w": 1280, | |
| 5880 | + "theme": "dark", | |
| 5881 | + "status": 200, | |
| 5882 | + "ttfb": 5, | |
| 5883 | + "overflow": null, | |
| 5884 | + "clipped": [ | |
| 5885 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1", | |
| 5886 | + "label.sr-only \"Search\" 46>1", | |
| 5887 | + "label.sr-only \"Country\" 53>1" | |
| 5888 | + ], | |
| 5889 | + "small": [], | |
| 5890 | + "smallCount": 0, | |
| 5891 | + "h1Covered": false, | |
| 5892 | + "h1Top": 106, | |
| 5893 | + "headerBottom": 57, | |
| 5894 | + "lang": "en", | |
| 5895 | + "metaDesc": 100, | |
| 5896 | + "h1Count": 1, | |
| 5897 | + "skipped": [], | |
| 5898 | + "unnamed": [], | |
| 5899 | + "ariaCurrent": 1, | |
| 5900 | + "footerOk": true, | |
| 5901 | + "contrast": [ | |
| 5902 | + { | |
| 5903 | + "ratio": 3.07, | |
| 5904 | + "color": "rgb(91, 97, 104)", | |
| 5905 | + "bg": "rgb(11,15,20)", | |
| 5906 | + "sample": "p.mt-8.text-[12px].text-ink-4 \"Exchange and network data: Pee\"", | |
| 5907 | + "n": 1 | |
| 5908 | + } | |
| 5909 | + ], | |
| 5910 | + "contrastChecked": 75, | |
| 5911 | + "height": 1171, | |
| 5912 | + "title": "Internet exchanges · DataCenterIndex", | |
| 5913 | + "errors": [ | |
| 5914 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 5915 | + ], | |
| 5916 | + "badResponses": [ | |
| 5917 | + "404 fetch /api/v1/docs?_rsc=LOh4kTrez0j5S-5D" | |
| 5918 | + ] | |
| 5919 | + }, | |
| 5920 | + { | |
| 5921 | + "name": "sources", | |
| 5922 | + "path": "/sources", | |
| 5923 | + "w": 1280, | |
| 5924 | + "theme": "dark", | |
| 5925 | + "status": 200, | |
| 5926 | + "ttfb": 16, | |
| 5927 | + "overflow": null, | |
| 5928 | + "clipped": [ | |
| 5929 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 5930 | + ], | |
| 5931 | + "small": [], | |
| 5932 | + "smallCount": 0, | |
| 5933 | + "h1Covered": false, | |
| 5934 | + "h1Top": 136, | |
| 5935 | + "headerBottom": 57, | |
| 5936 | + "lang": "en", | |
| 5937 | + "metaDesc": 162, | |
| 5938 | + "h1Count": 1, | |
| 5939 | + "skipped": [ | |
| 5940 | + "h1→h3" | |
| 5941 | + ], | |
| 5942 | + "unnamed": [], | |
| 5943 | + "ariaCurrent": 0, | |
| 5944 | + "footerOk": true, | |
| 5945 | + "contrast": [ | |
| 5946 | + { | |
| 5947 | + "ratio": 3.07, | |
| 5948 | + "color": "rgb(91, 97, 104)", | |
| 5949 | + "bg": "rgb(11,15,20)", | |
| 5950 | + "sample": "span.figure.text-[11px].text-ink-4 \"60\"", | |
| 5951 | + "n": 3 | |
| 5952 | + }, | |
| 5953 | + { | |
| 5954 | + "ratio": 3.07, | |
| 5955 | + "color": "rgb(91, 97, 104)", | |
| 5956 | + "bg": "rgb(11,15,20)", | |
| 5957 | + "sample": "p.mt-6.text-[12px].text-ink-4 \"Kinds rank source authority wh\"", | |
| 5958 | + "n": 1 | |
| 5959 | + }, | |
| 5960 | + { | |
| 5961 | + "ratio": 3.07, | |
| 5962 | + "color": "rgb(91, 97, 104)", | |
| 5963 | + "bg": "rgb(11,15,20)", | |
| 5964 | + "sample": "a.underline.decoration-hair-2.hover:text-ink \"reconciliation rules\"", | |
| 5965 | + "n": 2 | |
| 5966 | + } | |
| 5967 | + ], | |
| 5968 | + "contrastChecked": 880, | |
| 5969 | + "height": 8101, | |
| 5970 | + "title": "Sources · DataCenterIndex", | |
| 5971 | + "errors": [], | |
| 5972 | + "badResponses": [] | |
| 5973 | + }, | |
| 5974 | + { | |
| 5975 | + "name": "methodology", | |
| 5976 | + "path": "/methodology", | |
| 5977 | + "w": 1280, | |
| 5978 | + "theme": "dark", | |
| 5979 | + "status": 200, | |
| 5980 | + "ttfb": 3, | |
| 5981 | + "overflow": null, | |
| 5982 | + "clipped": [ | |
| 5983 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 5984 | + ], | |
| 5985 | + "small": [], | |
| 5986 | + "smallCount": 0, | |
| 5987 | + "h1Covered": false, | |
| 5988 | + "h1Top": 136, | |
| 5989 | + "headerBottom": 57, | |
| 5990 | + "lang": "en", | |
| 5991 | + "metaDesc": 201, | |
| 5992 | + "h1Count": 1, | |
| 5993 | + "skipped": [], | |
| 5994 | + "unnamed": [], | |
| 5995 | + "ariaCurrent": 0, | |
| 5996 | + "footerOk": true, | |
| 5997 | + "contrast": [ | |
| 5998 | + { | |
| 5999 | + "ratio": 3.07, | |
| 6000 | + "color": "rgb(91, 97, 104)", | |
| 6001 | + "bg": "rgb(11,15,20)", | |
| 6002 | + "sample": "div.figure.text-[11px].text-ink-4 \"countries_facilities\"", | |
| 6003 | + "n": 20 | |
| 6004 | + } | |
| 6005 | + ], | |
| 6006 | + "contrastChecked": 292, | |
| 6007 | + "height": 6645, | |
| 6008 | + "title": "Methodology · DataCenterIndex", | |
| 6009 | + "errors": [], | |
| 6010 | + "badResponses": [] | |
| 6011 | + }, | |
| 6012 | + { | |
| 6013 | + "name": "about", | |
| 6014 | + "path": "/about", | |
| 6015 | + "w": 1280, | |
| 6016 | + "theme": "dark", | |
| 6017 | + "status": 200, | |
| 6018 | + "ttfb": 1, | |
| 6019 | + "overflow": null, | |
| 6020 | + "clipped": [ | |
| 6021 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 6022 | + ], | |
| 6023 | + "small": [], | |
| 6024 | + "smallCount": 0, | |
| 6025 | + "h1Covered": false, | |
| 6026 | + "h1Top": 106, | |
| 6027 | + "headerBottom": 57, | |
| 6028 | + "lang": "en", | |
| 6029 | + "metaDesc": 85, | |
| 6030 | + "h1Count": 1, | |
| 6031 | + "skipped": [], | |
| 6032 | + "unnamed": [], | |
| 6033 | + "ariaCurrent": 0, | |
| 6034 | + "footerOk": true, | |
| 6035 | + "contrast": [], | |
| 6036 | + "contrastChecked": 72, | |
| 6037 | + "height": 1679, | |
| 6038 | + "title": "About · DataCenterIndex", | |
| 6039 | + "errors": [], | |
| 6040 | + "badResponses": [] | |
| 6041 | + }, | |
| 6042 | + { | |
| 6043 | + "name": "bot", | |
| 6044 | + "path": "/bot", | |
| 6045 | + "w": 1280, | |
| 6046 | + "theme": "dark", | |
| 6047 | + "status": 200, | |
| 6048 | + "ttfb": 2, | |
| 6049 | + "overflow": null, | |
| 6050 | + "clipped": [ | |
| 6051 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 6052 | + ], | |
| 6053 | + "small": [], | |
| 6054 | + "smallCount": 0, | |
| 6055 | + "h1Covered": false, | |
| 6056 | + "h1Top": 136, | |
| 6057 | + "headerBottom": 57, | |
| 6058 | + "lang": "en", | |
| 6059 | + "metaDesc": 179, | |
| 6060 | + "h1Count": 1, | |
| 6061 | + "skipped": [], | |
| 6062 | + "unnamed": [], | |
| 6063 | + "ariaCurrent": 0, | |
| 6064 | + "footerOk": true, | |
| 6065 | + "contrast": [], | |
| 6066 | + "contrastChecked": 91, | |
| 6067 | + "height": 2261, | |
| 6068 | + "title": "About our crawler · DataCenterIndex", | |
| 6069 | + "errors": [], | |
| 6070 | + "badResponses": [] | |
| 6071 | + }, | |
| 6072 | + { | |
| 6073 | + "name": "404", | |
| 6074 | + "path": "/this-page-does-not-exist", | |
| 6075 | + "w": 1280, | |
| 6076 | + "theme": "dark", | |
| 6077 | + "status": 404, | |
| 6078 | + "ttfb": 1, | |
| 6079 | + "overflow": null, | |
| 6080 | + "clipped": [ | |
| 6081 | + "a.sr-only.focus:not-sr-only.focus:fixed \"Skip to content\" 99>1" | |
| 6082 | + ], | |
| 6083 | + "small": [], | |
| 6084 | + "smallCount": 0, | |
| 6085 | + "h1Covered": false, | |
| 6086 | + "h1Top": 261, | |
| 6087 | + "headerBottom": 57, | |
| 6088 | + "lang": "en", | |
| 6089 | + "metaDesc": 159, | |
| 6090 | + "h1Count": 1, | |
| 6091 | + "skipped": [], | |
| 6092 | + "unnamed": [], | |
| 6093 | + "ariaCurrent": 0, | |
| 6094 | + "footerOk": false, | |
| 6095 | + "contrast": [], | |
| 6096 | + "contrastChecked": 16, | |
| 6097 | + "height": 900, | |
| 6098 | + "title": "DataCenterIndex — The global index of data center infrastructure", | |
| 6099 | + "errors": [ | |
| 6100 | + "Failed to load resource: the server responded with a status of 404 (Not Found)" | |
| 6101 | + ], | |
| 6102 | + "badResponses": [] | |
| 6103 | + } | |
| 6104 | + ] | |
| 6105 | +} | |
| \ No newline at end of file | ||
added
apps/web/qa/sweep/search-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/search-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/search-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/search-390-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/sources-1280-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/sources-1280-light.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/sources-390-dark.png
+0 −0
Binary file not shown.
added
apps/web/qa/sweep/sources-390-light.png
+0 −0
Binary file not shown.
modified
apps/web/src/app/(site)/(home)/page.tsx
+1 −1
@@ -38,7 +38,7 @@ export default async function HomePage() { | ||
| 38 | 38 | return ( |
| 39 | 39 | <Page> |
| 40 | 40 | {/* Hero — compact, graticule texture, big search */} |
| 41 | − <section className="relative -mx-3 border-b border-hair px-3 pb-8 pt-8 sm:-mx-5 sm:px-5 sm:pb-10 sm:pt-12 lg:-mx-8 lg:px-8"> | |
| 41 | + <section className="relative -mx-3 border-b border-hair px-3 pb-6 pt-6 sm:-mx-5 sm:px-5 sm:pb-10 sm:pt-12 lg:-mx-8 lg:px-8"> | |
| 42 | 42 | <div aria-hidden className="graticule pointer-events-none absolute inset-0" /> |
| 43 | 43 | <div className="relative mx-auto max-w-[1280px]"> |
| 44 | 44 | <div className="label-caps mb-3 flex items-center gap-2"> |
modified
apps/web/src/app/(site)/cloud-regions/(index)/page.tsx
+1 −1
@@ -35,7 +35,7 @@ const sel = "h-9 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] tex | ||
| 35 | 35 | function RegionTable({ items, showProvider, sp, sort }: { items: CloudRegionSummary[]; showProvider: boolean; sp: SearchParams; sort: SortState<SortKey> | null }) { |
| 36 | 36 | const th = (key: SortKey) => (sort ? sortTh(BASE, sp, sort, key) : {}); |
| 37 | 37 | return ( |
| 38 | − <Table dense> | |
| 38 | + <Table dense mobileCollapse={{ total: items.length, label: "regions" }}> | |
| 39 | 39 | <THead> |
| 40 | 40 | <tr> |
| 41 | 41 | {showProvider && <Th {...th("provider")}>Provider</Th>} |
modified
apps/web/src/app/(site)/cloud-regions/[slug]/page.tsx
+1 −1
@@ -166,7 +166,7 @@ export default async function CloudRegionPage({ params }: { params: Params }) { | ||
| 166 | 166 | |
| 167 | 167 | {siblings.length > 0 && ( |
| 168 | 168 | <Section id="siblings" title={`Other ${d.provider.name} regions`} description={`${siblings.length} ${siblings.length === 1 ? "region" : "regions"} indexed for this provider`} className="scroll-mt-28" aside={<Link href={providerHref} className="text-[12px] text-ink-3 hover:text-ink">All {d.provider.name} regions</Link>}> |
| 169 | − <Table dense> | |
| 169 | + <Table dense mobileCollapse={{ total: siblings.length, label: "regions" }}> | |
| 170 | 170 | <THead> |
| 171 | 171 | <tr> |
| 172 | 172 | <Th>Code</Th> |
modified
apps/web/src/app/(site)/countries/(index)/page.tsx
+2 −2
@@ -51,7 +51,7 @@ function sorter(s: SortState<SortKey>) { | ||
| 51 | 51 | function CountryTable({ rows, base, sp, sort }: { rows: CountrySummary[]; base: string; sp: SearchParams; sort: SortState<SortKey> }) { |
| 52 | 52 | const th = (key: SortKey) => sortTh(base, sp, sort, key); |
| 53 | 53 | return ( |
| 54 | − <Table> | |
| 54 | + <Table mobileCollapse={{ total: rows.length, label: "countries" }}> | |
| 55 | 55 | <THead> |
| 56 | 56 | <tr> |
| 57 | 57 | <Th {...th("name")}>Country</Th> |
@@ -91,7 +91,7 @@ function CountryTable({ rows, base, sp, sort }: { rows: CountrySummary[]; base: | ||
| 91 | 91 | </RowLink> |
| 92 | 92 | <div className="text-[11.5px] text-ink-3 sm:hidden">{c.subregion ?? c.region ?? ""}</div> |
| 93 | 93 | </Td> |
| 94 | − <Td label="Facilities" numeric> | |
| 94 | + <Td label="Facilities" numeric lead> | |
| 95 | 95 | {fmtInt(c.facilityCount)} |
| 96 | 96 | </Td> |
| 97 | 97 | <Td label="Known MW" numeric> |
modified
apps/web/src/app/(site)/countries/[slug]/page.tsx
+4 −4
@@ -231,7 +231,7 @@ export default async function CountryPage({ params, searchParams }: Props) { | ||
| 231 | 231 | |
| 232 | 232 | {metros.length > 0 && ( |
| 233 | 233 | <Section id="markets" title="Markets" description={`${fmtInt(metros.length)} ${pluralize(metros.length, "metro")} with indexed infrastructure`} className="scroll-mt-28"> |
| 234 | − <Table dense> | |
| 234 | + <Table dense mobileCollapse={{ total: metros.length, label: "markets" }}> | |
| 235 | 235 | <THead> |
| 236 | 236 | <tr> |
| 237 | 237 | <Th>Market</Th> |
@@ -258,7 +258,7 @@ export default async function CountryPage({ params, searchParams }: Props) { | ||
| 258 | 258 | <RowLink href={routes.market(m.slug)}>{m.name}</RowLink> |
| 259 | 259 | {m.regionName && m.regionName !== m.name && <span className="ml-1.5 text-[11.5px] text-ink-3">{m.regionName}</span>} |
| 260 | 260 | </Td> |
| 261 | − <Td label="Facilities" numeric> | |
| 261 | + <Td label="Facilities" numeric lead> | |
| 262 | 262 | {fmtInt(m.facilityCount)} |
| 263 | 263 | </Td> |
| 264 | 264 | <Td label="Known MW" numeric> |
@@ -293,7 +293,7 @@ export default async function CountryPage({ params, searchParams }: Props) { | ||
| 293 | 293 | |
| 294 | 294 | {cloud.length > 0 && ( |
| 295 | 295 | <Section id="cloud" title="Cloud regions" description={`${fmtInt(cloud.length)} public cloud ${pluralize(cloud.length, "region")} located in ${c.name}`} className="scroll-mt-28" aside={<Link href={`${routes.cloudRegions()}?country=${c.iso2}`} className="text-[12.5px] text-ink-3 hover:text-ink">All cloud regions →</Link>}> |
| 296 | − <Table dense> | |
| 296 | + <Table dense mobileCollapse={{ total: cloud.length, label: "cloud regions" }}> | |
| 297 | 297 | <THead> |
| 298 | 298 | <tr> |
| 299 | 299 | <Th>Provider</Th> |
@@ -358,7 +358,7 @@ export default async function CountryPage({ params, searchParams }: Props) { | ||
| 358 | 358 | |
| 359 | 359 | {c.recentEvents.length > 0 && ( |
| 360 | 360 | <Section id="changes" title="Recent changes" description="Latest detections for facilities and projects in the country." className="scroll-mt-28" aside={<Link href={routes.live(`country=${c.iso2}`)} className="text-[12.5px] text-ink-3 hover:text-ink">Live feed →</Link>}> |
| 361 | − <EventList events={c.recentEvents.slice(0, 12)} dense /> | |
| 361 | + <EventList events={c.recentEvents.slice(0, 10)} dense /> | |
| 362 | 362 | </Section> |
| 363 | 363 | )} |
| 364 | 364 | |
modified
apps/web/src/app/(site)/datacenters/markets/(index)/page.tsx
+1 −1
@@ -145,7 +145,7 @@ export default async function MarketsPage({ searchParams }: { searchParams: Prom | ||
| 145 | 145 | <CountryFlag iso2={m.countryIso2} withName /> |
| 146 | 146 | )} |
| 147 | 147 | </Td> |
| 148 | − <Td label="Facilities" numeric> | |
| 148 | + <Td label="Facilities" numeric lead> | |
| 149 | 149 | {fmtInt(m.facilityCount)} |
| 150 | 150 | </Td> |
| 151 | 151 | <Td label="Op · Con · Plan" className="figure hidden text-[12px] text-ink-3 lg:table-cell" title="operational · under construction · planned"> |
modified
apps/web/src/app/(site)/datacenters/markets/[slug]/page.tsx
+1 −1
@@ -250,7 +250,7 @@ export default async function MarketPage({ params, searchParams }: Props) { | ||
| 250 | 250 | </ul> |
| 251 | 251 | )} |
| 252 | 252 | {m.cloudRegions.length > 0 && ( |
| 253 | − <Table dense> | |
| 253 | + <Table dense mobileCollapse={{ total: m.cloudRegions.length, label: "cloud regions" }}> | |
| 254 | 254 | <THead> |
| 255 | 255 | <tr> |
| 256 | 256 | <Th>Region</Th> |
modified
apps/web/src/app/(site)/ixps/(index)/page.tsx
+1 −1
@@ -124,7 +124,7 @@ export default async function IxpsPage({ searchParams }: { searchParams: Promise | ||
| 124 | 124 | <Td label="Country"> |
| 125 | 125 | <CountryFlag iso2={x.countryIso2} withName className="max-w-[160px] text-ink-2" /> |
| 126 | 126 | </Td> |
| 127 | − <Td label="Networks" numeric> | |
| 127 | + <Td label="Networks" numeric lead> | |
| 128 | 128 | {x.networkCount === null ? <span className="text-ink-4">—</span> : fmtInt(x.networkCount)} |
| 129 | 129 | </Td> |
| 130 | 130 | <Td label="Facilities" numeric> |
modified
apps/web/src/app/(site)/operators/(index)/page.tsx
+1 −1
@@ -133,7 +133,7 @@ export default async function OperatorsPage({ searchParams }: { searchParams: Pr | ||
| 133 | 133 | <Td label="HQ"> |
| 134 | 134 | <CountryFlag iso2={o.hqCountryIso2} withName className="max-w-[160px] text-ink-2" /> |
| 135 | 135 | </Td> |
| 136 | − <Td label="Facilities" numeric> | |
| 136 | + <Td label="Facilities" numeric lead> | |
| 137 | 137 | {fmtInt(o.facilityCount)} |
| 138 | 138 | </Td> |
| 139 | 139 | <Td label="Countries" numeric> |
modified
apps/web/src/app/(site)/projects/(index)/page.tsx
+1 −1
@@ -199,7 +199,7 @@ export default async function ProjectsPage({ searchParams }: { searchParams: Pro | ||
| 199 | 199 | <Td label="Status"> |
| 200 | 200 | <StatusBadge status={pr.status} short size="xs" /> |
| 201 | 201 | </Td> |
| 202 | − <Td label="Planned MW" numeric> | |
| 202 | + <Td label="Planned MW" numeric lead> | |
| 203 | 203 | {pr.plannedMw === null ? ( |
| 204 | 204 | <span className="text-ink-4">—</span> |
| 205 | 205 | ) : ( |
modified
apps/web/src/app/(site)/rankings/[key]/page.tsx
+4 −4
@@ -99,7 +99,7 @@ export default async function RankingPage({ params }: { params: Params }) { | ||
| 99 | 99 | } |
| 100 | 100 | /> |
| 101 | 101 | ) : ( |
| 102 | − <Table dense> | |
| 102 | + <Table dense mobileCollapse={{ total: rows.length, label: "rows" }}> | |
| 103 | 103 | <THead> |
| 104 | 104 | <tr> |
| 105 | 105 | <Th numeric className="w-12"> |
@@ -132,7 +132,7 @@ export default async function RankingPage({ params }: { params: Params }) { | ||
| 132 | 132 | <RowLink href={rowHref(row, d.scope)}>{row.name}</RowLink> |
| 133 | 133 | </span> |
| 134 | 134 | </Td> |
| 135 | − <Td label={d.unit} numeric> | |
| 135 | + <Td label={d.unit} numeric lead> | |
| 136 | 136 | {fmtRankValue(row.value, d.unit)} |
| 137 | 137 | </Td> |
| 138 | 138 | {hasSecondary && ( |
@@ -167,13 +167,13 @@ export default async function RankingPage({ params }: { params: Params }) { | ||
| 167 | 167 | |
| 168 | 168 | <div className="mt-8 grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> |
| 169 | 169 | <Card> |
| 170 | − <CardHeader title="Methodology" description={`Unit: ${d.unit} · scope: ${SCOPE_LABEL[d.scope].toLowerCase()}`} /> | |
| 170 | + <CardHeader level={2} title="Methodology" description={`Unit: ${d.unit} · scope: ${SCOPE_LABEL[d.scope].toLowerCase()}`} /> | |
| 171 | 171 | <CardBody> |
| 172 | 172 | <p className="text-[13.5px] leading-relaxed text-ink-2">{d.methodology}</p> |
| 173 | 173 | </CardBody> |
| 174 | 174 | </Card> |
| 175 | 175 | <Card> |
| 176 | − <CardHeader title="How to read this" /> | |
| 176 | + <CardHeader level={2} title="How to read this" /> | |
| 177 | 177 | <CardBody className="text-[12.5px] leading-relaxed text-ink-2"> |
| 178 | 178 | <p>Values are lower bounds computed from published or filed figures only. Coverage is the share of an entity's facilities carrying any MW figure; a low coverage means the value understates reality.</p> |
| 179 | 179 | <p className="mt-2">Δ rank compares with the previous computation of the same ranking; “new” marks an entity that was not ranked before.</p> |
modified
apps/web/src/app/(site)/sources/(index)/page.tsx
+3 −3
@@ -57,7 +57,7 @@ function SourceTable({ items, sp, grouped, sort }: { items: SourceWithCounts[]; | ||
| 57 | 57 | const base = routes.sources(); |
| 58 | 58 | const th = (k: SortKey) => sortTh(base, sp, sort, k); |
| 59 | 59 | return ( |
| 60 | − <Table dense> | |
| 60 | + <Table dense mobileCollapse={{ total: items.length, label: "sources" }}> | |
| 61 | 61 | <THead> |
| 62 | 62 | <tr> |
| 63 | 63 | <Th {...th("name")}>Source</Th> |
@@ -99,7 +99,7 @@ function SourceTable({ items, sp, grouped, sort }: { items: SourceWithCounts[]; | ||
| 99 | 99 | <Td label="Attribution" className="hidden max-w-[200px] text-[12px] text-ink-3 xl:table-cell" title={s.attribution ?? undefined}> |
| 100 | 100 | {truncate(s.attribution, 48)} |
| 101 | 101 | </Td> |
| 102 | − <Td label="Documents" numeric> | |
| 102 | + <Td label="Documents" numeric lead> | |
| 103 | 103 | {fmtInt(s.documents)} |
| 104 | 104 | </Td> |
| 105 | 105 | <Td label="Provenance rows" numeric> |
@@ -214,7 +214,7 @@ export default async function SourcesPage({ searchParams }: { searchParams: Prom | ||
| 214 | 214 | <div className="mt-4 flex flex-col gap-6"> |
| 215 | 215 | <SectionNav items={groups.map((g) => ({ id: `kind-${g.kind}`, label: SOURCE_KIND_LABEL[g.kind], count: g.items.length }))} /> |
| 216 | 216 | {groups.map((g) => ( |
| 217 | − <Section key={g.kind} id={`kind-${g.kind}`} className="scroll-mt-28" title={SOURCE_KIND_LABEL[g.kind]} description={`${fmtInt(g.items.length)} ${g.items.length === 1 ? "source" : "sources"} · ${fmtInt(g.items.reduce((a, s) => a + s.provenanceRows, 0))} provenance rows`} level={3}> | |
| 217 | + <Section key={g.kind} id={`kind-${g.kind}`} className="scroll-mt-28" title={SOURCE_KIND_LABEL[g.kind]} description={`${fmtInt(g.items.length)} ${g.items.length === 1 ? "source" : "sources"} · ${fmtInt(g.items.reduce((a, s) => a + s.provenanceRows, 0))} provenance rows`}> | |
| 218 | 218 | <SourceTable items={g.items} sp={sp} grouped sort={sort} /> |
| 219 | 219 | </Section> |
| 220 | 220 | ))} |
modified
apps/web/src/app/globals.css
+24 −10
@@ -79,8 +79,8 @@ | ||
| 79 | 79 | --surface-3: #e6e4df; |
| 80 | 80 | --ink: #0b0f14; |
| 81 | 81 | --ink-2: #3b4148; |
| 82 | − --ink-3: #6b7178; | |
| 83 | − --ink-4: #9a9fa5; | |
| 82 | + --ink-3: #666c73; /* 5.1:1 on paper (was #6b7178 = 4.49:1) */ | |
| 83 | + --ink-4: #9a9fa5; /* hint level only (placeholders, kbd, "/total") — below 4.5:1 by design */ | |
| 84 | 84 | --hair: #e3e1dc; |
| 85 | 85 | --hair-2: #c9c6bf; |
| 86 | 86 | --accent: #c8541a; |
@@ -113,8 +113,8 @@ | ||
| 113 | 113 | --surface-3: #1d252e; |
| 114 | 114 | --ink: #eceae5; |
| 115 | 115 | --ink-2: #b9b6ae; |
| 116 | − --ink-3: #858a90; | |
| 117 | − --ink-4: #5b6168; | |
| 116 | + --ink-3: #8f959c; /* 6.2:1 on paper (was #858a90 = 5.4:1) */ | |
| 117 | + --ink-4: #7a8189; /* 4.8:1 on paper, 4.6:1 on surface (was #5b6168 = 3.1:1) */ | |
| 118 | 118 | --hair: #1f272f; |
| 119 | 119 | --hair-2: #2f3941; |
| 120 | 120 | --accent: #e8763a; |
@@ -220,17 +220,31 @@ | ||
| 220 | 220 | mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.9), transparent 90%); |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | −/* Responsive table → stacked cards under 640 px (each cell carries data-label). */ | |
| 223 | +/* Responsive table → compact two-line rows under 640 px (each cell carries data-label). | |
| 224 | + Line 1: [data-primary] cell (name + meta) with the [data-lead] figure on the right. | |
| 225 | + Line 2: the remaining cells wrap as "LABEL value" pairs. Cells hidden via `hidden sm:table-cell` stay hidden. */ | |
| 224 | 226 | .dci-table { width: 100%; } |
| 225 | 227 | .dci-table th { position: sticky; top: 0; z-index: 1; background: var(--surface); } |
| 226 | 228 | @media (max-width: 639.98px) { |
| 227 | 229 | .dci-table.responsive thead { display: none; } |
| 228 | − .dci-table.responsive, .dci-table.responsive tbody, .dci-table.responsive tr, .dci-table.responsive td { display: block; width: 100%; } | |
| 229 | − .dci-table.responsive tr { border-bottom: 1px solid var(--hair); padding: 10px 0; } | |
| 230 | − .dci-table.responsive td { padding: 2px 0 !important; border: 0 !important; display: flex; justify-content: space-between; gap: 12px; align-items: baseline; } | |
| 231 | − .dci-table.responsive td[data-label]::before { content: attr(data-label); color: var(--ink-3); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; flex: 0 0 auto; } | |
| 232 | − .dci-table.responsive td[data-primary] { display: block; font-weight: 500; padding-bottom: 4px !important; } | |
| 230 | + .dci-table.responsive, .dci-table.responsive tbody { display: block; width: 100%; } | |
| 231 | + .dci-table.responsive tr { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 12px; row-gap: 3px; width: 100%; border-bottom: 1px solid var(--hair); padding: 9px 0; } | |
| 232 | + .dci-table.responsive td { order: 2; display: inline-flex; flex: 0 0 auto; align-items: baseline; gap: 5px; min-width: 0; max-width: 100%; padding: 0 !important; border: 0 !important; font-size: 12px; line-height: 1.35; text-align: left; } | |
| 233 | + .dci-table.responsive td.hidden { display: none; } | |
| 234 | + .dci-table.responsive td[data-label]::before { content: attr(data-label); color: var(--ink-3); font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; flex: 0 0 auto; } | |
| 235 | + .dci-table.responsive td[data-primary] { order: 0; display: block; flex: 1 1 55%; font-size: 13px; line-height: 1.35; font-weight: 500; } | |
| 236 | + .dci-table.responsive tr:not(:has(td[data-lead])) td[data-primary] { flex-basis: 100%; } | |
| 233 | 237 | .dci-table.responsive td[data-primary]::before { display: none; } |
| 238 | + .dci-table.responsive td[data-lead] { order: 1; flex: 1 0 auto; justify-content: flex-end; font-size: 13px; color: var(--ink); } | |
| 239 | + .dci-table.responsive td[data-lead]::before { color: var(--ink-4); } | |
| 240 | + /* Whole row = tap target for the primary RowLink (≥ 40 px); other links in the row stay clickable above it. */ | |
| 241 | + .dci-table.responsive tr { position: relative; } | |
| 242 | + .dci-table.responsive td a { position: relative; z-index: 1; } | |
| 243 | + .dci-table.responsive td[data-primary] a.dci-rowlink { position: static; } | |
| 244 | + .dci-table.responsive td[data-primary] a.dci-rowlink::after { content: ""; position: absolute; inset: 0; z-index: 0; } | |
| 245 | + /* Collapsed long lists: only the first 10 rows until "Show all N" (see <CollapsibleRows/>). */ | |
| 246 | + .dci-collapse[data-collapsed] tbody > tr:nth-child(n + 11), | |
| 247 | + .dci-collapse[data-collapsed] > :is(ol, ul) > li:nth-child(n + 11) { display: none; } | |
| 234 | 248 | } |
| 235 | 249 | |
| 236 | 250 | /* Dual range slider — two overlaid native inputs, only the thumbs are visible. */ |
modified
apps/web/src/app/not-found.tsx
+7 −3
@@ -1,11 +1,13 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | +import { Footer } from "@/components/footer"; | |
| 2 | 3 | import { Button } from "@/components/ui/button"; |
| 3 | 4 | import { Page } from "@/components/ui/card"; |
| 4 | 5 | import { routes } from "@/lib/routes"; |
| 5 | 6 | |
| 6 | 7 | export default function NotFound() { |
| 7 | 8 | return ( |
| 8 | − <main id="main"> | |
| 9 | + <> | |
| 10 | + <main id="main"> | |
| 9 | 11 | <Page className="flex min-h-[60dvh] flex-col items-start justify-center py-16"> |
| 10 | 12 | <div className="label-caps mb-2">404</div> |
| 11 | 13 | <h1 className="text-[26px] font-semibold tracking-[-0.02em] sm:text-[32px]">This entry is not in the index.</h1> |
@@ -15,11 +17,13 @@ export default function NotFound() { | ||
| 15 | 17 | Search the index |
| 16 | 18 | </Button> |
| 17 | 19 | <Button href={routes.map()}>Open the map</Button> |
| 18 | − <Link href={routes.home()} className="inline-flex h-9 items-center px-2 text-[13px] text-ink-3 hover:text-ink"> | |
| 20 | + <Link href={routes.home()} className="inline-flex h-10 items-center px-2 text-[13px] text-ink-3 hover:text-ink sm:h-9"> | |
| 19 | 21 | Home |
| 20 | 22 | </Link> |
| 21 | 23 | </div> |
| 22 | 24 | </Page> |
| 23 | − </main> | |
| 25 | + </main> | |
| 26 | + <Footer /> | |
| 27 | + </> | |
| 24 | 28 | ); |
| 25 | 29 | } |
modified
apps/web/src/components/brand/logo.tsx
+1 −1
@@ -38,7 +38,7 @@ export function Logo({ href = "/", size = 22, className, compact, textClassName | ||
| 38 | 38 | ); |
| 39 | 39 | if (!href) return <span className={cn("inline-flex items-center gap-2", className)}>{inner}</span>; |
| 40 | 40 | return ( |
| 41 | − <Link href={href} className={cn("inline-flex items-center gap-2 rounded-xs", className)} aria-label="DataCenterIndex — home"> | |
| 41 | + <Link href={href} className={cn("inline-flex min-h-10 items-center gap-2 rounded-xs", className)} aria-label="DataCenterIndex — home"> | |
| 42 | 42 | {inner} |
| 43 | 43 | </Link> |
| 44 | 44 | ); |
modified
apps/web/src/components/event-list.tsx
+6 −5
@@ -53,7 +53,7 @@ export function SignificanceMark({ value, className }: { value: number; classNam | ||
| 53 | 53 | export function EventRow({ e, dense, highlight }: { e: EventDTO; dense?: boolean; highlight?: boolean }) { |
| 54 | 54 | const href = e.entity ? entityHref(e.entityType, e.entity.slug, e.entity.name) : null; |
| 55 | 55 | return ( |
| 56 | − <li className={cn("grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 py-2.5", highlight && "animate-row-in", dense ? "py-2" : "py-3")}> | |
| 56 | + <li className={cn("relative grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 py-2.5", highlight && "animate-row-in", dense ? "py-2" : "py-3")}> | |
| 57 | 57 | <div className="flex flex-col items-center gap-1.5 pt-1"> |
| 58 | 58 | <SignificanceMark value={e.significance} /> |
| 59 | 59 | </div> |
@@ -66,7 +66,8 @@ export function EventRow({ e, dense, highlight }: { e: EventDTO; dense?: boolean | ||
| 66 | 66 | </div> |
| 67 | 67 | <div className="mt-1 text-[13.5px] leading-snug"> |
| 68 | 68 | {href && e.entity ? ( |
| 69 | − <Link href={href} className="font-medium text-ink hover:text-accent hover:underline"> | |
| 69 | + // stretched link: the whole row is the tap target for the entity (secondary links sit above it via z-index) | |
| 70 | + <Link href={href} className="font-medium text-ink hover:text-accent hover:underline after:absolute after:inset-0 after:content-['']"> | |
| 70 | 71 | {e.entity.name} |
| 71 | 72 | </Link> |
| 72 | 73 | ) : ( |
@@ -78,11 +79,11 @@ export function EventRow({ e, dense, highlight }: { e: EventDTO; dense?: boolean | ||
| 78 | 79 | {!dense && e.summary && <p className="mt-1 line-clamp-2 text-[12.5px] text-ink-2">{e.summary}</p>} |
| 79 | 80 | <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3"> |
| 80 | 81 | {e.operator && ( |
| 81 | − <Link href={entityHref("operator", e.operator.slug)} className="hover:text-ink"> | |
| 82 | + <Link href={entityHref("operator", e.operator.slug)} className="relative z-[1] inline-flex min-h-6 items-center hover:text-ink"> | |
| 82 | 83 | {e.operator.name} |
| 83 | 84 | </Link> |
| 84 | 85 | )} |
| 85 | − <a href={e.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent" title={`${SOURCE_KIND_LABEL[e.sourceKind]} source`}> | |
| 86 | + <a href={e.url} target="_blank" rel="noopener noreferrer" className="relative z-[1] inline-flex min-h-6 items-center gap-1 hover:text-accent" title={`${SOURCE_KIND_LABEL[e.sourceKind]} source`}> | |
| 86 | 87 | {e.sourceName} |
| 87 | 88 | <ExternalLink className="size-3" /> |
| 88 | 89 | </a> |
@@ -116,7 +117,7 @@ export function EventList({ events, groupByDay, dense, highlightIds, emptyTitle | ||
| 116 | 117 | {Array.from(groups.entries()).map(([day, list]) => ( |
| 117 | 118 | <section key={day} aria-label={fmtDayHeading(day)}> |
| 118 | 119 | <div className="sticky top-12 z-[5] -mx-3 flex items-baseline gap-2 border-y border-hair bg-paper/95 px-3 py-1.5 backdrop-blur sm:top-14 sm:mx-0 sm:px-0"> |
| 119 | − <h3 className="text-[12.5px] font-semibold text-ink">{fmtDayHeading(day)}</h3> | |
| 120 | + <h2 className="text-[12.5px] font-semibold text-ink">{fmtDayHeading(day)}</h2> | |
| 120 | 121 | <span className="figure text-[11px] text-ink-3"> |
| 121 | 122 | {day} · {list.length} {list.length === 1 ? "change" : "changes"} |
| 122 | 123 | </span> |
modified
apps/web/src/components/facility-table.tsx
+2 −2
@@ -37,7 +37,7 @@ export function FacilityTable({ items, filters = {}, basePath = routes.datacente | ||
| 37 | 37 | if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} compact />; |
| 38 | 38 | |
| 39 | 39 | return ( |
| 40 | − <Table> | |
| 40 | + <Table mobileCollapse={{ total: items.length, label: "facilities" }}> | |
| 41 | 41 | <THead> |
| 42 | 42 | <tr> |
| 43 | 43 | <Th sort={sortState("name")} sortHref={sortHref("name")}> |
@@ -108,7 +108,7 @@ export function FacilityTable({ items, filters = {}, basePath = routes.datacente | ||
| 108 | 108 | </Td> |
| 109 | 109 | )} |
| 110 | 110 | {has("mw") && ( |
| 111 | − <Td label="Capacity" numeric> | |
| 111 | + <Td label="Capacity" numeric lead> | |
| 112 | 112 | {mw === null ? ( |
| 113 | 113 | <span className="text-ink-4">—</span> |
| 114 | 114 | ) : ( |
modified
apps/web/src/components/footer.tsx
+12 −5
@@ -4,7 +4,8 @@ import { routes } from "@/lib/routes"; | ||
| 4 | 4 | |
| 5 | 5 | export const ATTRIBUTION = "Data: PeeringDB, © OpenStreetMap contributors (ODbL), Wikidata, World Bank, operator and government sources"; |
| 6 | 6 | |
| 7 | −const COLS = [ | |
| 7 | +// `plain` = served by the API (not a Next route) → plain <a>, no router prefetch. | |
| 8 | +const COLS: Array<{ title: string; links: Array<{ label: string; href: string; plain?: boolean }> }> = [ | |
| 8 | 9 | { |
| 9 | 10 | title: "Index", |
| 10 | 11 | links: [ |
@@ -31,7 +32,7 @@ const COLS = [ | ||
| 31 | 32 | { label: "About", href: routes.about() }, |
| 32 | 33 | { label: "Methodology", href: routes.methodology() }, |
| 33 | 34 | { label: "Sources", href: routes.sources() }, |
| 34 | − { label: "API docs", href: routes.apiDocs() }, | |
| 35 | + { label: "API docs", href: routes.apiDocs(), plain: true }, | |
| 35 | 36 | ], |
| 36 | 37 | }, |
| 37 | 38 | ]; |
@@ -52,9 +53,15 @@ export function Footer({ compact }: { compact?: boolean }) { | ||
| 52 | 53 | <ul className="flex flex-col gap-1"> |
| 53 | 54 | {c.links.map((l) => ( |
| 54 | 55 | <li key={l.href}> |
| 55 | − <Link href={l.href} className="inline-flex min-h-7 items-center text-[13px] text-ink-2 hover:text-ink"> | |
| 56 | − {l.label} | |
| 57 | − </Link> | |
| 56 | + {l.plain ? ( | |
| 57 | + <a href={l.href} className="inline-flex min-h-10 items-center text-[13px] text-ink-2 hover:text-ink sm:min-h-7"> | |
| 58 | + {l.label} | |
| 59 | + </a> | |
| 60 | + ) : ( | |
| 61 | + <Link href={l.href} className="inline-flex min-h-10 items-center text-[13px] text-ink-2 hover:text-ink sm:min-h-7"> | |
| 62 | + {l.label} | |
| 63 | + </Link> | |
| 64 | + )} | |
| 58 | 65 | </li> |
| 59 | 66 | ))} |
| 60 | 67 | </ul> |
modified
apps/web/src/components/header.tsx
+12 −5
@@ -12,13 +12,14 @@ import { cn } from "@/components/ui/cn"; | ||
| 12 | 12 | import { Kbd } from "@/components/ui/kbd"; |
| 13 | 13 | import { NAV_PRIMARY, routes } from "@/lib/routes"; |
| 14 | 14 | |
| 15 | −const SECONDARY = [ | |
| 15 | +// `plain` = not a Next route (served by the API): render a plain <a> so the router never prefetches it as RSC. | |
| 16 | +const SECONDARY: Array<{ label: string; href: string; plain?: boolean }> = [ | |
| 16 | 17 | { label: "Cloud regions", href: routes.cloudRegions() }, |
| 17 | 18 | { label: "Internet exchanges", href: routes.ixps() }, |
| 18 | 19 | { label: "Sources", href: routes.sources() }, |
| 19 | 20 | { label: "Methodology", href: routes.methodology() }, |
| 20 | 21 | { label: "About", href: routes.about() }, |
| 21 | − { label: "API", href: routes.apiDocs() }, | |
| 22 | + { label: "API", href: routes.apiDocs(), plain: true }, | |
| 22 | 23 | ]; |
| 23 | 24 | |
| 24 | 25 | export function Header() { |
@@ -113,9 +114,15 @@ export function Header() { | ||
| 113 | 114 | <ul className="grid grid-cols-2 gap-x-3"> |
| 114 | 115 | {SECONDARY.map((n) => ( |
| 115 | 116 | <li key={n.href}> |
| 116 | − <Link href={n.href} className="flex h-11 items-center border-b border-hair text-[14px] text-ink-2"> | |
| 117 | − {n.label} | |
| 118 | − </Link> | |
| 117 | + {n.plain ? ( | |
| 118 | + <a href={n.href} className="flex h-11 items-center border-b border-hair text-[14px] text-ink-2"> | |
| 119 | + {n.label} | |
| 120 | + </a> | |
| 121 | + ) : ( | |
| 122 | + <Link href={n.href} className="flex h-11 items-center border-b border-hair text-[14px] text-ink-2"> | |
| 123 | + {n.label} | |
| 124 | + </Link> | |
| 125 | + )} | |
| 119 | 126 | </li> |
| 120 | 127 | ))} |
| 121 | 128 | </ul> |
modified
apps/web/src/components/home/hero.tsx
+2 −2
@@ -43,14 +43,14 @@ export function HeroSearch() { | ||
| 43 | 43 | <Kbd>⌘</Kbd> |
| 44 | 44 | <Kbd>K</Kbd> |
| 45 | 45 | </span> |
| 46 | − <button type="submit" aria-label="Search" className="mr-1.5 inline-flex size-9 items-center justify-center rounded-sm bg-ink text-paper hover:bg-ink-2 sm:size-10"> | |
| 46 | + <button type="submit" aria-label="Search" className="mr-1 inline-flex size-10 items-center justify-center rounded-sm bg-ink text-paper hover:bg-ink-2 sm:mr-1.5"> | |
| 47 | 47 | <ArrowRight className="size-4" /> |
| 48 | 48 | </button> |
| 49 | 49 | </form> |
| 50 | 50 | <div className="mt-2.5 -mx-3 flex gap-1.5 overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0"> |
| 51 | 51 | <span className="shrink-0 py-1 text-[11.5px] text-ink-4">Try</span> |
| 52 | 52 | {SUGGESTIONS.map((s) => ( |
| 53 | − <button key={s} type="button" onClick={() => router.push(routes.search(s))} className="shrink-0 rounded-sm border border-hair px-2 py-1 text-[11.5px] text-ink-2 hover:border-hair-2 hover:text-ink"> | |
| 53 | + <button key={s} type="button" onClick={() => router.push(routes.search(s))} className="inline-flex h-10 shrink-0 items-center rounded-sm border border-hair px-2.5 text-[12px] text-ink-2 hover:border-hair-2 hover:text-ink sm:h-auto sm:px-2 sm:py-1 sm:text-[11.5px]"> | |
| 54 | 54 | {s} |
| 55 | 55 | </button> |
| 56 | 56 | ))} |
modified
apps/web/src/components/map/map-page.tsx
+1 −0
@@ -168,6 +168,7 @@ export function MapPage() { | ||
| 168 | 168 | |
| 169 | 169 | return ( |
| 170 | 170 | <div className="relative h-full w-full"> |
| 171 | + <h1 className="sr-only">Global map of data centers</h1> | |
| 171 | 172 | {/* Map */} |
| 172 | 173 | <div className="absolute inset-0"> |
| 173 | 174 | <MapCanvas filters={filters} showCloudRegions={showCloud} initialView={initialView} onViewChange={onViewChange} onData={onData} onSelect={onSelect} onSelectCloudRegion={(c) => { setSelected(null); setSelectedCloud(c); }} flyTo={fly} paddingBottom={isDesktop ? 0 : 260} /> |
modified
apps/web/src/components/project-list.tsx
+4 −3
@@ -12,17 +12,18 @@ export function ProjectList({ items, emptyTitle = "No projects yet", emptyDescri | ||
| 12 | 12 | return ( |
| 13 | 13 | <ul className="divide-y divide-hair"> |
| 14 | 14 | {items.map((p) => ( |
| 15 | − <li key={p.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 py-2.5"> | |
| 15 | + <li key={p.id} className="relative grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 py-2.5"> | |
| 16 | 16 | <div className="min-w-0"> |
| 17 | 17 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 18 | − <Link href={routes.project(p.slug)} className="truncate text-[13.5px] font-medium text-ink hover:text-accent hover:underline"> | |
| 18 | + {/* stretched link: the whole row opens the project; the operator link sits above via z-index */} | |
| 19 | + <Link href={routes.project(p.slug)} className="truncate text-[13.5px] font-medium text-ink hover:text-accent hover:underline after:absolute after:inset-0 after:content-['']"> | |
| 19 | 20 | {p.name} |
| 20 | 21 | </Link> |
| 21 | 22 | <StatusBadge status={p.status} short size="xs" /> |
| 22 | 23 | </div> |
| 23 | 24 | <div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-[12px] text-ink-3"> |
| 24 | 25 | {p.operator ? ( |
| 25 | − <Link href={routes.operator(p.operator.slug)} className="text-ink-2 hover:text-ink"> | |
| 26 | + <Link href={routes.operator(p.operator.slug)} className="relative z-[1] inline-flex min-h-6 items-center text-ink-2 hover:text-ink"> | |
| 26 | 27 | {p.operator.name} |
| 27 | 28 | </Link> |
| 28 | 29 | ) : ( |
modified
apps/web/src/components/ui/button.tsx
+7 −6
@@ -13,10 +13,11 @@ const variants: Record<Variant, string> = { | ||
| 13 | 13 | accent: "border-accent bg-accent text-white hover:bg-accent-ink hover:border-accent-ink", |
| 14 | 14 | danger: "border-danger/40 bg-transparent text-danger hover:bg-danger/10", |
| 15 | 15 | }; |
| 16 | +// Touch first: every size is ≥ 40 px tall under 640 px, the compact heights apply from `sm`. | |
| 16 | 17 | const sizes: Record<Size, string> = { |
| 17 | − xs: "h-7 px-2 text-[12px]", | |
| 18 | − sm: "h-8 px-2.5 text-[13px]", | |
| 19 | − md: "h-9 px-3 text-[13.5px]", | |
| 18 | + xs: "h-10 px-2 text-[12px] sm:h-7", | |
| 19 | + sm: "h-10 px-2.5 text-[13px] sm:h-8", | |
| 20 | + md: "h-10 px-3 text-[13.5px] sm:h-9", | |
| 20 | 21 | lg: "h-11 px-4 text-[14px]", |
| 21 | 22 | }; |
| 22 | 23 | |
@@ -58,9 +59,9 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> | ||
| 58 | 59 | active?: boolean; |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | −/** 44 px minimum hit area on touch (via padding on lg), visually 32–36 px. */ | |
| 62 | +/** ≥ 40 px hit area under 640 px (touch); compact 32–36 px from `sm`, 44 px for `lg`. */ | |
| 62 | 63 | export function IconButton({ label, size = "md", variant = "ghost", active, className, children, type = "button", ...rest }: IconButtonProps) { |
| 63 | − const dims = size === "sm" ? "size-8" : size === "lg" ? "size-11" : "size-9"; | |
| 64 | + const dims = size === "sm" ? "size-10 sm:size-8" : size === "lg" ? "size-11" : "size-10 sm:size-9"; | |
| 64 | 65 | return ( |
| 65 | 66 | <button |
| 66 | 67 | type={type} |
@@ -84,7 +85,7 @@ export function IconButton({ label, size = "md", variant = "ghost", active, clas | ||
| 84 | 85 | /** "View all →" style inline link. */ |
| 85 | 86 | export function MoreLink({ href, children = "View all", className }: { href: string; children?: ReactNode; className?: string }) { |
| 86 | 87 | return ( |
| 87 | − <Link href={href} className={cn("inline-flex items-center gap-1 whitespace-nowrap text-[12.5px] font-medium text-ink-2 hover:text-accent", className)}> | |
| 88 | + <Link href={href} className={cn("inline-flex min-h-10 items-center gap-1 whitespace-nowrap text-[12.5px] font-medium text-ink-2 hover:text-accent sm:min-h-0", className)}> | |
| 88 | 89 | {children} |
| 89 | 90 | <span aria-hidden>→</span> |
| 90 | 91 | </Link> |
modified
apps/web/src/components/ui/card.tsx
+3 −2
@@ -10,11 +10,12 @@ export function Card({ className, children, ...rest }: HTMLAttributes<HTMLDivEle | ||
| 10 | 10 | ); |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | −export function CardHeader({ title, description, aside, className }: { title: ReactNode; description?: ReactNode; aside?: ReactNode; className?: string }) { | |
| 13 | +export function CardHeader({ title, description, aside, className, level = 3 }: { title: ReactNode; description?: ReactNode; aside?: ReactNode; className?: string; level?: 2 | 3 }) { | |
| 14 | + const H = level === 2 ? "h2" : "h3"; | |
| 14 | 15 | return ( |
| 15 | 16 | <div className={cn("flex items-start justify-between gap-3 border-b border-hair px-3 py-2.5 sm:px-4", className)}> |
| 16 | 17 | <div className="min-w-0"> |
| 17 | − <h3 className="text-[13.5px] font-semibold leading-tight text-ink">{title}</h3> | |
| 18 | + <H className="text-[13.5px] font-semibold leading-tight text-ink">{title}</H> | |
| 18 | 19 | {description && <p className="mt-0.5 text-[12px] text-ink-3">{description}</p>} |
| 19 | 20 | </div> |
| 20 | 21 | {aside && <div className="shrink-0">{aside}</div>} |
modified
apps/web/src/components/ui/charts.tsx
+1 −1
@@ -81,7 +81,7 @@ export function BarList({ items, format = fmtCompact, max, className, showRank = | ||
| 81 | 81 | {items.map((it, i) => { |
| 82 | 82 | const pct = it.value === null || it.value === undefined ? 0 : Math.max(1.5, (it.value / m) * 100); |
| 83 | 83 | const row = ( |
| 84 | − <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 py-[5px]"> | |
| 84 | + <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 py-[5px] sm:min-h-0"> | |
| 85 | 85 | {showRank ? <span className="figure w-5 text-right text-[11px] text-ink-4">{it.rank ?? i + 1}</span> : <span />} |
| 86 | 86 | <div className="min-w-0"> |
| 87 | 87 | <div className="flex items-baseline justify-between gap-2"> |
added
apps/web/src/components/ui/collapsible-rows.tsx
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { ChevronDown } from "lucide-react"; | |
| 4 | +import { useState, type ReactNode } from "react"; | |
| 5 | +import { fmtInt } from "@/lib/format"; | |
| 6 | +import { cn } from "./cn"; | |
| 7 | + | |
| 8 | +/** Rows kept visible while collapsed (matches `.dci-collapse` nth-child rule in globals.css). */ | |
| 9 | +export const COLLAPSE_ROWS = 10; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Mobile-only "Show all N" for long server-rendered lists (tables, <ol>/<ul>): under 640 px only the | |
| 13 | + * first COLLAPSE_ROWS rows are shown until the user expands. Purely CSS-driven (data attribute), so the | |
| 14 | + * children stay server components and nothing but serialisable props crosses the boundary. | |
| 15 | + * On ≥ 640 px every row is visible and the toggle is hidden. | |
| 16 | + */ | |
| 17 | +export function CollapsibleRows({ total, label = "rows", children, className }: { total: number; label?: string; children: ReactNode; className?: string }) { | |
| 18 | + const [open, setOpen] = useState(false); | |
| 19 | + const collapsible = total > COLLAPSE_ROWS + 2; | |
| 20 | + const collapsed = collapsible && !open; | |
| 21 | + return ( | |
| 22 | + <div className={cn("dci-collapse", className)} data-collapsed={collapsed ? "" : undefined}> | |
| 23 | + {children} | |
| 24 | + {collapsible && ( | |
| 25 | + <button | |
| 26 | + type="button" | |
| 27 | + onClick={() => setOpen((o) => !o)} | |
| 28 | + aria-expanded={!collapsed} | |
| 29 | + className="mt-1 flex h-11 w-full items-center justify-center gap-1.5 rounded-sm border border-hair bg-surface text-[12.5px] font-medium text-ink-2 hover:text-ink sm:hidden" | |
| 30 | + > | |
| 31 | + {collapsed ? `Show all ${fmtInt(total)} ${label}` : "Show fewer"} | |
| 32 | + <ChevronDown className={cn("size-3.5 transition-transform", !collapsed && "rotate-180")} strokeWidth={1.75} aria-hidden /> | |
| 33 | + </button> | |
| 34 | + )} | |
| 35 | + </div> | |
| 36 | + ); | |
| 37 | +} | |
modified
apps/web/src/components/ui/filter-chips.tsx
+4 −4
@@ -27,7 +27,7 @@ export function FilterChips({ chips, resetHref, onReset, className, children }: | ||
| 27 | 27 | <X className="size-3 text-ink-3" aria-hidden /> |
| 28 | 28 | </> |
| 29 | 29 | ); |
| 30 | − const cls = "inline-flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-sm border border-hair-2 bg-surface px-2 text-[12px] text-ink hover:bg-surface-2"; | |
| 30 | + const cls = "inline-flex h-10 shrink-0 items-center gap-1 whitespace-nowrap rounded-sm border border-hair-2 bg-surface px-2 text-[12px] text-ink hover:bg-surface-2 sm:h-7"; | |
| 31 | 31 | return c.removeHref ? ( |
| 32 | 32 | <Link key={c.key} href={c.removeHref} scroll={false} className={cls} aria-label={`Remove filter ${c.label ?? ""} ${typeof c.value === "string" ? c.value : ""}`}> |
| 33 | 33 | {inner} |
@@ -40,11 +40,11 @@ export function FilterChips({ chips, resetHref, onReset, className, children }: | ||
| 40 | 40 | })} |
| 41 | 41 | {chips.length > 0 && |
| 42 | 42 | (resetHref ? ( |
| 43 | − <Link href={resetHref} scroll={false} className="shrink-0 px-1 text-[12px] text-ink-3 hover:text-ink"> | |
| 43 | + <Link href={resetHref} scroll={false} className="inline-flex min-h-10 shrink-0 items-center px-1 text-[12px] text-ink-3 hover:text-ink sm:min-h-0"> | |
| 44 | 44 | Reset |
| 45 | 45 | </Link> |
| 46 | 46 | ) : onReset ? ( |
| 47 | − <button type="button" onClick={onReset} className="shrink-0 px-1 text-[12px] text-ink-3 hover:text-ink"> | |
| 47 | + <button type="button" onClick={onReset} className="inline-flex min-h-10 shrink-0 items-center px-1 text-[12px] text-ink-3 hover:text-ink sm:min-h-0"> | |
| 48 | 48 | Reset |
| 49 | 49 | </button> |
| 50 | 50 | ) : null)} |
@@ -60,7 +60,7 @@ export function ToggleChip({ active, onClick, children, dot, className }: { acti | ||
| 60 | 60 | aria-pressed={active} |
| 61 | 61 | onClick={onClick} |
| 62 | 62 | className={cn( |
| 63 | − "inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-sm border px-2.5 text-[12.5px] transition-colors", | |
| 63 | + "inline-flex h-10 items-center gap-1.5 whitespace-nowrap rounded-sm border px-2.5 text-[12.5px] transition-colors sm:h-8", | |
| 64 | 64 | active ? "border-ink bg-ink text-paper" : "border-hair-2 bg-surface text-ink-2 hover:bg-surface-2", |
| 65 | 65 | className, |
| 66 | 66 | )} |
modified
apps/web/src/components/ui/metric.tsx
+17 −9
@@ -36,21 +36,29 @@ export function Metric({ value, unit, label, delta, deltaLabel, note, size = "md | ||
| 36 | 36 | ); |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | −/** Horizontal metric strip — scrolls on mobile (snap), grid on ≥ md. Hairline separators, no cards. */ | |
| 39 | +/** | |
| 40 | + * Metric strip. Under 640 px: a 2-column grid so every figure is visible without scrolling (an odd | |
| 41 | + * last cell spans both columns); ≥ 640 px: one row of `cols` columns. Hairline separators, no cards. | |
| 42 | + */ | |
| 40 | 43 | export function MetricRow({ children, className, cols }: { children: ReactNode; className?: string; cols?: number }) { |
| 41 | 44 | return ( |
| 42 | − <div className={cn("-mx-3 overflow-x-auto no-scrollbar sm:mx-0", className)}> | |
| 43 | − <div | |
| 44 | − className={cn("flex min-w-max divide-x divide-hair border-y border-hair px-3 sm:grid sm:min-w-0 sm:px-0 sm:[&>*]:min-w-0")} | |
| 45 | − style={cols ? { gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` } : undefined} | |
| 46 | − > | |
| 47 | − {children} | |
| 48 | − </div> | |
| 45 | + <div | |
| 46 | + className={cn( | |
| 47 | + "grid grid-cols-2 border-y border-hair [&>*]:min-w-0", | |
| 48 | + // phones: vertical hairline between the two columns, horizontal hairline between rows, odd last cell full width | |
| 49 | + "max-sm:[&>*:nth-child(even)]:border-l max-sm:[&>*:nth-child(n+3)]:border-t max-sm:[&>*:nth-child(odd)]:pl-0 max-sm:[&>*:last-child:nth-child(odd)]:col-span-2 max-sm:[&>*]:border-hair", | |
| 50 | + // ≥ sm: one row, separators between cells | |
| 51 | + "sm:[grid-template-columns:var(--metric-cols)] sm:[&>*+*]:border-l sm:[&>*]:border-hair", | |
| 52 | + className, | |
| 53 | + )} | |
| 54 | + style={{ ["--metric-cols" as string]: `repeat(${cols ?? 4}, minmax(0, 1fr))` }} | |
| 55 | + > | |
| 56 | + {children} | |
| 49 | 57 | </div> |
| 50 | 58 | ); |
| 51 | 59 | } |
| 52 | 60 | |
| 53 | 61 | /** Wrapper for one metric cell inside MetricRow. */ |
| 54 | 62 | export function MetricCell({ children, className }: { children: ReactNode; className?: string }) { |
| 55 | − return <div className={cn("min-w-[132px] px-3 py-3 first:pl-0 sm:min-w-0 sm:px-4 sm:first:pl-0", className)}>{children}</div>; | |
| 63 | + return <div className={cn("min-w-0 px-3 py-3 sm:px-4 sm:first:pl-0", className)}>{children}</div>; | |
| 56 | 64 | } |
modified
apps/web/src/components/ui/pagination.tsx
+1 −1
@@ -61,7 +61,7 @@ export function Pagination({ page, perPage, total, hrefFor, className, label = " | ||
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 63 | function PageLink({ href, label, children }: { href: string | null; label: string; children: React.ReactNode }) { |
| 64 | − const cls = "inline-flex size-9 items-center justify-center rounded-sm border border-hair text-ink-2"; | |
| 64 | + const cls = "inline-flex size-10 items-center justify-center rounded-sm border border-hair text-ink-2 sm:size-9"; | |
| 65 | 65 | if (!href) return <span className={cn(cls, "opacity-40")}>{children}</span>; |
| 66 | 66 | return ( |
| 67 | 67 | <Link href={href} aria-label={label} scroll={false} className={cn(cls, "hover:bg-surface-2")}> |
modified
apps/web/src/components/ui/table.tsx
+22 −7
@@ -1,20 +1,32 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { HTMLAttributes, ReactNode, TdHTMLAttributes, ThHTMLAttributes } from "react"; |
| 3 | 3 | import { cn } from "./cn"; |
| 4 | +import { CollapsibleRows } from "./collapsible-rows"; | |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | 7 | * Dense data table. Sticky header (via .dci-table th), hairline rows, tabular numerics. |
| 7 | − * `responsive` turns rows into stacked cards under 640 px — every <Td> must carry data-label | |
| 8 | − * (use <Td label="…">) and the leading cell should be <Td primary>. | |
| 8 | + * `responsive` turns rows into compact two-line rows under 640 px — the first line is the | |
| 9 | + * <Td primary> cell with the optional <Td lead> figure on the right, the other cells wrap under it | |
| 10 | + * as "LABEL value" pairs (every <Td> must carry `label`). Cells hidden with `hidden sm:table-cell` | |
| 11 | + * stay hidden on phones. | |
| 12 | + * `mobileCollapse` shows only the first 10 rows under 640 px with a "Show all N" toggle. | |
| 9 | 13 | */ |
| 10 | −export function Table({ className, responsive = true, children, dense, ...rest }: HTMLAttributes<HTMLTableElement> & { responsive?: boolean; dense?: boolean }) { | |
| 11 | − return ( | |
| 14 | +export function Table({ className, responsive = true, children, dense, mobileCollapse, ...rest }: HTMLAttributes<HTMLTableElement> & { responsive?: boolean; dense?: boolean; mobileCollapse?: { total: number; label?: string } }) { | |
| 15 | + const table = ( | |
| 12 | 16 | <div className={cn("w-full", !responsive && "overflow-x-auto")}> |
| 13 | 17 | <table className={cn("dci-table text-[13px]", responsive && "responsive", dense && "dense", className)} {...rest}> |
| 14 | 18 | {children} |
| 15 | 19 | </table> |
| 16 | 20 | </div> |
| 17 | 21 | ); |
| 22 | + if (mobileCollapse && responsive) { | |
| 23 | + return ( | |
| 24 | + <CollapsibleRows total={mobileCollapse.total} label={mobileCollapse.label}> | |
| 25 | + {table} | |
| 26 | + </CollapsibleRows> | |
| 27 | + ); | |
| 28 | + } | |
| 29 | + return table; | |
| 18 | 30 | } |
| 19 | 31 | |
| 20 | 32 | export function THead({ children, className }: { children: ReactNode; className?: string }) { |
@@ -71,12 +83,15 @@ export interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> { | ||
| 71 | 83 | numeric?: boolean; |
| 72 | 84 | primary?: boolean; |
| 73 | 85 | muted?: boolean; |
| 86 | + /** key figure of the row: sits right of the primary cell on the first line under 640 px */ | |
| 87 | + lead?: boolean; | |
| 74 | 88 | } |
| 75 | −export function Td({ children, className, label, numeric, primary, muted, ...rest }: TdProps) { | |
| 89 | +export function Td({ children, className, label, numeric, primary, muted, lead, ...rest }: TdProps) { | |
| 76 | 90 | return ( |
| 77 | 91 | <td |
| 78 | 92 | data-label={label} |
| 79 | 93 | data-primary={primary ? "" : undefined} |
| 94 | + data-lead={lead ? "" : undefined} | |
| 80 | 95 | className={cn("px-2 py-2 first:pl-0 last:pr-0", numeric && "figure text-right", muted && "text-ink-3", className)} |
| 81 | 96 | {...rest} |
| 82 | 97 | > |
@@ -85,10 +100,10 @@ export function Td({ children, className, label, numeric, primary, muted, ...res | ||
| 85 | 100 | ); |
| 86 | 101 | } |
| 87 | 102 | |
| 88 | −/** Row link that covers the primary cell (keeps the table semantic). */ | |
| 103 | +/** Row link (keeps the table semantic). Under 640 px, inside a <Td primary>, it stretches over the whole row (`.dci-rowlink`). */ | |
| 89 | 104 | export function RowLink({ href, children, className }: { href: string; children: ReactNode; className?: string }) { |
| 90 | 105 | return ( |
| 91 | − <Link href={href} className={cn("font-medium text-ink hover:text-accent hover:underline", className)}> | |
| 106 | + <Link href={href} className={cn("dci-rowlink font-medium text-ink hover:text-accent hover:underline", className)}> | |
| 92 | 107 | {children} |
| 93 | 108 | </Link> |
| 94 | 109 | ); |
modified
apps/web/src/lib/format.ts
+28 −5
@@ -14,11 +14,32 @@ export function fmtInt(n: number | null | undefined): string { | ||
| 14 | 14 | return new Intl.NumberFormat(EN, { maximumFractionDigits: 0 }).format(n); |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | −/** Compact numbers: 1.2K, 3.4M. */ | |
| 17 | +/** `toFixed` with trailing zeros stripped ("15.0" → "15", "1.20" → "1.2"). */ | |
| 18 | +function fixed(n: number, digits: number): string { | |
| 19 | + const s = n.toFixed(digits); | |
| 20 | + return s.includes(".") ? s.replace(/\.?0+$/, "") : s; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Compact numbers: 1.2K, 3.4M, 15B. Hand-rolled (K/M/B/T) rather than `Intl` `notation: "compact"`: | |
| 25 | + * ICU builds differ between Node and browsers on how `maximumFractionDigits` rounds compact values | |
| 26 | + * ("$15.0B" vs "$15B"), which produced React hydration mismatches (#418) on the live feed. | |
| 27 | + */ | |
| 18 | 28 | export function fmtCompact(n: number | null | undefined, digits = 1): string { |
| 19 | 29 | if (!isNum(n)) return DASH; |
| 20 | − if (Math.abs(n) < 1000) return new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(n); | |
| 21 | − return new Intl.NumberFormat(EN, { notation: "compact", maximumFractionDigits: digits }).format(n); | |
| 30 | + const sign = n < 0 ? "−" : ""; | |
| 31 | + const abs = Math.abs(n); | |
| 32 | + if (abs < 1000) return `${sign}${new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(abs)}`; | |
| 33 | + const units: Array<[number, string]> = [ | |
| 34 | + [1e12, "T"], | |
| 35 | + [1e9, "B"], | |
| 36 | + [1e6, "M"], | |
| 37 | + [1e3, "K"], | |
| 38 | + ]; | |
| 39 | + for (const [v, suffix] of units) { | |
| 40 | + if (abs >= v) return `${sign}${fixed(abs / v, digits)}${suffix}`; | |
| 41 | + } | |
| 42 | + return `${sign}${fixed(abs, digits)}`; | |
| 22 | 43 | } |
| 23 | 44 | |
| 24 | 45 | /** Split a MW value into { value, unit } — GW when ≥ 1000 MW; 0–1 decimals. */ |
@@ -38,10 +59,12 @@ export function fmtMw(mw: number | null | undefined): string { | ||
| 38 | 59 | return s ? `${s.value} ${s.unit}` : DASH; |
| 39 | 60 | } |
| 40 | 61 | |
| 41 | −/** Compact USD: $1.2B, $450M. */ | |
| 62 | +/** Compact USD: $1.2B, $450M, $15B — deterministic between server and client (see fmtCompact). */ | |
| 42 | 63 | export function fmtUsd(v: number | null | undefined): string { |
| 43 | 64 | if (!isNum(v)) return DASH; |
| 44 | − return new Intl.NumberFormat(EN, { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: v >= 1e9 ? 1 : 0 }).format(v); | |
| 65 | + const abs = Math.abs(v); | |
| 66 | + const body = fmtCompact(abs, abs >= 1e9 ? 1 : 0); | |
| 67 | + return `${v < 0 ? "−" : ""}$${body}`; | |
| 45 | 68 | } |
| 46 | 69 | |
| 47 | 70 | export function fmtPct(share: number | null | undefined, digits = 0): string { |
modified
apps/worker/src/connectors/news/article-parser.ts
+3 −0
@@ -169,6 +169,9 @@ export const newsArticleParser: Parser = { | ||
| 169 | 169 | if (!content.text || content.text.length < 80) return []; |
| 170 | 170 | const a = extractAnnouncement(content.title, content.text, { publishedAt: content.published }); |
| 171 | 171 | if (a.relevance === "none") { ctx.log("debug", `news_article_v1: not about data centers, skipped ${doc.finalUrl}`); return []; } |
| 172 | + // Third-party publishers (keepText false): an article only weakly about data centers, or whose data-center vocabulary | |
| 173 | + // sits deep in the body, is not indexed at all. The sentinel keeps the GenericConnector fallback from archiving it. | |
| 174 | + if (!p.keepText && (a.relevance === "weak" || !a.leadRelevance)) { ctx.log("debug", `news_article_v1: off-topic for a third-party publisher, skipped ${doc.finalUrl}`); return [indexSentinel(ctx.connectorId, doc.finalUrl)]; } | |
| 172 | 175 | return buildRecords({ connectorId: ctx.connectorId, url: doc.finalUrl, a, content, params: p }); |
| 173 | 176 | }, |
| 174 | 177 | }; |
modified
apps/worker/src/connectors/news/extract-project.test.ts
+87 −0
@@ -192,6 +192,93 @@ describe("announcement → project extraction", () => { | ||
| 192 | 192 | }); |
| 193 | 193 | |
| 194 | 194 | describe("helpers", () => { |
| 195 | + describe("precision pass 2026-09-11 (hand-classified false positives)", () => { | |
| 196 | + it("appointments, awards, interviews, events and leadership changes never yield a project", () => { | |
| 197 | + for (const [t, body] of [ | |
| 198 | + ["AirTrunk appoints Laura Coad as Chief Data Centre Officer (CDCO)", "Asia Pacific & Japan (APJ) hyperscale data centre specialist, AirTrunk today announced the executive appointment of Laura Coad as Chief Data Centre Officer, responsible for 1.4GW of capacity across the region."], | |
| 199 | + ["AirTrunk recognised alongside Blackstone at Mergermarket M&A Awards", "AirTrunk, the hyperscale data centre specialist, was recognised at the awards for the 300MW SYD1 campus transaction."], | |
| 200 | + ["AITech interview with COO Erling Guðmundsson - atNorth", "How AI impacts digital infrastructure and how atNorth is shaping a sustainable future with its 60MW data center campus plans."], | |
| 201 | + ["Women in Construction and Why It Matters: CyrusOne Hosts Inaugural Event in Madrid", "New 90 megawatt campus places emphasis on biodiversity, sustainability and giving back to the local community. CyrusOne plans to build the campus in phases."], | |
| 202 | + ["Cassava Technologies Announces Leadership Changes To Accelerate Growth", "Cassava Technologies announces new strategic appointments to the leadership team as the group plans a 6MW data centre expansion in Nairobi."], | |
| 203 | + ] as Array<[string, string]>) { | |
| 204 | + const a = x(t, body); | |
| 205 | + expect(a.nonProjectTitle, t).toBe(true); | |
| 206 | + expect(qualifiesAsProject(a), t).toBe(false); | |
| 207 | + expect(a.eventType, t).toBe("news"); | |
| 208 | + } | |
| 209 | + }); | |
| 210 | + it("partnerships, hosting agreements, POP deployments, financing and market reports are not projects", () => { | |
| 211 | + for (const [t, body] of [ | |
| 212 | + ["CyrusOne and E.ON Announce Strategic Partnership to Overcome Data Center Grid Capacity Constraints for Customers in Europe", "Local power generation system designed to deliver an additional 61 megawatts of electricity to CyrusOne's FRA7 data center by 2029, expanding the campus' total capacity to 126MW."], | |
| 213 | + ["Duos Technologies Signs 55 MW AI Hosting Agreements with Axe Compute", "Five-year hosting agreements cover 55 MW of AI facility capacity. Agreements represent more than $500 million in base payments."], | |
| 214 | + ["China Telecom Deploys POP At Africa Data Centres Johannesburg Facility", "Africa Data Centres has extended its partnership with China Telecom, which has deployed a point of presence at the 14MW Johannesburg data centre."], | |
| 215 | + ["AirTrunk gears up for Asia-Pacific expansion with AUD $850 million financing", "AirTrunk has successfully completed an AUD $850 million financing process to fund a major expansion of its 90MW hyperscale data centre platform."], | |
| 216 | + ["Data Center Chillers Market to Surpass $6.4 Billion by 2035", "The global data center chillers market is projected to grow from USD 2.8 billion in 2026 to USD 6.4 billion by 2035 at a 9.6% CAGR, propelled by escalating hyperscale data center construction."], | |
| 217 | + ["Starcloud Raises $250m Series A Extension At $2.3bn Valuation For Orbital AI Data Centers", "Orbital data center company Starcloud has raised $250 million in a Series A extension, planning 20GW of orbital compute."], | |
| 218 | + ["House Bill Would Put Federal Electricity Tax on Data Centers", "U.S. bill proposes 1¢/kWh tax on data center electricity over 1 MW, funding five federal programs."], | |
| 219 | + ["Schneider Electric's $2.3 Billion in AI Power and Cooling Deals Sends Message to Data Center Sector", "Twin supply-capacity agreements by Schneider Electric with Switch and Digital Realty show how AI data centers are starting to lock up power and thermal infrastructure. Switch plans a 2MW pilot in Las Vegas."], | |
| 220 | + ["Data Centre Developer Sues New Jersey Town for $300m", "Data centre developer Hexa Builders is seeking $300 million in damages from Monroe Township, New Jersey, after officials rejected its proposed 1 million sq ft data center."], | |
| 221 | + ] as Array<[string, string]>) { | |
| 222 | + const a = x(t, body); | |
| 223 | + expect(qualifiesAsProject(a), t).toBe(false); | |
| 224 | + } | |
| 225 | + }); | |
| 226 | + it("a build headline overrides deal vocabulary: 'Vantage secures approval for 300MW campus' still qualifies", () => { | |
| 227 | + const a = x("Vantage Data Centers secures planning approval for 300MW campus in Cardiff, Wales", "Vantage Data Centers has secured planning approval for a new 300MW data center campus in Cardiff. Construction of the first 48MW building begins in 2027."); | |
| 228 | + expect(a.nonProjectTitle).toBe(false); | |
| 229 | + expect(a.status).toBe("approved"); | |
| 230 | + expect(qualifiesAsProject(a)).toBe(true); | |
| 231 | + }); | |
| 232 | + it("openings and 'we are online' headlines describe existing facilities, not pipeline projects", () => { | |
| 233 | + for (const [t, body] of [ | |
| 234 | + ["AirTrunk opens Melbourne's largest hyperscale data centre campus", "A specialist in hyperscale data centres, AirTrunk today announced the opening of its second facility in the Asia-Pacific region, the 50MW MEL1 campus."], | |
| 235 | + ["Utah County, We Are Online! - Meta Data Centers", "Today, we are excited to announce that the Eagle Mountain Data Center in Utah is now serving traffic. The 800 MW campus was announced in 2018 and construction began soon after."], | |
| 236 | + ["DataBank Announces Opening of \"SLC5\" Salt Lake City Data Center", "DataBank announces the opening of its fifth Salt Lake City data center (SLC), a 66MW facility and the 20th data center the company has opened."], | |
| 237 | + ["2MW Delivered in Serverfarm's Chicago Data Center", "Serverfarm has delivered an additional 2MW of capacity in its Chicago data center for a hyperscale customer."], | |
| 238 | + ["AirTrunk opens AI-ready data centre in Malaysia, accelerating innovation and the energy transition", "JHB1 official opening ceremony attended by Malaysian government officials. AirTrunk has commenced operations at the 1.4GW-capable Johor campus, with plans to expand."], | |
| 239 | + ] as Array<[string, string]>) { | |
| 240 | + const a = x(t, body); | |
| 241 | + expect(a.status, t).toBe("operational"); | |
| 242 | + expect(qualifiesAsProject(a), t).toBe(false); | |
| 243 | + } | |
| 244 | + // a future opening remains a plan | |
| 245 | + expect(x("Equinix to open 20MW PA11 data center in Paris in 2027", "Equinix will open PA11, a 20MW facility, in Q2 2027.").status).toBe("announced"); | |
| 246 | + expect(x("Equinix opens 20MW PA11 data center in Paris", "Equinix has opened PA11.").status).toBe("operational"); | |
| 247 | + }); | |
| 248 | + it("portfolio totals are not the site figure: TOK2 (410MW in Japan, 1.2GW across APJ) and OSK1 (20MW)", () => { | |
| 249 | + const a = x("AirTrunk scales up in Japan with announcement of second Tokyo hyperscale data centre", "TOK2 increases AirTrunk's capacity to over 410MW in Japan and 1.2GW across Asia Pacific & Japan (APJ). AirTrunk today announced TOK2, a new 110MW hyperscale data centre in Inzai, Tokyo."); | |
| 250 | + expect(a.headlineMw).toBe(110); | |
| 251 | + const b = x("AirTrunk breaks ground in Osaka", "Construction begins on 20+MW OSK1 in West Osaka. Asia-Pacific & Japan hyperscale data centre specialist, AirTrunk, has begun the construction of its 20 megawatt OSK1 data centre, bringing its capacity across the region to 1.4GW."); | |
| 252 | + expect(b.headlineMw).toBe(20); | |
| 253 | + expect(b.status).toBe("under_construction"); | |
| 254 | + expect(qualifiesAsProject(b)).toBe(true); | |
| 255 | + // when the lead only quotes company totals, no lead figure is used | |
| 256 | + expect(x("AirTrunk expands in Japan with new data centre development in Osaka", "OSK1 will be AirTrunk's third data centre in Japan. The company now operates over 1.4GW across Asia Pacific.").headlineMw).toBeNull(); | |
| 257 | + }); | |
| 258 | + it("names never start with a unit: '1.5 GW Texas Data Center' → generated name; '8,000 Acre AI Campus' → generated name", () => { | |
| 259 | + const a = x("Prometheus' 1.5 GW Texas Data Center Plan Tests Private Power", "Prometheus Hyperscale plans an off-grid 1.5 GW Texas Data Center campus in Pecos, Texas. The 1.5 GW Texas Data Center would come online in 2027."); | |
| 260 | + expect(a.explicitName).toBeNull(); | |
| 261 | + expect(a.projectName).not.toMatch(/^GW /); | |
| 262 | + const b = x("Amazon Explores Grid Connection for 8,000 Acre AI Campus", "Amazon is exploring a grid connection for an 8,000 Acre AI Campus in Louisiana that would combine a private-generation complex with an AI data center. The 8,000 Acre AI Campus has drawn scrutiny."); | |
| 263 | + expect(b.explicitName).toBeNull(); | |
| 264 | + expect(b.projectName).not.toMatch(/^Acre /); | |
| 265 | + }); | |
| 266 | + it("an unknown headline subject keeps body operators out: Cerebras/G42, Greenfield/Google, JV/Dallas", () => { | |
| 267 | + const a = x("Cerebras Begins Construction Of 165MW Data Center In Mikkeli, Finland", "Cerebras has begun construction on a 165MW data center campus in Mikkeli, Finland, with the first 50MW phase now underway. The campus is being developed with G42 as anchor customer."); | |
| 268 | + expect(a.operator).toBeNull(); | |
| 269 | + expect(a.location?.country).toBe("FI"); // Mikkeli is not in the city lexicon: country only, never a guessed city | |
| 270 | + expect(a.headlineMw).toBe(165); | |
| 271 | + const b = x("Greenfield And Finsbury Infrastructure To Invest €360M In Milan Data Center", "UK infrastructure developer Greenfield and Finsbury Infrastructure plan to invest up to €360 million ($418m) to develop a 36MW data center east of Milan, Italy, near Google's existing campus."); | |
| 272 | + expect(b.operator).toBeNull(); | |
| 273 | + expect(b.headlineMw).toBe(36); | |
| 274 | + const c = x("JV Plans 54MW Data Center In Dallas, Texas", "A new 54MW data center is planned for the North Dallas Corridor, with Northampton Capital Partners and Provident Data Centers targeting a late 2027 launch."); | |
| 275 | + expect(c.location?.city).toBe("Dallas"); | |
| 276 | + expect(c.projectName).toBe("Dallas data center project"); | |
| 277 | + // a known operator in the title is unaffected | |
| 278 | + expect(x("Microsoft plans 300MW campus in Mount Pleasant, Wisconsin", "Microsoft plans a 300MW campus in Mount Pleasant, Wisconsin.").operator?.name).toBe("Microsoft"); | |
| 279 | + }); | |
| 280 | + }); | |
| 281 | + | |
| 195 | 282 | it("parseAllMoney keeps the largest first and ignores small figures", () => { |
| 196 | 283 | expect(parseAllMoney("a $2 billion project with a $150 million first phase and $25/MWh power")).toEqual([{ amount: 2_000_000_000, currency: "USD" }, { amount: 150_000_000, currency: "USD" }]); |
| 197 | 284 | }); |
modified
apps/worker/src/connectors/news/extract-project.ts
+111 −13
@@ -20,6 +20,8 @@ export interface Announcement { | ||
| 20 | 20 | leadRelevance: boolean; |
| 21 | 21 | /** the title itself names a data center / campus / MW figure or a known operator */ |
| 22 | 22 | titleSignal: boolean; |
| 23 | + /** the headline is about people, awards, deals, financing, market research, policy or opinion — never a project */ | |
| 24 | + nonProjectTitle: boolean; | |
| 23 | 25 | mwAll: number[]; |
| 24 | 26 | headlineMw: number | null; |
| 25 | 27 | money: Money | null; |
@@ -62,7 +64,7 @@ export const STATUS_RULES: Array<[RegExp, FacilityStatus]> = [ | ||
| 62 | 64 | [/\b(planning application|permit application|rezoning (request|application|case|bid)|seeking approval|applied for|files? (plans|an application|for|a planning|planning)|filed (plans|an application|for)|submitted (plans|an application|a proposal|a planning)|submits? (plans|an application|a proposal)|under review by|seeks? (approval|permission|rezoning|to rezone|planning)|planning (commission|board|committee) (will|to|is set to) (consider|review|hear|vote)|public hearing|lodged (plans|a planning|an application)|lodges? (plans|a planning|an application))\b/i, "permitting"], |
| 63 | 65 | [/\b(under construction|being built|construction (is )?under ?way|break(s|ing)? ground|broke ground|groundbreaking|topped out|construction (has |have )?(started|began|begun|commenced)|begins? construction|starts? construction|kicks? off construction|construction (start|begins|kicks off))\b/i, "under_construction"], |
| 64 | 66 | [/\b(partially operational|phase\s*(1|one|I)\s*(is\s*)?(now\s*)?(live|operational|complete|completed|open|opens|opened))\b/i, "partially_operational"], |
| 65 | − [/\b(announc(es|ed|ing)|unveil(s|ed)?|plans? (to build|for|a|to develop|to invest|to construct|to create)|will build|to build|to develop|reveal(s|ed)? plans|launch(es|ed)? plans|proposes? to build|set to build|to construct|will invest|to invest|is planning|are planning|planning to (build|develop|invest)|commits? (to|\$|€|£)|pledges?|earmarks?)\b/i, "announced"], | |
| 67 | + [/\b(announc(es|ed|ing)|unveil(s|ed)?|plans? (to build|for|a|to develop|to invest|to construct|to create|to open)|will build|to build|to develop|reveal(s|ed)? plans|launch(es|ed)? plans|proposes? to build|set to build|to construct|will invest|to invest|is planning|are planning|planning to (build|develop|invest|open)|commits? (to|\$|€|£)|pledges?|earmarks?|to open|will open|set to open|due to open|slated to open)\b/i, "announced"], | |
| 66 | 68 | [/\b(proposed|proposal|proposes|pitches|pitched)\b/i, "proposed"], |
| 67 | 69 | [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks|eyes|eyeing|mulls?|mulling|weighs?|weighing|scouting|could build)\b/i, "rumored"], |
| 68 | 70 | [/\b(expansion|expanding|expands|adds? capacity|new phase|additional \d+ ?MW|second phase|next phase|extension of)\b/i, "expansion"], |
@@ -71,8 +73,18 @@ export const STATUS_RULES: Array<[RegExp, FacilityStatus]> = [ | ||
| 71 | 73 | /** "will come online in 2027" is a plan, not an operational facility. */ |
| 72 | 74 | const FUTURE_ONLINE_RE = /\b(to|will|would|expected to|slated to|set to|due to|scheduled to|is expected to|are expected to|aims? to|plans? to) (come|go|be|become) (online|live|operational|ready for service|energi[sz]ed|commissioned)\b|\b(come|coming|go|going) (online|live) (in|by) (Q[1-4]|20\d\d|early|late|mid|the)\b|\b(open|opening|operational|online|live|ready for service|RFS|completion|complete|delivered?|delivery) (in|by|for|from) (Q[1-4]|H[12]|20\d\d|early|late|mid|the (first|second) half)\b/i; |
| 73 | 75 | |
| 76 | +/** | |
| 77 | + * A headline about a facility that is open / online / delivered describes an existing facility, whatever else the | |
| 78 | + * sentence "announces" ("AirTrunk today announced the opening of…", "Utah County, We Are Online!", "2MW Delivered"). | |
| 79 | + */ | |
| 80 | +export const OPENING_TITLE_RE = /\b(opens|opened|opening of|now open|officially open(?:s|ed)?|now online|we are online|is online|are online|now live|goes live|went live|now serving|inaugurat\w+|delivered|commissioned|completes|completion of|celebrates? (?:the )?(?:opening|launch|grand opening)|ribbon[- ]cutting|welcomes? (?:its )?first customers?)\b/i; | |
| 81 | +const FUTURE_OPEN_RE = /\b(to|will|set to|plans? to|due to|expected to|slated to|scheduled to|aims? to|on track to) (open|be online|go live|be delivered|be completed|be commissioned)\b|\b(open|opening|online|live|delivered|completed) (in|by) (Q[1-4]|H[12]|20\d\d|early|late|mid)\b/i; | |
| 82 | + | |
| 74 | 83 | export function inferStatusPrecedence(title: string | null, text: string): { status: FacilityStatus | null; method: string } { |
| 75 | 84 | const scopes: Array<[string, string]> = [["title", title ?? ""], ["lead", text.slice(0, 1500)], ["body", text.slice(0, 6000)]]; |
| 85 | + if (title && OPENING_TITLE_RE.test(title) && !FUTURE_OPEN_RE.test(title) && !FUTURE_ONLINE_RE.test(title) && !STATUS_RULES.slice(0, 3).some(([re]) => re.test(title))) { | |
| 86 | + return { status: "operational", method: "regex:status_v1:title-opening" }; | |
| 87 | + } | |
| 76 | 88 | for (const [name, scope] of scopes) { |
| 77 | 89 | if (!scope.trim()) continue; |
| 78 | 90 | const matched = STATUS_RULES.filter(([re]) => re.test(scope)).map(([, st]) => st); |
@@ -85,6 +97,56 @@ export function inferStatusPrecedence(title: string | null, text: string): { sta | ||
| 85 | 97 | return { status: null, method: "none" }; |
| 86 | 98 | } |
| 87 | 99 | |
| 100 | +// ─── headline vetoes ─────────────────────────────────────────────────────────────────────────────── | |
| 101 | +/** | |
| 102 | + * Headlines that are about something other than a facility being planned or built: people, awards, events, | |
| 103 | + * partnerships and commercial deals, financing, market research, legal and policy stories, opinion. None of them | |
| 104 | + * yields a project (whatever MW figures the body quotes), and their event type falls back to plain `news`. | |
| 105 | + */ | |
| 106 | +export const NON_PROJECT_TITLE_RE = new RegExp( | |
| 107 | + [ | |
| 108 | + // people | |
| 109 | + "appoint(?:s|ed|ment)?|names? [A-Z][a-z]+ [A-Z][a-z]+ (?:as|to)|joins? (?:as|the board)|(?:new|hires?|promotes?) (?:CEO|CFO|COO|CTO|CRO|CIO|chief|president|head of|director|vice president|VP|managing director)|leadership (?:change|team|appointments?)|steps? down|retire(?:s|ment)|passing of|obituary|in memoriam|welcomes [A-Z][a-z]+ [A-Z][a-z]+", | |
| 110 | + // awards, rankings, events, marketing | |
| 111 | + "awards?\\b|award-winning|recogni[sz]ed|honou?red|wins? (?:the )?(?:[A-Z]\\w+ )?(?:award|prize)|finalist|shortlist|named (?:a |one of |to )?(?:top|best|leader|great place)|certif(?:ied|ication) (?:as|for|by)|(?:earns?|achieves?|receives?) (?:[A-Z]\\w+ )?(?:certification|rating|accreditation|iso \\d)|interview|webinar|podcast|fireside|keynote|to speak|speaks? at|panel|summit|conference|expo\\b|exhibit|booth|hosts? (?:inaugural |annual )?(?:event|gala|open house|tour)|women in|diversity|scholarship|volunteer|charity|donat(?:es|ion)|community (?:day|commitment|grant)|tree|sponsor|customer spotlight|case study|white ?paper|e-?book|guide to|checklist|tips|best practices|explained|101\\b|q&a|faq|behind the scenes|inside look|a look (?:at|inside)|day in the life|celebrat(?:es|ing) \\d+ years|anniversary", | |
| 112 | + // partnerships / commercial deals (not construction) | |
| 113 | + "partnership|partners? with|join forces|team(?:s|ed)? up|collaborat(?:es?|ion)|alliance|memorandum|mou\\b|deals?\\b|sends? (?:a )?message|deploys? (?:a )?pop|point of presence|peering|interconnect(?:s|ion)? (?:with|to)|cloud on-?ramp|exchange point|selects? [A-Z]\\w+ (?:for|as|to)|chooses? [A-Z]\\w+ (?:for|as|to)|(?:hosting|colocation|lease|leasing|services?|master|supply|framework|offtake) agreements?|signs? (?:multi-year |long-term |a )?(?:agreement|contract|deal|lease)s?|secures? (?:\\$[\\d.,]+[mb]n? )?(?:hyperscaler |anchor |major )?(?:contract|tenant|customer|lease)|renews? (?:lease|contract)|expands? (?:its )?(?:relationship|partnership)|adds? [A-Z]\\w+ (?:as|to its)|launches? (?:new )?(?:service|platform|product|portal|program|offering|solution|marketplace)|available (?:now|in)|now available|integrat(?:es|ion) with|certified by", | |
| 114 | + // financing / corporate | |
| 115 | + "financ(?:es|ing)|refinanc\\w+|raises? \\$|series [a-e]\\b|valuation|funding (?:round|arranged|secured)|loan|credit facility|green bond|bonds?\\b|notes? offering|debt|equity|ipo\\b|listing|shareholders?|dividend|quarterly results|half[- ]year results|annual results|q[1-4] (?:fy)?\\d{2}(?:\\d{2})? results|(?:1h|2h|fy)\\d{2} results|earnings|revenue|ebitda|guidance|investor (?:day|presentation|update)|s&p|moody|fitch|rating agency|acquisition of [A-Z]\\w+ (?:group|holdings|inc|ltd|llc)|acquires? [A-Z]\\w+ (?:group|holdings|inc|ltd|llc)|merger|takeover|buyout|stake in", | |
| 116 | + // market research / policy / legal / opinion | |
| 117 | + "market (?:to|will|set to|expected to|projected to) (?:surpass|reach|hit|grow|exceed|top)|market size|cagr|forecast|outlook|report(?: finds| shows| reveals|:)|survey|study (?:finds|shows|reveals)|index\\b|trends?|predictions?|what to expect|state of|whitepaper|bill (?:would|to|passes|introduced)|legislation|lawmakers|senate|congress|house bill|regulat(?:ion|ors?)|tax (?:on|credit|break)|moratorium|ban\\b|sues?|lawsuit|litigation|court|settles?|settlement|fine[ds]?\\b|penalty|opinion|op-ed|analysis|why |how |what |the case for|explainer|myth|debate|lessons", | |
| 118 | + ] | |
| 119 | + .map((alt) => `\\b(?:${alt})`) | |
| 120 | + .join("|"), | |
| 121 | + "i", | |
| 122 | +); | |
| 123 | +/** A headline that clearly describes building / planning a facility overrides the veto ("Vantage breaks ground…", "… files plans for 300MW campus"). */ | |
| 124 | +export const BUILD_TITLE_RE = /\b(breaks? ground|broke ground|groundbreaking|to build|will build|plans? (?:for|to build|to develop|to construct|new|\$|€|£|\d)|proposes?|proposed|approv(?:es|ed|al)|files? (?:plans|application|for)|rezon\w+|to develop|to construct|construction (?:begins|starts|start|of|on|underway|under way)|unveils? (?:plans|new|\d|\$)|announces? (?:plans|new|its|a new|\$|€|£|\d)|to invest|invests? [\$€£]|expansion (?:of|at|in)|expands? (?:with|in|into|its)|new (?:\d+ ?MW|\d+ ?GW|hyperscale|data cent(?:er|re)|campus)|(?:\d+ ?(?:MW|GW)) (?:campus|data cent(?:er|re)|facility|site|project)|permit(?:s|ted)? (?:granted|issued|approved)|greenlit|green-?light|land (?:purchase|acquisition|deal) for|acquires? (?:\d+[- ]acre|land|site)|topping out|topped out|phase \d|energi[sz](?:ed|es)|breaks? ground)\b/i; | |
| 125 | + | |
| 126 | +/** true when the headline is about people / deals / money / markets / policy and does not describe a build. */ | |
| 127 | +export function isNonProjectTitle(title: string | null): boolean { | |
| 128 | + if (!title) return false; | |
| 129 | + return NON_PROJECT_TITLE_RE.test(title) && !BUILD_TITLE_RE.test(title); | |
| 130 | +} | |
| 131 | + | |
| 132 | +/** | |
| 133 | + * Title subject: the proper-noun phrase before the first announcement verb. When it names a company that is not in | |
| 134 | + * the operator lexicon ("Cerebras Begins Construction…", "Greenfield And Finsbury Infrastructure To Invest…", | |
| 135 | + * "JV Plans 54MW Data Center…"), operators found in the body are partners, tenants or neighbours — not the developer. | |
| 136 | + */ | |
| 137 | +const TITLE_VERBS = "to|plans?|begins?|breaks?|files?|proposes?|announces?|secures?|invests?|unveils?|expands?|starts?|wins?|gets?|seeks?|eyes|raises|acquires|buys|signs?|launches?|opens?|confirms?|reveals?|sues|picks|selects|lands|inks|taps|targets|commits|pledges|explores|weighs|mulls|readies|advances|completes|delivers|scraps|cancels|delays|pauses|halts|receives|obtains|submits|lodges|withdraws|kicks|moves|set|slated|eyeing|weighing|considering"; | |
| 138 | +const TITLE_SUBJECT_RE = new RegExp(`^((?:[A-Z][\\w&'’.-]*|and|&|of|de|du|the)(?:\\s(?:[A-Z][\\w&'’.-]*|and|&|of|de|du|the)){0,5})(?:’s|'s)?\\s+(?:${TITLE_VERBS})\\b`, "i"); | |
| 139 | +export function unknownTitleSubject(title: string | null): boolean { | |
| 140 | + if (!title) return false; | |
| 141 | + const m = title.match(TITLE_SUBJECT_RE); | |
| 142 | + if (!m) return false; | |
| 143 | + const subject = m[1]!.trim(); | |
| 144 | + if (!/[A-Z]/.test(subject)) return false; // no proper noun at all | |
| 145 | + if (subject.split(/\s+/).length === 1 && GENERIC_WORD.test(subject)) return false; // "New", "Local", "Massive" | |
| 146 | + if (/^(Data Cent(?:er|re)s?|Developer|Company|Firm|Group|Council|County|City|Board|Utility|Town|State|Officials?|Residents?|Neighbou?rs|Report|Study|Bill|Judge|Court|Governor|Mayor|Senator)\b/i.test(subject) && subject.split(/\s+/).length <= 3) return true; | |
| 147 | + return detectOperators(subject).length === 0 && !/\b(county|city|town|township|parish|council|board|commission|state|province|government|ministry|department)\b/i.test(subject); | |
| 148 | +} | |
| 149 | + | |
| 88 | 150 | // ─── money ───────────────────────────────────────────────────────────────────────────────────────── |
| 89 | 151 | const CURRENCY_START_RE = /(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?\d/g; |
| 90 | 152 | /** Approximate rates — gating only (is this ≥ $50M?), never persisted. */ |
@@ -191,6 +253,8 @@ export function parseExpectedOpening(text: string, opts: DateBounds): { value: s | ||
| 191 | 253 | const NAME_RE = /(?<![A-Za-z])((?:[A-Z][A-Za-z0-9&'’.-]*\s){1,4}(?:Campus|Data Cent(?:er|re)|Technology Park|Digital (?:Gateway|Park|Hub|Campus)|Innovation Park|Business Park|AI (?:Campus|Factory|Hub)|Hub|Gateway|Cluster|Supercomputer)(?:\s(?:Campus|Park|Project))?)(?![A-Za-z])/g; |
| 192 | 254 | const GENERIC_WORD = /^(Trillion|Billion|Million|Trillion-Dollar|Billion-Dollar|Gigawatt|Megawatt|Faces|Delays|Pushes|Enters|Assessing|Latest|Why|How|What|When|Where|Inside|Beyond|Behind|Building|Powering|Cooling|Designing|Scaling|Financing|New|The|A|An|First|Second|Third|Largest|Massive|Huge|Giant|Hyperscale|AI|Its|Their|Planned|Proposed|Major|Big|Another|Future|Existing|Local|Global|National|Regional|Green|Sustainable|Modular|Mega|Own|Our|This|That|These|Those|Two|Three|Four|Five|Multiple|Several|Sprawling|Controversial|Proposed|Multi-Billion|Billion-Dollar|Large|Small|Wholesale|Colocation|Enterprise|Edge|Cloud|Sovereign|Nuclear|Solar|Gas|Data|Center|Centre|Digital|Tech|Technology|Business|Innovation|Industrial|Park|Site|In|Of|For|At|On|To|And|With|Near|Inc|LLC|Corp|County|City|Township|State|Mr|Ms|Dr|CEO|President|Governor|Mayor|Board|Council|Commission|Supervisors|Planning|Zoning|Company|Group|Holdings|Partners|Capital|Energy|Power|Grid|Utility|Electric)$/i; |
| 193 | 255 | |
| 256 | +const UNIT_WORD = /^(GW|MW|kW|KW|MWh|Acre|Acres|Hectare|Hectares|Sq|Ft|Sqft|Percent|Pct)$/i; | |
| 257 | + | |
| 194 | 258 | export function findExplicitName(title: string | null, text: string, operator: OperatorHit | null): string | null { |
| 195 | 259 | const body = text.slice(0, 6000); |
| 196 | 260 | for (const [scopeName, scope] of [["title", title ?? ""], ["lead", text.slice(0, 1500)]] as Array<[string, string]>) { |
@@ -198,12 +262,15 @@ export function findExplicitName(title: string | null, text: string, operator: O | ||
| 198 | 262 | for (const m of scope.matchAll(NAME_RE)) { |
| 199 | 263 | const name = cleanText(m[1]!); |
| 200 | 264 | if (!name || name.length < 8 || name.length > 80 || /\b\d+\s?(MW|GW|kW)\b/i.test(name)) continue; |
| 265 | + // "1.5 GW Texas Data Center", "8,000 Acre AI Campus": a figure before the match means the first word is a unit | |
| 266 | + if (/[\d.,%]\s*$/.test(scope.slice(0, m.index ?? 0))) continue; | |
| 201 | 267 | // Title-Case headlines ("JV Faces Delays On Data Center") look like names: a real name recurs in the body |
| 202 | 268 | if (scopeName === "title" && !body.includes(name)) continue; |
| 203 | 269 | const words = name.split(/\s+/); |
| 270 | + if (UNIT_WORD.test(words[0]!) || GENERIC_WORD.test(words[0]!)) continue; | |
| 204 | 271 | const typeIdx = words.findIndex((w) => /^(Campus|Data|Technology|Digital|Innovation|Business|AI|Hub|Gateway|Cluster|Supercomputer)$/.test(w)); |
| 205 | 272 | const lead = words.slice(0, typeIdx > 0 ? typeIdx : words.length - 1); |
| 206 | − if (!lead.length || lead.every((w) => GENERIC_WORD.test(w))) continue; | |
| 273 | + if (!lead.length || lead.every((w) => GENERIC_WORD.test(w) || UNIT_WORD.test(w))) continue; | |
| 207 | 274 | // "Vantage Data Centers" / "Microsoft Data Center" are operators, not project names |
| 208 | 275 | const ops = detectOperators(name); |
| 209 | 276 | if (ops.length && (ops[0]!.matched.length >= name.length - 1 || lead.join(" ") === ops[0]!.matched || (operator && lead.join(" ") === operator.matched))) continue; |
@@ -300,15 +367,16 @@ export function extractAnnouncement(rawTitle: string | null, rawText: string, op | ||
| 300 | 367 | const cls = classifyAnnouncement(title, text); |
| 301 | 368 | methods.pageType = cls.rule; |
| 302 | 369 | |
| 303 | − // MW: headline figure = largest in title/lead, else largest in body. Figures above 20 GW are market statistics, not a project. | |
| 304 | − const plausible = (v: number) => v > 0 && v <= 20_000; | |
| 305 | − const mwTitle = parseAllMw(title).filter(plausible); | |
| 306 | − const mwLead = parseAllMw(`${title} ${head}`).filter(plausible); | |
| 307 | − const mwBody = parseAllMw(`${title} ${body}`).filter(plausible); | |
| 370 | + // MW: headline figure = largest project figure in title/lead, else in body. Figures above 20 GW are market statistics; | |
| 371 | + // portfolio totals ("capacity to over 410MW in Japan", "1.2GW across APJ") are skipped when a site figure exists. | |
| 372 | + const mwTitle = projectMwFigures(title); | |
| 373 | + const mwLead = projectMwFigures(`${title} ${head}`); | |
| 374 | + const mwBodySite = projectMwFigures(`${title} ${body}`); | |
| 375 | + const mwBody = parseAllMw(`${title} ${body}`).filter((v) => v > 0 && v <= 20_000); // every figure, for the event's `mw` list | |
| 308 | 376 | let headlineMw: number | null = null; |
| 309 | 377 | if (mwTitle.length) { headlineMw = mwTitle[0]!; methods.plannedMw = "regex:mw_v1:title"; } |
| 310 | 378 | else if (mwLead.length) { headlineMw = mwLead[0]!; methods.plannedMw = "regex:mw_v1:lead"; } |
| 311 | − else if (mwBody.length) { headlineMw = mwBody[0]!; methods.plannedMw = "regex:mw_v1:body"; } | |
| 379 | + else if (mwBodySite.length) { headlineMw = mwBodySite[0]!; methods.plannedMw = "regex:mw_v1:body"; } | |
| 312 | 380 | |
| 313 | 381 | // money: same title → lead → body preference, largest figure in the winning scope |
| 314 | 382 | let money: Money | null = null; |
@@ -345,18 +413,47 @@ export function extractAnnouncement(rawTitle: string | null, rawText: string, op | ||
| 345 | 413 | if (location) { methods.location = location.method; } |
| 346 | 414 | |
| 347 | 415 | const operators = detectOperators(`${title}\n${body}`); |
| 348 | − const operator = primaryOperator(title, body); | |
| 349 | − if (operator) methods.operatorName = "lexicon:operator"; | |
| 416 | + let operator = primaryOperator(title, body); | |
| 417 | + // the headline's subject is a company we do not know → lexicon operators in the body are not the developer | |
| 418 | + if (operator && !detectOperators(title).length && unknownTitleSubject(title)) { operator = null; methods.operatorName = "none:unknown-title-subject"; } | |
| 419 | + else if (operator) methods.operatorName = "lexicon:operator"; | |
| 350 | 420 | |
| 351 | 421 | const explicitName = findExplicitName(title, text, operator); |
| 352 | 422 | const gen = generateProjectName({ title, explicitName, operator, location }); |
| 353 | 423 | methods.name = gen.method; |
| 354 | 424 | |
| 355 | − const pageType = pageTypeFromStatus(cls.pageType, st.status, headlineMw != null || money != null || acreage != null); | |
| 356 | − const eventType = pageType === cls.pageType ? cls.eventType : eventTypeFor(pageType, `${title}\n${body}`); | |
| 425 | + const nonProject = isNonProjectTitle(title); | |
| 426 | + let pageType = pageTypeFromStatus(cls.pageType, st.status, headlineMw != null || money != null || acreage != null); | |
| 427 | + let eventType = pageType === cls.pageType ? cls.eventType : eventTypeFor(pageType, `${title}\n${body}`); | |
| 357 | 428 | if (pageType !== cls.pageType) methods.pageType = `${cls.rule}+status:${st.status}`; |
| 429 | + // people / deals / money / market headlines are plain news whatever the body's vocabulary says | |
| 430 | + if (nonProject && !["acquisition", "closure", "cloud_region", "financial_disclosure"].includes(pageType)) { pageType = "press_release"; eventType = "news"; methods.pageType = `${methods.pageType}+veto:title`; } | |
| 431 | + | |
| 432 | + return { title, pageType, eventType, relevance, leadRelevance: leadRelevanceOf(title, text), titleSignal: titleSignalOf(title), nonProjectTitle: nonProject, mwAll: mwBody, headlineMw, money, investmentUsd, investmentUsdApprox, acreage, phaseCount, expectedOpening: exp.value, status: st.status, location, operators, operator, explicitName, projectName: gen.name, methods }; | |
| 433 | +} | |
| 434 | + | |
| 435 | +/** Portfolio / company-wide context around a MW figure — not the size of the announced site. */ | |
| 436 | +const PORTFOLIO_BEFORE_RE = /\b(total(?:l?ing)?|across|portfolio|combined|footprint|pipeline|capacity to(?: over| more than)?|to over|to more than|brings?(?: its| the| total)?[^.]{0,60}(?:to|capacity)|bringing[^.]{0,60}to|taking[^.]{0,60}to|now (?:has|operates|owns|manages)|operates?(?: over| more than)?|owns?(?: over| more than)?|manages?(?: over| more than)?|(?:more than|over) \d+ (?:data cent(?:er|re)s?|facilities|sites|campuses)[^.]{0,20}|globally|worldwide|company-?wide|nationwide|in (?:asia|apj|japan|europe|emea|the region|australia|the us|the u\.s\.|the country|india|latin america|the americas|the uk|the middle east)|of (?:total )?capacity (?:in|across))\s*(?:of\s*)?$/i; | |
| 437 | +const PORTFOLIO_AFTER_RE = /^\s*(?:MW|GW|megawatts?|gigawatts?)?\s*(?:across|portfolio|globally|worldwide|company-?wide|nationwide|in (?:asia|apj|japan|europe|emea|the region|australia|the us|the country|india|latin america|the americas|the uk|the middle east)|of (?:total )?(?:capacity |power )?(?:across|in operation|under (?:management|development)|planned)|of (?:contracted|committed|operational|total) (?:capacity|power))\b/i; | |
| 438 | +const MW_TOKEN_RE = /(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s?(gigawatts?|gw|megawatts?|mw)\b/gi; | |
| 358 | 439 | |
| 359 | − return { title, pageType, eventType, relevance, leadRelevance: leadRelevanceOf(title, text), titleSignal: titleSignalOf(title), mwAll: mwBody, headlineMw, money, investmentUsd, investmentUsdApprox, acreage, phaseCount, expectedOpening: exp.value, status: st.status, location, operators, operator, explicitName, projectName: gen.name, methods }; | |
| 440 | +/** | |
| 441 | + * MW figures in a scope that describe the announced site (largest first), skipping company-wide totals and market | |
| 442 | + * statistics. When every figure in the scope is a portfolio total, none is returned — the caller moves to the next scope. | |
| 443 | + */ | |
| 444 | +export function projectMwFigures(scope: string): number[] { | |
| 445 | + const site: number[] = []; | |
| 446 | + MW_TOKEN_RE.lastIndex = 0; | |
| 447 | + for (const m of scope.matchAll(MW_TOKEN_RE)) { | |
| 448 | + const start = m.index ?? 0; | |
| 449 | + const value = parseAllMw(m[0])[0]; | |
| 450 | + if (value == null || value <= 0 || value > 20_000) continue; | |
| 451 | + const before = scope.slice(Math.max(0, start - 100), start); | |
| 452 | + const after = scope.slice(start + m[0].length, start + m[0].length + 40); | |
| 453 | + if (PORTFOLIO_BEFORE_RE.test(before) || PORTFOLIO_AFTER_RE.test(after)) continue; | |
| 454 | + site.push(value); | |
| 455 | + } | |
| 456 | + return [...new Set(site)].sort((a, b) => b - a); | |
| 360 | 457 | } |
| 361 | 458 | |
| 362 | 459 | const PROJECT_PAGE_TYPES: readonly PageType[] = ["project_announcement", "expansion", "construction_update", "planning_document", "power_infrastructure", "press_release", "utility_announcement"]; |
@@ -369,6 +466,7 @@ const PROJECT_STATUSES: readonly FacilityStatus[] = [...PIPELINE_STATUSES, "expa | ||
| 369 | 466 | */ |
| 370 | 467 | export function qualifiesAsProject(a: Announcement, opts: { minMw?: number; minInvestmentUsd?: number; minAcres?: number; lenient?: boolean } = {}): boolean { |
| 371 | 468 | if (a.relevance !== "strong") return false; |
| 469 | + if (a.nonProjectTitle) return false; | |
| 372 | 470 | if (!a.status || !PROJECT_STATUSES.includes(a.status)) return false; |
| 373 | 471 | if (!PROJECT_PAGE_TYPES.includes(a.pageType)) return false; |
| 374 | 472 | // An announcement names its subject up front: data-center vocabulary in the lead, and a title that mentions the |
modified
apps/worker/src/connectors/news/locations-lexicon.ts
+4 −1
@@ -100,7 +100,10 @@ export function detectLocation(text: string): LocationHit | null { | ||
| 100 | 100 | if (!text) return null; |
| 101 | 101 | CITY_STATE_RE.lastIndex = 0; |
| 102 | 102 | for (const m of text.matchAll(CITY_STATE_RE)) { |
| 103 | − const city = m[1]!.replace(/\.$/, ""); | |
| 103 | + // "54MW Data Center In Dallas, Texas": drop leading words that cannot start a city name until a proper noun is left | |
| 104 | + const words = m[1]!.replace(/\.$/, "").split(/[ ]/); | |
| 105 | + while (words.length > 1 && (NOT_A_CITY.test(words[0]!) || /^(Near|Outside|Around|Into|Onto|Across|Through|Plans?|Planned|Proposed|Approves?|Approved|Files?|Builds?|Opens?|Campus|Facility|Site|Project|Hub|Park)$/i.test(words[0]!))) words.shift(); | |
| 106 | + const city = words.join(" "); | |
| 104 | 107 | const st = m[2]!; |
| 105 | 108 | const stateInfo = STATE_NAMES.get(st.length <= 2 ? st : st.toLowerCase()); |
| 106 | 109 | if (!stateInfo || NOT_A_CITY.test(city)) continue; |
modified
apps/worker/src/ingest/contract.ts
+8 −0
@@ -34,6 +34,14 @@ export interface IngestStats { | ||
| 34 | 34 | changes: DetectedChange[]; |
| 35 | 35 | /** entity ids touched: [{type,id}] for documents.entity_refs */ |
| 36 | 36 | refs: Array<{ type: string; id: string }>; |
| 37 | + /** data-quality counters (optional, additive): operators inferred from a canonical name prefix */ | |
| 38 | + inferredOperators?: number; | |
| 39 | + /** coordinates dropped because the point lies in another country than the one the source states */ | |
| 40 | + geoCountryMismatch?: number; | |
| 41 | + /** field values kept in provenance but not written to a column (e.g. pre-1980 opening dates from OSM) */ | |
| 42 | + implausibleDropped?: number; | |
| 43 | + /** projects folded into an already-known announcement (same operator, city, MW ±10 %, within 30 days) */ | |
| 44 | + projectDedup?: number; | |
| 37 | 45 | } |
| 38 | 46 | |
| 39 | 47 | export type IngestFn = (run: IngestRun, entities: NormalizedEntity[], doc: IngestDocRef | null) => Promise<IngestStats>; |
modified
apps/worker/src/ingest/facilities.ts
+58 −16
@@ -9,8 +9,12 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | import { sql, textArray } from "@dci/db"; |
| 11 | 11 | import { cleanText, geohash, newId, normalizeName, validLatLng, type ConfidenceLevel, type NormalizedFacility, type Provenance } from "@dci/core"; |
| 12 | −import { addRef, bump, provenanceFor, safeCountry, uniqueSlug, uniqStrings, normalizeWebsite, type IngestContext, type Tx } from "./common.js"; | |
| 12 | +import { addRef, bump, isPrimaryKind, provenanceFor, safeCountry, uniqueSlug, uniqStrings, normalizeWebsite, type IngestContext, type Tx } from "./common.js"; | |
| 13 | 13 | import { isHyperscalerName } from "./canonical-operators.js"; |
| 14 | +import { inferOperatorFromName } from "./operator-inference.js"; | |
| 15 | + | |
| 16 | +/** Country pairs whose city-level points legitimately straddle a border / enclave (stated:point-in). */ | |
| 17 | +export const BORDER_TOLERANT: ReadonlySet<string> = new Set(["HK:CN", "MO:CN", "SG:MY", "MY:SG", "LU:DE", "LU:FR", "LU:BE", "MC:FR", "VA:IT", "SM:IT", "PS:IL", "IL:PS", "XK:RS", "RS:XK", "LI:CH", "LI:AT", "AD:FR", "AD:ES", "GI:ES", "BH:SA", "CH:DE", "DE:CH", "NL:BE", "BE:NL", "NL:DE", "DE:NL", "AT:DE", "DE:AT", "IE:GB", "GB:IE", "US:CA", "CA:US", "US:MX", "MX:US", "DK:SE", "SE:DK", "FR:BE", "BE:FR", "FR:CH", "CH:FR", "FR:DE", "DE:FR", "CZ:DE", "DE:CZ", "PL:DE", "DE:PL", "SK:AT", "AT:SK", "HU:AT", "AT:HU", "PT:ES", "ES:PT", "NO:SE", "SE:NO", "FI:SE", "SE:FI", "AE:OM", "OM:AE", "CN:HK", "CN:MO"]); | |
| 14 | 18 | import { resolveCampus } from "./campuses.js"; |
| 15 | 19 | import { discoverySignificance, emitDiffEvents, recordEvent, TRACKED_FACILITY_FIELDS } from "./events.js"; |
| 16 | 20 | import { bboxAround, validGeo } from "./geo.js"; |
@@ -99,10 +103,25 @@ async function followMerged(tx: Tx, id: string): Promise<string> { | ||
| 99 | 103 | return cur; |
| 100 | 104 | } |
| 101 | 105 | |
| 106 | +/** | |
| 107 | + * External-id namespaces that identify ONE facility. Anything else stored in `externalIds` (an operator's Wikidata | |
| 108 | + * id, a state name, an investment figure, a shared `ref` tag…) is informative but must never link records — a | |
| 109 | + * shared `operator_wikidata` once folded 121 AWS OpenStreetMap features into a single facility. | |
| 110 | + */ | |
| 111 | +export const IDENTIFYING_EXTERNAL_ID_KEYS: ReadonlySet<string> = new Set(["osm", "wikidata", "wikipedia_en", "peeringdb_fac", "peeringdb", "pdb_fac", "dcmap", "geonames", "facebook_page", "meta_info_sheet", "google_detail_page", "cyrusone_slug", "qts_slug", "coresite_code"]); | |
| 112 | +const IDENTIFYING_KEY_RE = /(^|_)(id|slug|code|page|sheet|url|qid|uid)$/; | |
| 113 | + | |
| 114 | +/** true when `key` names one facility (curated namespaces, or `*_id` / `*_slug` / `*_code` / `*_page` keys that are not operator-level). */ | |
| 115 | +export function isIdentifyingExternalId(key: string): boolean { | |
| 116 | + if (IDENTIFYING_EXTERNAL_ID_KEYS.has(key)) return true; | |
| 117 | + if (/^(operator|owner|brand|company|parent|network)_/.test(key)) return false; | |
| 118 | + return IDENTIFYING_KEY_RE.test(key); | |
| 119 | +} | |
| 120 | + | |
| 102 | 121 | async function byExternalIds(tx: Tx, ext: Record<string, string | number> | undefined): Promise<string | null> { |
| 103 | 122 | if (!ext) return null; |
| 104 | 123 | for (const [k, v] of Object.entries(ext)) { |
| 105 | − if (v == null || v === "") continue; | |
| 124 | + if (v == null || v === "" || !isIdentifyingExternalId(k)) continue; | |
| 106 | 125 | const rows = await tx.execute(sql`select id from facilities where merged_into is null and external_ids @> ${JSON.stringify({ [k]: v })}::jsonb order by created_at asc limit 1`); |
| 107 | 126 | if (rows[0]) return String(rows[0].id); |
| 108 | 127 | } |
@@ -123,15 +142,16 @@ async function loadCandidates(tx: Tx, nf: NormalizedFacility, operatorId: string | ||
| 123 | 142 | if (operatorId && countryIso2) conds.push(sql`(f.operator_id = ${operatorId} and f.country_iso2 = ${countryIso2})`); |
| 124 | 143 | if (!conds.length) return []; |
| 125 | 144 | const rows = await tx.execute(sql` |
| 126 | − select f.id, f.name, f.normalized_name, f.operator_id, f.country_iso2, f.city, f.address, f.lat, f.lng, f.geo_precision, f.external_ids, | |
| 145 | + select f.id, f.name, f.normalized_name, f.operator_id, o.name as operator_name, f.country_iso2, f.city, f.address, f.lat, f.lng, f.geo_precision, f.external_ids, | |
| 127 | 146 | (select coalesce(array_agg(alias), '{}'::text[]) from facility_aliases a where a.facility_id = f.id) as aliases |
| 128 | − from facilities f where f.merged_into is null and (${sql.join(conds, sql` or `)}) limit 400`); | |
| 147 | + from facilities f left join operators o on o.id = f.operator_id where f.merged_into is null and (${sql.join(conds, sql` or `)}) limit 400`); | |
| 129 | 148 | return rows.map((r) => ({ |
| 130 | 149 | id: String(r.id), |
| 131 | 150 | name: String(r.name), |
| 132 | 151 | normalizedName: String(r.normalized_name), |
| 133 | 152 | aliases: Array.isArray(r.aliases) ? (r.aliases as string[]) : [], |
| 134 | 153 | operatorId: r.operator_id == null ? null : String(r.operator_id), |
| 154 | + operatorName: r.operator_name == null ? null : String(r.operator_name), | |
| 135 | 155 | countryIso2: r.country_iso2 == null ? null : String(r.country_iso2), |
| 136 | 156 | city: r.city == null ? null : String(r.city), |
| 137 | 157 | address: r.address == null ? null : String(r.address), |
@@ -148,7 +168,7 @@ interface Resolution { | ||
| 148 | 168 | match?: { candidate: FacilityCandidate; match: MatchScore } | null; |
| 149 | 169 | } |
| 150 | 170 | |
| 151 | −async function resolveFacility(tx: Tx, ctx: IngestContext, nf: NormalizedFacility, operatorId: string | null, countryIso2: string | null): Promise<Resolution> { | |
| 171 | +async function resolveFacility(tx: Tx, ctx: IngestContext, nf: NormalizedFacility, operatorId: string | null, operatorName: string | null, countryIso2: string | null): Promise<Resolution> { | |
| 152 | 172 | const byKey = await facilityIdForKey(tx, ctx, nf.key); |
| 153 | 173 | if (byKey) return { id: byKey, how: "key" }; |
| 154 | 174 | const byExt = await byExternalIds(tx, nf.externalIds); |
@@ -157,7 +177,7 @@ async function resolveFacility(tx: Tx, ctx: IngestContext, nf: NormalizedFacilit | ||
| 157 | 177 | if (!candidates.length) return { id: null, how: "create" }; |
| 158 | 178 | const geo = validGeo(nf.geo) ? nf.geo : null; |
| 159 | 179 | const best = bestMatch( |
| 160 | − { name: nf.name, aliases: nf.aliases, operatorId, countryIso2, city: nf.city ?? null, address: nf.address ?? null, lat: geo?.lat ?? null, lng: geo?.lng ?? null, geoPrecision: geo?.precision ?? null }, | |
| 180 | + { name: nf.name, aliases: nf.aliases, operatorId, operatorName, countryIso2, city: nf.city ?? null, address: nf.address ?? null, lat: geo?.lat ?? null, lng: geo?.lng ?? null, geoPrecision: geo?.precision ?? null }, | |
| 161 | 181 | candidates, |
| 162 | 182 | ); |
| 163 | 183 | if (!best) return { id: null, how: "create" }; |
@@ -188,16 +208,31 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF | ||
| 188 | 208 | if (!url) throw new Error(`facility ${nf.key}: provenance url is required`); |
| 189 | 209 | |
| 190 | 210 | // --- related entities ----------------------------------------------------------------------------------------- |
| 191 | − const operator = nf.operatorName ? await resolveOperator(tx, ctx, { name: nf.operatorName, key: nf.operatorKey ?? null }) : null; | |
| 192 | − const owner = nf.ownerName && normalizeName(nf.ownerName) !== normalizeName(nf.operatorName ?? "") ? await resolveOperator(tx, ctx, { name: nf.ownerName }) : null; | |
| 193 | − const geo = validGeo(nf.geo) ? nf.geo : null; | |
| 211 | + // operator: the source's value, else a conservative canonical-brand prefix of the name ("Equinix AM3" → Equinix), | |
| 212 | + // recorded with method "inferred:name-prefix" and moderate confidence so the inference stays visible in provenance | |
| 213 | + const inferred = nf.operatorName ? null : inferOperatorFromName(name); | |
| 214 | + const operatorName = nf.operatorName ?? inferred?.name ?? null; | |
| 215 | + const operatorProv: Provenance = inferred ? { ...pf("operatorName"), method: inferred.method, confidence: "moderate" } : pf("operatorName"); | |
| 216 | + if (inferred) ctx.stats.inferredOperators = (ctx.stats.inferredOperators ?? 0) + 1; | |
| 217 | + const operator = operatorName ? await resolveOperator(tx, ctx, { name: operatorName, key: nf.operatorKey ?? null }) : null; | |
| 218 | + const owner = nf.ownerName && normalizeName(nf.ownerName) !== normalizeName(operatorName ?? "") ? await resolveOperator(tx, ctx, { name: nf.ownerName }) : null; | |
| 219 | + let geo = validGeo(nf.geo) ? nf.geo : null; | |
| 194 | 220 | let country = await safeCountry(tx, ctx, nf.countryIso2); |
| 195 | 221 | if (!country && geo) country = await safeCountry(tx, ctx, countryFromPoint(geo.lat, geo.lng)); // offline Natural Earth polygons (OSM features rarely carry addr:country) |
| 222 | + // coordinates that fall in another country than the one the source states are wrong on one side — keep the | |
| 223 | + // stated country, drop the point (a swapped lat/lng or a geocoder miss must never place a facility abroad) | |
| 224 | + if (geo && nf.countryIso2 && geo.precision !== "metro") { | |
| 225 | + const pointCountry = countryFromPoint(geo.lat, geo.lng); | |
| 226 | + if (pointCountry && pointCountry !== nf.countryIso2.toUpperCase() && !BORDER_TOLERANT.has(`${nf.countryIso2.toUpperCase()}:${pointCountry}`)) { | |
| 227 | + ctx.stats.geoCountryMismatch = (ctx.stats.geoCountryMismatch ?? 0) + 1; | |
| 228 | + geo = null; | |
| 229 | + } | |
| 230 | + } | |
| 196 | 231 | const metro = await assignMetro(tx, { lat: geo?.lat, lng: geo?.lng, city: nf.city, countryIso2: country }); |
| 197 | 232 | country = country ?? metro.countryIso2; |
| 198 | 233 | |
| 199 | 234 | // --- resolution ------------------------------------------------------------------------------------------------- |
| 200 | − const res = await resolveFacility(tx, ctx, nf, operator?.id ?? null, country); | |
| 235 | + const res = await resolveFacility(tx, ctx, nf, operator?.id ?? null, operator?.name ?? operatorName, country); | |
| 201 | 236 | const existing = res.id ? await loadFacility(tx, res.id) : null; |
| 202 | 237 | const prov = existing ? await loadCurrentProvenance(tx, "facility", String(existing.id)) : []; |
| 203 | 238 | const isNew = !existing; |
@@ -234,6 +269,12 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF | ||
| 234 | 269 | if (v == null) continue; |
| 235 | 270 | const p = pf(field); |
| 236 | 271 | observed.push({ field, value: v, provenance: p }); |
| 272 | + // an opening year before 1980 from a crowd-sourced / dataset source is the building's construction date | |
| 273 | + // (OSM `start_date`), not the data center's: keep the observation in provenance, never in the column | |
| 274 | + if (field === "openedOn" && !isPrimaryKind(ctx.run.sourceKind) && ctx.run.connectorId !== "wikidata" && Number(String(v).slice(0, 4)) < 1980) { | |
| 275 | + ctx.stats.implausibleDropped = (ctx.stats.implausibleDropped ?? 0) + 1; | |
| 276 | + continue; | |
| 277 | + } | |
| 237 | 278 | const dec = shouldReplace(obs(v, p, ctx), storedObs(prov, field, existing?.[field])); |
| 238 | 279 | if (dec.replace) next[field] = v; |
| 239 | 280 | } |
@@ -245,11 +286,12 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF | ||
| 245 | 286 | const dec = shouldReplaceMw(obs(v, p, ctx), storedObs(prov, field, existing?.[field])); |
| 246 | 287 | if (dec.replace) next[field] = v; |
| 247 | 288 | } |
| 248 | − // operator / owner: authority policy like other fields (a news source never re-assigns an operator set by the operator itself) | |
| 289 | + // operator / owner: authority policy like other fields (a news source never re-assigns an operator set by the operator itself); | |
| 290 | + // an operator inferred from the name never replaces one stated by a source | |
| 249 | 291 | if (operator) { |
| 250 | − observed.push({ field: "operatorId", value: operator.id, provenance: pf("operatorName") }); | |
| 251 | − observed.push({ field: "operatorName", value: operator.name, provenance: pf("operatorName") }); | |
| 252 | − if (shouldReplace(obs(operator.id, pf("operatorName"), ctx), storedObs(prov, "operatorId", existing?.operatorId)).replace) next.operatorId = operator.id; | |
| 292 | + observed.push({ field: "operatorId", value: operator.id, provenance: operatorProv }); | |
| 293 | + observed.push({ field: "operatorName", value: operator.name, provenance: operatorProv }); | |
| 294 | + if (inferred ? !existing?.operatorId : shouldReplace(obs(operator.id, operatorProv, ctx), storedObs(prov, "operatorId", existing?.operatorId)).replace) next.operatorId = operator.id; | |
| 253 | 295 | } |
| 254 | 296 | if (owner) { |
| 255 | 297 | observed.push({ field: "ownerId", value: owner.id, provenance: pf("ownerName") }); |
@@ -285,7 +327,7 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF | ||
| 285 | 327 | next.isAi = true; |
| 286 | 328 | observed.push({ field: "isAi", value: true, provenance: pf("isAi") }); |
| 287 | 329 | } |
| 288 | − if (nf.isHyperscale === true || isHyperscalerName(nf.operatorName)) { | |
| 330 | + if (nf.isHyperscale === true || isHyperscalerName(operatorName)) { | |
| 289 | 331 | next.isHyperscale = true; |
| 290 | 332 | if (nf.isHyperscale === true) observed.push({ field: "isHyperscale", value: true, provenance: pf("isHyperscale") }); |
| 291 | 333 | } |
@@ -442,7 +484,7 @@ export async function refreshDerived(tx: Tx, id: string, forceConfidence: Confid | ||
| 442 | 484 | const onlyEstimates = prov.length > 0 && prov.every((r) => r.isEstimate); |
| 443 | 485 | // a facility created from an ambiguous match stays "unverified" until the admin decides |
| 444 | 486 | const pending = forceConfidence ? [] : await tx.execute(sql`select 1 from entity_matches where status = 'pending' and candidate->>'createdFacilityId' = ${id} limit 1`); |
| 445 | − const confidence = forceConfidence ?? (pending.length ? "unverified" : facilityConfidence({ sourceKinds: kinds, lastVerifiedIso: lastPrimaryObserved, onlyEstimates })); | |
| 487 | + const confidence = forceConfidence ?? (pending.length ? "unverified" : facilityConfidence({ sourceKinds: kinds, sourceCount: sourceIds.length, lastVerifiedIso: lastPrimaryObserved, onlyEstimates })); | |
| 446 | 488 | const counts = (await tx.execute(sql`select carriers_count, ixp_count from facilities where id = ${id}`))[0]; |
| 447 | 489 | const completeness = completenessScore({ |
| 448 | 490 | geoPrecision: row.geoPrecision as string, |
modified
apps/worker/src/ingest/match.test.ts
+91 −2
@@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | +import { compareFacilityCodes, extractFacilityCodes } from "@dci/core"; | |
| 2 | 3 | import { AUTO_MERGE_THRESHOLD, PENDING_THRESHOLD, bestMatch, completenessScore, decide, facilityConfidence, scoreFacilityMatch, shouldReplace, shouldReplaceGeo, shouldReplaceMw, type FacilityCandidate } from "./match.js"; |
| 3 | 4 | |
| 4 | 5 | const equinix = "op_equinix"; |
@@ -115,11 +116,99 @@ describe("completeness and confidence", () => { | ||
| 115 | 116 | expect(exact).toBeGreaterThan(city); |
| 116 | 117 | expect(exact).toBeGreaterThan(est); |
| 117 | 118 | }); |
| 118 | − it("confidence: single operator source is high, two primary kinds verified, news-only moderate, nothing unverified", () => { | |
| 119 | + it("confidence: single operator source is high, two authoritative kinds verified, single community/news source unverified", () => { | |
| 119 | 120 | expect(facilityConfidence({ sourceKinds: ["operator"] })).toBe("high"); |
| 121 | + expect(facilityConfidence({ sourceKinds: ["government"] })).toBe("high"); | |
| 120 | 122 | expect(facilityConfidence({ sourceKinds: ["operator", "government"] })).toBe("verified"); |
| 121 | − expect(facilityConfidence({ sourceKinds: ["news"] })).toBe("moderate"); | |
| 123 | + expect(facilityConfidence({ sourceKinds: ["news"], sourceCount: 1 })).toBe("unverified"); | |
| 124 | + expect(facilityConfidence({ sourceKinds: ["community"], sourceCount: 1 })).toBe("unverified"); | |
| 125 | + expect(facilityConfidence({ sourceKinds: ["community", "dataset"], sourceCount: 2 })).toBe("moderate"); | |
| 126 | + // a registry or dataset alone never reaches "high" | |
| 127 | + expect(facilityConfidence({ sourceKinds: ["registry"], sourceCount: 1 })).toBe("moderate"); | |
| 128 | + expect(facilityConfidence({ sourceKinds: ["dataset"], sourceCount: 1 })).toBe("moderate"); | |
| 122 | 129 | expect(facilityConfidence({ sourceKinds: [] })).toBe("unverified"); |
| 123 | 130 | expect(facilityConfidence({ sourceKinds: ["operator"], onlyEstimates: true })).toBe("estimated"); |
| 124 | 131 | }); |
| 125 | 132 | }); |
| 133 | + | |
| 134 | +describe("reconciliation noise (entity_matches queue, 2026-09-11)", () => { | |
| 135 | + const virtus = "op_virtus", gswitch = "op_globalswitch", telehouse = "op_telehouse", colt = "op_colt", google = "op_google", nextdc = "op_nextdc", pulsant = "op_pulsant"; | |
| 136 | + const cand = (name: string, op: string | null, opName: string | null, lat: number, lng: number, extra: Partial<FacilityCandidate> = {}): FacilityCandidate => ({ id: `fac_${name.replace(/\W+/g, "").toLowerCase()}`, name, normalizedName: name.toLowerCase(), operatorId: op, operatorName: opName, countryIso2: "GB", lat, lng, geoPrecision: "exact", ...extra }); | |
| 137 | + | |
| 138 | + it("LONDON6 vs LONDON5: differing facility codes are a hard negative (< pending)", () => { | |
| 139 | + const m = scoreFacilityMatch({ name: "Virtus LONDON6", operatorId: virtus, operatorName: "Virtus", countryIso2: "GB", lat: 51.5105687, lng: -0.4538102, geoPrecision: "exact" }, cand("Virtus LONDON5", virtus, "Virtus", 51.5095441, -0.4542871)); | |
| 140 | + expect(m.reasons).toContain("codes:conflict"); | |
| 141 | + expect(m.score).toBeLessThan(PENDING_THRESHOLD); | |
| 142 | + }); | |
| 143 | + it("Colt DCS Tokyo Inzai 4 vs Inzai 3 / Sify Chennai 01 vs 02 / NEXTDC S3 vs S1: numbered siblings create", () => { | |
| 144 | + expect(decide(scoreFacilityMatch({ name: "Colt DCS Tokyo Inzai 4 Data Centre", operatorId: colt, operatorName: "Colt Data Centre Services", countryIso2: "JP" }, { ...cand("Colt DCS Tokyo Inzai 3 Data Centre", colt, "Colt Data Centre Services", 35.8, 140.1), countryIso2: "JP", lat: null, lng: null }).score)).toBe("create"); | |
| 145 | + expect(decide(scoreFacilityMatch({ name: "Sify Chennai 02", operatorId: "op_sify", operatorName: "Sify Technologies", countryIso2: "IN", city: "Chennai" }, { ...cand("Sify Chennai 01", "op_sify", "Sify Technologies", 13, 80), countryIso2: "IN", city: "Chennai", lat: null, lng: null }).score)).toBe("create"); | |
| 146 | + expect(decide(scoreFacilityMatch({ name: "NEXTDC S3 Sydney", operatorId: nextdc, operatorName: "NEXTDC", countryIso2: "AU", city: "Sydney" }, { ...cand("NEXTDC S1 Sydney", nextdc, "NEXTDC", -33.8, 151.2), countryIso2: "AU", city: "Sydney", lat: null, lng: null }).score)).toBe("create"); | |
| 147 | + }); | |
| 148 | + it("Global Switch London East vs London North, Telehouse West vs East, Telehouse North Two vs North: directional / ordinal siblings create", () => { | |
| 149 | + const north = cand("Global Switch London North", gswitch, "Global Switch", 51.511119, -0.005573); | |
| 150 | + const east = scoreFacilityMatch({ name: "Global Switch London East", operatorId: gswitch, operatorName: "Global Switch", countryIso2: "GB", lat: 51.5109396, lng: -0.003425, geoPrecision: "exact" }, north); | |
| 151 | + expect(east.reasons).toContain("rule:siblings"); | |
| 152 | + expect(east.score).toBeLessThan(PENDING_THRESHOLD); | |
| 153 | + const tEast = cand("Telehouse East", telehouse, "Telehouse Europe", 51.5115297, -0.001266); | |
| 154 | + expect(scoreFacilityMatch({ name: "Telehouse West", operatorId: telehouse, operatorName: "Telehouse Europe", countryIso2: "GB", lat: 51.5111949, lng: -0.0023135, geoPrecision: "exact" }, tEast).score).toBeLessThan(PENDING_THRESHOLD); | |
| 155 | + const tNorth = cand("Telehouse North", telehouse, "Telehouse Europe", 51.512052, -0.0019544); | |
| 156 | + expect(scoreFacilityMatch({ name: "Telehouse North Two", operatorId: telehouse, operatorName: "Telehouse Europe", countryIso2: "GB", lat: 51.5121049, lng: -0.0005474, geoPrecision: "exact" }, tNorth).score).toBeLessThan(PENDING_THRESHOLD); | |
| 157 | + // "Campus II" and "Campus 2" are the same building, not siblings | |
| 158 | + expect(scoreFacilityMatch({ name: "CyrusOne Frankfurt II", operatorId: "op_c1", operatorName: "CyrusOne", countryIso2: "DE", lat: 50.1, lng: 8.6, geoPrecision: "exact" }, { ...cand("CyrusOne Frankfurt 2", "op_c1", "CyrusOne", 50.1, 8.6), countryIso2: "DE" }).reasons).toContain("name:exact"); | |
| 159 | + }); | |
| 160 | + it("Pulsant Reading SE-3 vs 'Pulsant', 'BCX NDC2' vs 'BCX', 'Switch' vs 'Switch Las Vegas 8': brand-only names never carry a match", () => { | |
| 161 | + const m = scoreFacilityMatch({ name: "Pulsant Reading SE-3", operatorId: pulsant, operatorName: "Pulsant", countryIso2: "GB" }, { ...cand("Pulsant", pulsant, "Pulsant", 51.4, -0.9), lat: null, lng: null }); | |
| 162 | + expect(m.reasons).toContain("name:operator-only"); | |
| 163 | + expect(decide(m.score)).toBe("create"); | |
| 164 | + expect(decide(scoreFacilityMatch({ name: "BCX NDC2", operatorId: "op_bcx", operatorName: "BCX", countryIso2: "ZA", lat: -26.1, lng: 28.1, geoPrecision: "exact" }, { ...cand("BCX", "op_bcx", "BCX", -26.1001, 28.1001), countryIso2: "ZA" }).score)).toBe("create"); | |
| 165 | + expect(decide(scoreFacilityMatch({ name: "Switch", operatorId: "op_switch", operatorName: "Switch", countryIso2: "US", lat: 36.07, lng: -115.1, geoPrecision: "exact" }, { ...cand("Switch Las Vegas 8", "op_switch", "Switch", 36.075, -115.105), countryIso2: "US" }).score)).not.toBe("merge"); | |
| 166 | + }); | |
| 167 | + it("names in a non-Latin script are compared verbatim, never 'equal because both normalize to nothing'", () => { | |
| 168 | + const m = scoreFacilityMatch({ name: "ساختمان مهندسین مهرزاد", countryIso2: "IR", lat: 35.7, lng: 51.4, geoPrecision: "exact" }, { ...cand("شرکت فناوری رایان افزار پارسان", null, null, 35.7, 51.4), countryIso2: "IR" }); | |
| 169 | + expect(m.reasons).toContain("name:0.00"); | |
| 170 | + expect(decide(m.score)).toBe("create"); | |
| 171 | + expect(scoreFacilityMatch({ name: "سرورفارم", countryIso2: "IR", lat: 35.7, lng: 51.4, geoPrecision: "exact" }, { ...cand("سرورفارم", null, null, 35.7, 51.4), countryIso2: "IR" }).reasons).toContain("name:exact"); | |
| 172 | + }); | |
| 173 | + it("Wikidata 'Google Data Center Fredericia' vs Google page 'Fredericia, Denmark' 6 km apart at city level → merge", () => { | |
| 174 | + const page = cand("Google Fredericia, Denmark data center", google, "Google", 55.57, 9.75, { countryIso2: "DK", city: "Fredericia", geoPrecision: "city" }); | |
| 175 | + const withOperator = scoreFacilityMatch({ name: "Google Data Center Fredericia", operatorId: google, operatorName: "Google", countryIso2: "DK", lat: 55.5579737, lng: 9.6531741, geoPrecision: "exact" }, page); | |
| 176 | + expect(withOperator.reasons).toContain("rule:operator+site"); | |
| 177 | + expect(withOperator.score).toBeGreaterThanOrEqual(AUTO_MERGE_THRESHOLD); | |
| 178 | + // without an operator on the Wikidata side the name still implies Google → at least pending, never create | |
| 179 | + const implied = scoreFacilityMatch({ name: "Google Data Center Fredericia", countryIso2: "DK", lat: 55.5579737, lng: 9.6531741, geoPrecision: "exact" }, page); | |
| 180 | + expect(implied.reasons).toContain("operator:name-implied"); | |
| 181 | + expect(implied.score).toBeGreaterThanOrEqual(PENDING_THRESHOLD); | |
| 182 | + // but "Google" alone next to Eemshaven is not a specific building | |
| 183 | + expect(decide(scoreFacilityMatch({ name: "Google", operatorId: google, operatorName: "Google", countryIso2: "NL", lat: 53.44, lng: 6.83, geoPrecision: "exact" }, cand("Google Eemshaven, Netherlands data center", google, "Google", 53.45, 6.86, { countryIso2: "NL", geoPrecision: "city" })).score)).not.toBe("merge"); | |
| 184 | + }); | |
| 185 | + it("'Meta Odense Data Center' twice, same city, operator unknown on one side → merge; 'Adeo Datacenter ApS' at 0 m → merge", () => { | |
| 186 | + const m = scoreFacilityMatch({ name: "Meta Odense Data Center", countryIso2: "DK", city: "Odense Municipality", lat: 55.36668, lng: 10.48479, geoPrecision: "street" }, cand("Meta Odense Data Center", "op_meta", "Meta", 55.4, 10.39, { countryIso2: "DK", city: "Odense", geoPrecision: "city" })); | |
| 187 | + expect(m.reasons).toContain("rule:exact-name+site"); | |
| 188 | + expect(decide(m.score)).toBe("merge"); | |
| 189 | + expect(decide(scoreFacilityMatch({ name: "Adeo Datacenter ApS", countryIso2: "DK", lat: 55.7, lng: 12.5, geoPrecision: "exact" }, cand("Adeo Datacenter ApS", null, null, 55.7, 12.5, { countryIso2: "DK" })).score)).toBe("merge"); | |
| 190 | + // exact name from two sources at 80 m with an address that only differs in formatting → merge | |
| 191 | + expect(decide(scoreFacilityMatch({ name: "Equinix ZRH5", operatorId: "op_eq", operatorName: "Equinix", countryIso2: "CH", address: "Sägereistrasse 35, 8152 Glattbrugg", lat: 47.43, lng: 8.56, geoPrecision: "exact" }, cand("Equinix ZRH5", "op_eq", "Equinix", 47.4307, 8.5605, { countryIso2: "CH", address: "Sägereistrasse 35, Opfikon" })).score)).toBe("merge"); | |
| 192 | + // exact but brand-only OSM names 230 m apart with different house numbers stay separate | |
| 193 | + expect(decide(scoreFacilityMatch({ name: "Digital Realty Data Center", countryIso2: "US", address: "100 Main St", lat: 41.88, lng: -87.63, geoPrecision: "exact" }, cand("Digital Realty Data Center", null, null, 41.882, -87.631, { countryIso2: "US", address: "350 Cermak Rd" })).score)).not.toBe("merge"); | |
| 194 | + }); | |
| 195 | + it("Meta Henrico vs Meta Cheyenne: a brand-only alias ('meta') never links two sites", () => { | |
| 196 | + const m = scoreFacilityMatch({ name: "Meta Henrico Data Center", aliases: ["Meta Data Center"], countryIso2: "US", lat: 37.55, lng: -77.4, geoPrecision: "approximate" }, { ...cand("Meta Cheyenne Data Center", "op_meta", "Meta", 37.55, -77.4, { countryIso2: "US", geoPrecision: "approximate" }), aliases: ["Meta Data Center", "Meta Beaver Dam Data Center"] }); | |
| 197 | + expect(m.reasons).not.toContain("name:alias"); | |
| 198 | + expect(decide(m.score)).not.toBe("merge"); | |
| 199 | + }); | |
| 200 | + it("extractFacilityCodes covers LONDON6 / LON6 / DC-12 / FRA01A / IAD12 / SYD01 / SC-2 / S3 and ignores units and tiers", () => { | |
| 201 | + expect(extractFacilityCodes("Virtus LONDON6")).toEqual(["LONDON6"]); | |
| 202 | + expect(extractFacilityCodes("Equinix DC-12")).toEqual(["DC12"]); | |
| 203 | + expect(extractFacilityCodes("FRA01A")).toEqual(["FRA1A"]); | |
| 204 | + expect(extractFacilityCodes("AirTrunk SYD01")).toEqual(["SYD1"]); | |
| 205 | + expect(extractFacilityCodes("Pulsant Edinburgh SC-2")).toEqual(["SC2"]); | |
| 206 | + expect(extractFacilityCodes("NEXTDC S3 Sydney")).toEqual(["S3"]); | |
| 207 | + expect(extractFacilityCodes("Colt DCS Tokyo Inzai 3 Data Centre")).toEqual(["INZAI3"]); | |
| 208 | + expect(extractFacilityCodes("Tier 3 facility with 50 MW on Route 7")).toEqual([]); | |
| 209 | + expect(compareFacilityCodes(["LONDON6"], ["LONDON5"])).toBe(0); | |
| 210 | + expect(compareFacilityCodes(["SYD1"], ["MEL1"])).toBeNull(); | |
| 211 | + expect(compareFacilityCodes(["SY5B"], ["SY5"])).toBe(1); | |
| 212 | + expect(compareFacilityCodes(["DATA4", "MAD1"], ["DATA4", "MAD2"])).toBe(0); | |
| 213 | + }); | |
| 214 | +}); | |
modified
apps/worker/src/ingest/match.ts
+223 −44
@@ -4,7 +4,9 @@ | ||
| 4 | 4 | * Algorithm + thresholds are documented in docs/RECONCILIATION.md. |
| 5 | 5 | */ |
| 6 | 6 | import { |
| 7 | + compareFacilityCodes, | |
| 7 | 8 | computeConfidence, |
| 9 | + COUNTRY_ALIASES, | |
| 8 | 10 | extractFacilityCodes, |
| 9 | 11 | haversineKm, |
| 10 | 12 | jaccard, |
@@ -13,13 +15,13 @@ import { | ||
| 13 | 15 | normalizeNameKeepCodes, |
| 14 | 16 | PIPELINE_STATUSES, |
| 15 | 17 | PRECISION_RANK, |
| 16 | − tokenSet, | |
| 17 | 18 | validLatLng, |
| 18 | 19 | type ConfidenceLevel, |
| 19 | 20 | type GeoPrecision, |
| 20 | 21 | type SourceKind, |
| 21 | 22 | } from "@dci/core"; |
| 22 | 23 | import { authority, isPrimaryKind } from "./common.js"; |
| 24 | +import { findCanonicalOperator } from "./canonical-operators.js"; | |
| 23 | 25 | |
| 24 | 26 | export const AUTO_MERGE_THRESHOLD = 0.92; |
| 25 | 27 | export const PENDING_THRESHOLD = 0.6; |
@@ -31,6 +33,8 @@ export interface FacilityCandidate { | ||
| 31 | 33 | normalizedName: string; |
| 32 | 34 | aliases?: string[]; |
| 33 | 35 | operatorId?: string | null; |
| 36 | + /** canonical operator name when known — lets the name signal ignore operator tokens ("Colt DCS", "Sabey") */ | |
| 37 | + operatorName?: string | null; | |
| 34 | 38 | countryIso2?: string | null; |
| 35 | 39 | city?: string | null; |
| 36 | 40 | address?: string | null; |
@@ -45,6 +49,7 @@ export interface FacilityProbe { | ||
| 45 | 49 | name: string; |
| 46 | 50 | aliases?: string[]; |
| 47 | 51 | operatorId?: string | null; |
| 52 | + operatorName?: string | null; | |
| 48 | 53 | countryIso2?: string | null; |
| 49 | 54 | city?: string | null; |
| 50 | 55 | address?: string | null; |
@@ -99,38 +104,159 @@ function addressSimilarity(a: string | null | undefined, b: string | null | unde | ||
| 99 | 104 | const ta = new Set(na.split(" ")); |
| 100 | 105 | const tb = new Set(nb.split(" ")); |
| 101 | 106 | const j = jaccard(ta, tb); |
| 102 | − // house number agreement is a strong hint | |
| 103 | − const numA = na.match(/^\d+[a-z]?/)?.[0]; | |
| 104 | − const numB = nb.match(/^\d+[a-z]?/)?.[0]; | |
| 105 | − if (numA && numB && numA === numB) return Math.max(j, 0.75); | |
| 106 | − if (numA && numB && numA !== numB) return Math.min(j, 0.4); | |
| 107 | + // house number agreement is a strong hint — "21721 Filigree Ct" (US) or "Sägereistrasse 35" (Europe); a leading | |
| 108 | + // token is always a candidate, other numeric tokens only when short enough not to be a postal code | |
| 109 | + const houseNumbers = (s: string): Set<string> => { | |
| 110 | + const toks = s.split(" "); | |
| 111 | + const out = new Set<string>(); | |
| 112 | + for (const [i, t] of toks.entries()) if (/^\d+[a-z]?$/.test(t) && (i === 0 || t.replace(/[a-z]$/, "").length <= 4)) out.add(t); | |
| 113 | + return out; | |
| 114 | + }; | |
| 115 | + const ha = houseNumbers(na), hb = houseNumbers(nb); | |
| 116 | + if (ha.size && hb.size) { | |
| 117 | + const shared = [...ha].some((n) => hb.has(n)); | |
| 118 | + return shared ? Math.max(j, 0.75) : Math.min(j, 0.4); | |
| 119 | + } | |
| 107 | 120 | return j; |
| 108 | 121 | } |
| 109 | 122 | |
| 110 | −function nameSimilarity(probe: FacilityProbe, cand: FacilityCandidate): { score: number; exact: boolean; alias: boolean } { | |
| 111 | − const pn = normalizeName(probe.name); | |
| 112 | − const cn = cand.normalizedName || normalizeName(cand.name); | |
| 113 | − if (pn && pn === cn) return { score: 1, exact: true, alias: false }; | |
| 114 | − const pk = normalizeNameKeepCodes(probe.name); | |
| 115 | − const ck = normalizeNameKeepCodes(cand.name); | |
| 116 | − if (pk && pk === ck) return { score: 1, exact: true, alias: false }; | |
| 117 | − const probeAliases = [probe.name, ...(probe.aliases ?? [])].map(normalizeName).filter(Boolean); | |
| 118 | − const candAliases = [cand.name, ...(cand.aliases ?? [])].map(normalizeName).filter(Boolean); | |
| 119 | − const alias = probeAliases.some((a) => candAliases.includes(a)); | |
| 120 | − if (alias) return { score: 1, exact: false, alias: true }; | |
| 121 | − const j = jaccard(tokenSet(probe.name), tokenSet(cand.name)); | |
| 122 | − // containment ("Equinix DC12" inside "Equinix DC12 Building B") counts more than plain jaccard | |
| 123 | − const pa = tokenSet(probe.name); | |
| 124 | − const ca = tokenSet(cand.name); | |
| 125 | − const inter = [...pa].filter((t) => ca.has(t)).length; | |
| 126 | − const containment = pa.size && ca.size ? inter / Math.min(pa.size, ca.size) : 0; | |
| 127 | − return { score: Math.max(j, containment * 0.85), exact: false, alias: false }; | |
| 123 | +// --------------------------------------------------------------------------------------------------------------- | |
| 124 | +// Name comparison | |
| 125 | + | |
| 126 | +/** Ordinals / roman numerals are compared as digits: "Campus II" = "Campus 2" = "Campus Two". */ | |
| 127 | +const ORDINAL_TOKENS: Record<string, string> = { one: "1", i: "1", two: "2", ii: "2", three: "3", iii: "3", four: "4", iv: "4", five: "5", v: "5", six: "6", vi: "6", seven: "7", vii: "7", eight: "8", viii: "8", nine: "9", ix: "9", ten: "10", x: "10" }; | |
| 128 | +/** Tokens that distinguish sibling buildings of one site: directions, ordinals, single letters, small numbers. */ | |
| 129 | +const SIBLING_WORDS = new Set(["north", "south", "east", "west", "northern", "southern", "eastern", "western", "a", "b", "c", "d", "e", "f"]); | |
| 130 | +const isSiblingToken = (t: string): boolean => SIBLING_WORDS.has(t) || /^\d{1,3}$/.test(t); | |
| 131 | + | |
| 132 | +/** Canonical token set of a facility name (noise words removed, ordinals as digits). */ | |
| 133 | +export function nameTokens(name: string | null | undefined): Set<string> { | |
| 134 | + if (!name) return new Set(); | |
| 135 | + return new Set(normalizeName(name).split(" ").filter(Boolean).map((t) => ORDINAL_TOKENS[t] ?? t)); | |
| 136 | +} | |
| 137 | + | |
| 138 | +const setsEqual = (a: Set<string>, b: Set<string>): boolean => a.size === b.size && [...a].every((t) => b.has(t)); | |
| 139 | +const without = (a: Set<string>, drop: Set<string>): Set<string> => new Set([...a].filter((t) => !drop.has(t))); | |
| 140 | + | |
| 141 | +const countryTokenCache = new Map<string, string[][]>(); | |
| 142 | +/** Token sequences naming a country ("united kingdom", "uk", "denmark") — stripped from names before comparison. */ | |
| 143 | +function countryAliasTokens(iso2: string | null | undefined): string[][] { | |
| 144 | + if (!iso2) return []; | |
| 145 | + const hit = countryTokenCache.get(iso2); | |
| 146 | + if (hit) return hit; | |
| 147 | + const out: string[][] = []; | |
| 148 | + for (const [alias, code] of Object.entries(COUNTRY_ALIASES)) if (code === iso2) { const t = normalizeName(alias).split(" ").filter(Boolean); if (t.length) out.push(t); } | |
| 149 | + countryTokenCache.set(iso2, out); | |
| 150 | + return out; | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** Token sequences naming an operator (canonical name + curated aliases + the stored name). */ | |
| 154 | +function operatorAliasTokens(names: Array<string | null | undefined>): string[][] { | |
| 155 | + const out: string[][] = []; | |
| 156 | + for (const n of names) { | |
| 157 | + if (!n) continue; | |
| 158 | + const canon = findCanonicalOperator(n); | |
| 159 | + for (const a of [n, ...(canon ? [canon.name, ...canon.aliases] : [])]) { const t = normalizeName(a).split(" ").filter(Boolean); if (t.length) out.push(t); } | |
| 160 | + } | |
| 161 | + return out; | |
| 162 | +} | |
| 163 | + | |
| 164 | +/** Remove every alias whose tokens are all present ("united kingdom" is removed only when both tokens are there). */ | |
| 165 | +function stripSequences(tokens: Set<string>, sequences: string[][]): Set<string> { | |
| 166 | + let out = tokens; | |
| 167 | + for (const seq of sequences) if (seq.every((t) => out.has(t))) out = without(out, new Set(seq)); | |
| 168 | + return out; | |
| 169 | +} | |
| 170 | + | |
| 171 | +export interface NameSimilarity { | |
| 172 | + score: number; | |
| 173 | + /** identical names (canonical token sets, or code-preserving normalization) */ | |
| 174 | + exact: boolean; | |
| 175 | + alias: boolean; | |
| 176 | + /** the names only differ by sibling tokens: "Telehouse North" / "Telehouse East", "Inzai 3" / "Inzai 4", "Campus I" / "Campus II" */ | |
| 177 | + siblings: boolean; | |
| 178 | + /** identical once operator and country tokens are removed: "Google Fredericia" / "Fredericia, Denmark" (operator Google) */ | |
| 179 | + residualEqual: boolean; | |
| 180 | + /** at least one name is nothing but the operator's name ("Pulsant", "BCX", "Digital Realty Data Center") */ | |
| 181 | + operatorOnly: boolean; | |
| 182 | + /** number of tokens in the shorter full name */ | |
| 183 | + minTokens: number; | |
| 184 | +} | |
| 185 | + | |
| 186 | +export function nameSimilarity(probe: FacilityProbe, cand: FacilityCandidate): NameSimilarity { | |
| 187 | + const pt = nameTokens(probe.name); | |
| 188 | + const ct = nameTokens(cand.name); | |
| 189 | + const minTokens = Math.min(pt.size, ct.size); | |
| 190 | + const none: NameSimilarity = { score: 0, exact: false, alias: false, siblings: false, residualEqual: false, operatorOnly: false, minTokens }; | |
| 191 | + | |
| 192 | + // names that normalize to nothing (non-Latin scripts) are compared verbatim — never "equal because both empty" | |
| 193 | + if (!pt.size || !ct.size) { | |
| 194 | + const rp = probe.name.trim().toLowerCase(), rc = cand.name.trim().toLowerCase(); | |
| 195 | + return rp && rp === rc ? { ...none, exact: true, score: 1 } : none; | |
| 196 | + } | |
| 197 | + | |
| 198 | + const opSeqs = operatorAliasTokens([probe.operatorName, cand.operatorName]); | |
| 199 | + const ctySeqs = [...countryAliasTokens(probe.countryIso2), ...countryAliasTokens(cand.countryIso2)]; | |
| 200 | + const pr = stripSequences(stripSequences(pt, opSeqs), ctySeqs); | |
| 201 | + const cr = stripSequences(stripSequences(ct, opSeqs), ctySeqs); | |
| 202 | + // a name that is just a brand ("Digital Realty Data Center") says nothing about which building it is | |
| 203 | + const brandOnly = (tokens: Set<string>, raw: string) => !tokens.size || !!findCanonicalOperator(raw) || (opSeqs.length === 0 && !!findCanonicalOperator([...tokens].join(" "))); | |
| 204 | + const operatorOnly = brandOnly(pr, probe.name) || brandOnly(cr, cand.name); | |
| 205 | + | |
| 206 | + // siblings: identical apart from direction / ordinal / letter tokens, with a shared non-sibling stem | |
| 207 | + const diff = new Set([...[...pt].filter((t) => !ct.has(t)), ...[...ct].filter((t) => !pt.has(t))]); | |
| 208 | + const stem = [...pt].filter((t) => ct.has(t) && !isSiblingToken(t)); | |
| 209 | + const siblings = diff.size > 0 && stem.length > 0 && [...diff].every(isSiblingToken); | |
| 210 | + | |
| 211 | + const exact = setsEqual(pt, ct) || (normalizeNameKeepCodes(probe.name) !== "" && normalizeNameKeepCodes(probe.name) === normalizeNameKeepCodes(cand.name)); | |
| 212 | + if (exact) return { ...none, score: 1, exact: true, operatorOnly }; | |
| 213 | + if (siblings) return { ...none, score: 0.2, siblings: true, operatorOnly }; | |
| 214 | + | |
| 215 | + const residualEqual = pr.size > 0 && cr.size > 0 && setsEqual(pr, cr); | |
| 216 | + if (residualEqual) return { ...none, score: 0.9, residualEqual: true, operatorOnly }; | |
| 217 | + | |
| 218 | + // alias hit: only aliases that carry more than a brand ("meta" alone must not link every Meta site) | |
| 219 | + const meaningful = (a: string) => { const t = nameTokens(a); return t.size >= 2 || (t.size === 1 && [...t][0]!.length >= 5 && !findCanonicalOperator(a)); }; | |
| 220 | + const probeAliases = [probe.name, ...(probe.aliases ?? [])].filter(meaningful).map((a) => normalizeName(a)); | |
| 221 | + const candAliases = [cand.name, ...(cand.aliases ?? [])].filter(meaningful).map((a) => normalizeName(a)); | |
| 222 | + if (probeAliases.some((a) => candAliases.includes(a))) return { ...none, score: 0.9, alias: true, operatorOnly }; | |
| 223 | + | |
| 224 | + // token overlap on the residual names (operator / country tokens ignored when known) | |
| 225 | + const a = pr.size ? pr : pt, b = cr.size ? cr : ct; | |
| 226 | + const j = jaccard(a, b); | |
| 227 | + const inter = [...a].filter((t) => b.has(t)).length; | |
| 228 | + const containment = a.size && b.size ? inter / Math.min(a.size, b.size) : 0; | |
| 229 | + // containment ("Equinix DC12" inside "Equinix DC12 Building B") beats jaccard, but a single shared token is weak | |
| 230 | + const cScore = containment * (Math.min(a.size, b.size) >= 2 ? 0.85 : 0.6); | |
| 231 | + return { ...none, score: Math.max(j, cScore), operatorOnly }; | |
| 232 | +} | |
| 233 | + | |
| 234 | +/** Code prefixes that name a building inside a site rather than the site itself. */ | |
| 235 | +const BUILDING_CODE_PREFIXES = new Set(["DC", "B", "BLDG", "BUILDING", "HALL", "DH", "PH", "PHASE", "BLD", "BLOCK", "H"]); | |
| 236 | +/** true when one name carries a building code the other lacks, or when exactly one of the two names says "campus". */ | |
| 237 | +function subBuildingCodes(a: string, b: string): boolean { | |
| 238 | + const ca = extractFacilityCodes(a), cb = extractFacilityCodes(b); | |
| 239 | + const onlyA = ca.filter((c) => !cb.includes(c)), onlyB = cb.filter((c) => !ca.includes(c)); | |
| 240 | + const building = (codes: string[]) => codes.some((c) => BUILDING_CODE_PREFIXES.has(c.replace(/\d.*$/, ""))); | |
| 241 | + if ((building(onlyA) && !onlyB.length) || (building(onlyB) && !onlyA.length)) return true; | |
| 242 | + const campusA = /\bcampus\b/i.test(a), campusB = /\bcampus\b/i.test(b); | |
| 243 | + return campusA !== campusB && (onlyA.length > 0 || onlyB.length > 0); | |
| 244 | +} | |
| 245 | + | |
| 246 | +/** true when the un-attributed side's name starts with the other side's operator ("Google Data Center Fredericia" vs operator Google). */ | |
| 247 | +function operatorImpliedByName(probe: FacilityProbe, cand: FacilityCandidate): boolean { | |
| 248 | + const pair = !probe.operatorId && cand.operatorId && cand.operatorName ? [probe.name, cand.operatorName] : probe.operatorId && !cand.operatorId && probe.operatorName ? [cand.name, probe.operatorName] : null; | |
| 249 | + if (!pair) return false; | |
| 250 | + const tokens = nameTokens(pair[0]); | |
| 251 | + return operatorAliasTokens([pair[1]]).some((seq) => seq.every((t) => tokens.has(t))); | |
| 128 | 252 | } |
| 129 | 253 | |
| 130 | 254 | /** |
| 131 | 255 | * Weighted score of an incoming record against a stored candidate. |
| 132 | 256 | * Weights: name 0.40, facility codes 0.15, operator 0.20, distance 0.15, address 0.10 — weights for signals |
| 133 | − * that are unavailable on either side are redistributed over the available ones. | |
| 257 | + * that are unavailable on either side are redistributed over the available ones. Rules on top (docs/RECONCILIATION.md): | |
| 258 | + * operator+code and operator+site raise the name signal; sibling tokens and conflicting codes are hard negatives | |
| 259 | + * (score < pending); an exact multi-token name at the same spot with no operator conflict auto-merges. | |
| 134 | 260 | */ |
| 135 | 261 | export function scoreFacilityMatch(probe: FacilityProbe, cand: FacilityCandidate): MatchScore { |
| 136 | 262 | const reasons: string[] = []; |
@@ -139,16 +265,14 @@ export function scoreFacilityMatch(probe: FacilityProbe, cand: FacilityCandidate | ||
| 139 | 265 | const name = nameSimilarity(probe, cand); |
| 140 | 266 | if (name.exact) reasons.push("name:exact"); |
| 141 | 267 | else if (name.alias) reasons.push("name:alias"); |
| 268 | + else if (name.residualEqual) reasons.push("name:site-equal"); | |
| 269 | + else if (name.siblings) reasons.push("name:siblings"); | |
| 142 | 270 | else reasons.push(`name:${name.score.toFixed(2)}`); |
| 271 | + if (name.operatorOnly) reasons.push("name:operator-only"); | |
| 143 | 272 | |
| 144 | − // facility codes (DC12, FR5, LD8…) | |
| 145 | − const pc = extractFacilityCodes(probe.name); | |
| 146 | − const cc = extractFacilityCodes(cand.name); | |
| 147 | − let codes: number | null = null; | |
| 148 | − if (pc.length && cc.length) { | |
| 149 | − codes = pc.some((c) => cc.includes(c)) ? 1 : 0; | |
| 150 | − reasons.push(codes ? "codes:match" : "codes:conflict"); | |
| 151 | − } | |
| 273 | + // facility codes (DC12, FR5, LONDON6, Inzai 3…): shared → 1, same prefix with different numbers → 0, else no signal | |
| 274 | + const codes = compareFacilityCodes(extractFacilityCodes(probe.name), extractFacilityCodes(cand.name)); | |
| 275 | + if (codes != null) reasons.push(codes ? "codes:match" : "codes:conflict"); | |
| 152 | 276 | |
| 153 | 277 | // operator |
| 154 | 278 | let op = 0.5; |
@@ -163,32 +287,48 @@ export function scoreFacilityMatch(probe: FacilityProbe, cand: FacilityCandidate | ||
| 163 | 287 | const addr = addressSimilarity(probe.address, cand.address); |
| 164 | 288 | blocked = !(name.exact && addr === 1); |
| 165 | 289 | } |
| 290 | + } else if (operatorImpliedByName(probe, cand)) { | |
| 291 | + op = 0.9; | |
| 292 | + reasons.push("operator:name-implied"); | |
| 166 | 293 | } else reasons.push("operator:unknown"); |
| 167 | 294 | |
| 168 | 295 | // distance |
| 169 | 296 | let geo: number | null = null; |
| 170 | 297 | let distanceKm: number | null = null; |
| 298 | + const radius = matchRadiusKm(probe.geoPrecision ?? "unknown", (cand.geoPrecision as GeoPrecision | undefined) ?? "unknown"); | |
| 299 | + let sameCity = false; | |
| 171 | 300 | if (validLatLng(probe.lat, probe.lng) && validLatLng(cand.lat, cand.lng)) { |
| 172 | 301 | distanceKm = haversineKm(probe.lat as number, probe.lng as number, cand.lat as number, cand.lng as number); |
| 173 | − const radius = matchRadiusKm(probe.geoPrecision ?? "unknown", (cand.geoPrecision as GeoPrecision | undefined) ?? "unknown"); | |
| 174 | 302 | geo = distanceKm <= radius ? 1 - (distanceKm / radius) * 0.4 : Math.max(0, 1 - distanceKm / (radius * 4)); |
| 175 | 303 | reasons.push(`distance:${distanceKm.toFixed(2)}km/${radius}km`); |
| 176 | 304 | } else if (probe.city && cand.city && normalizeName(probe.city) === normalizeName(cand.city)) { |
| 177 | 305 | geo = 0.6; |
| 306 | + sameCity = true; | |
| 178 | 307 | reasons.push("city:same"); |
| 179 | 308 | } |
| 309 | + // "same place": inside twice the match radius, or the same city without coordinates | |
| 310 | + const samePlace = distanceKm != null ? distanceKm <= 2 * radius : sameCity; | |
| 311 | + const geoCompatible = geo == null || samePlace; | |
| 180 | 312 | |
| 181 | 313 | const addr = addressSimilarity(probe.address, cand.address); |
| 182 | 314 | if (addr != null) reasons.push(`address:${addr.toFixed(2)}`); |
| 183 | 315 | |
| 184 | 316 | let nameScore = name.score; |
| 185 | − // Strong identifier rule: same operator + same facility code + geographically compatible → treat as exact. | |
| 186 | − if (codes === 1 && op === 1 && (geo == null || geo >= 0.6)) { | |
| 317 | + // Strong identifier rule: same (or name-implied) operator + same facility code + geographically compatible → exact. | |
| 318 | + if (codes === 1 && op >= 0.9 && geoCompatible) { | |
| 187 | 319 | nameScore = Math.max(nameScore, 0.95); |
| 188 | 320 | reasons.push("rule:operator+code"); |
| 189 | 321 | } |
| 190 | − // Code conflict with the same operator (DC12 vs DC13) is a strong negative. | |
| 191 | − if (codes === 0 && op === 1) nameScore = Math.min(nameScore, 0.45); | |
| 322 | + // Same site rule: names identical once the operator / country words are removed ("Fredericia" = "Fredericia, Denmark"). | |
| 323 | + if (name.residualEqual && op >= 0.9 && geoCompatible) { | |
| 324 | + nameScore = Math.max(nameScore, op === 1 ? 1 : 0.95); | |
| 325 | + reasons.push("rule:operator+site"); | |
| 326 | + } | |
| 327 | + // Conflicting codes (DC12 vs DC13, LONDON6 vs LONDON5) and sibling tokens (North vs East, I vs II) are hard negatives. | |
| 328 | + if (codes === 0) nameScore = Math.min(nameScore, 0.45); | |
| 329 | + if (name.siblings) nameScore = Math.min(nameScore, 0.2); | |
| 330 | + // A brand-only name ("Pulsant", "Switch") is not evidence for one particular building of that brand. | |
| 331 | + if (name.operatorOnly && !(name.exact && codes === 1)) nameScore = Math.min(nameScore, 0.3); | |
| 192 | 332 | |
| 193 | 333 | parts.push({ w: 0.4, v: nameScore }); |
| 194 | 334 | if (codes != null) parts.push({ w: 0.15, v: codes }); |
@@ -199,16 +339,39 @@ export function scoreFacilityMatch(probe: FacilityProbe, cand: FacilityCandidate | ||
| 199 | 339 | const wsum = parts.reduce((s, p) => s + p.w, 0); |
| 200 | 340 | let score = parts.reduce((s, p) => s + p.w * p.v, 0) / wsum; |
| 201 | 341 | |
| 342 | + // Exact multi-token name at the same spot, no operator conflict, no address disagreement → the same building. | |
| 343 | + // ("Meta Odense Data Center" ×2 in Odense, "Adeo Datacenter ApS" ×2 at 0 m, "Equinix ZRH5" from two sources.) | |
| 344 | + const nameIsSpecific = name.minTokens >= 2 || addr === 1 || (distanceKm != null && distanceKm <= 0.1); | |
| 345 | + if (name.exact && !name.operatorOnly && op !== 0 && samePlace && (addr == null || addr >= 0.75) && nameIsSpecific && codes !== 0) { | |
| 346 | + score = Math.max(score, op === 1 ? 0.93 : 0.92); | |
| 347 | + reasons.push("rule:exact-name+site"); | |
| 348 | + } | |
| 349 | + // An alias may come from a rename or an earlier fold: it can send a pair to review, never auto-merge it. | |
| 350 | + if (name.alias && score >= AUTO_MERGE_THRESHOLD) { | |
| 351 | + score = AUTO_MERGE_THRESHOLD - 0.01; | |
| 352 | + reasons.push("rule:alias-review"); | |
| 353 | + } | |
| 354 | + // Campus vs one of its buildings ("DATA4 MAD1" vs "MAD1-DC01", "Teraco JB3 Isando Campus" vs "JB3 Data Centre"): | |
| 355 | + // a shared site code with an extra building code on one side is a containment, not an identity → review. | |
| 356 | + if (codes === 1 && subBuildingCodes(probe.name, cand.name) && score >= AUTO_MERGE_THRESHOLD) { | |
| 357 | + score = AUTO_MERGE_THRESHOLD - 0.01; | |
| 358 | + reasons.push("rule:campus-vs-building"); | |
| 359 | + } | |
| 360 | + | |
| 202 | 361 | // country mismatch is decisive |
| 203 | 362 | if (probe.countryIso2 && cand.countryIso2 && probe.countryIso2 !== cand.countryIso2) { |
| 204 | 363 | score = Math.min(score, 0.3); |
| 205 | 364 | reasons.push("country:different"); |
| 206 | 365 | } |
| 207 | 366 | // far apart with precise coordinates on both sides → not the same building |
| 208 | − if (distanceKm != null && distanceKm > 4 * matchRadiusKm(probe.geoPrecision ?? "unknown", (cand.geoPrecision as GeoPrecision | undefined) ?? "unknown")) { | |
| 367 | + if (distanceKm != null && distanceKm > 4 * radius) { | |
| 209 | 368 | score = Math.min(score, 0.5); |
| 210 | 369 | reasons.push("distance:too-far"); |
| 211 | 370 | } |
| 371 | + if (codes === 0 || name.siblings) { | |
| 372 | + score = Math.min(score, PENDING_THRESHOLD - 0.01); | |
| 373 | + reasons.push(codes === 0 ? "rule:code-conflict" : "rule:siblings"); | |
| 374 | + } | |
| 212 | 375 | if (blocked) { |
| 213 | 376 | score = Math.min(score, PENDING_THRESHOLD - 0.01); |
| 214 | 377 | reasons.push("blocked:different-operators"); |
@@ -330,23 +493,39 @@ export function completenessScore(f: CompletenessInput): number { | ||
| 330 | 493 | export interface ConfidenceSummaryInput { |
| 331 | 494 | /** distinct source kinds with current provenance for this facility */ |
| 332 | 495 | sourceKinds: string[]; |
| 496 | + /** number of distinct sources with current provenance (defaults to the number of kinds) */ | |
| 497 | + sourceCount?: number; | |
| 333 | 498 | lastVerifiedIso?: string | null; |
| 334 | 499 | /** true when every MW observation is an estimate and no non-MW primary source exists */ |
| 335 | 500 | onlyEstimates?: boolean; |
| 336 | 501 | } |
| 337 | 502 | |
| 338 | 503 | const KIND_ORDER: SourceKind[] = ["operator", "government", "filing", "utility", "cloud_provider", "registry", "dataset", "community", "secondary", "news"]; |
| 504 | +/** Kinds whose single word is enough for `high`: the operator itself, a government / regulator, a filing, a utility, a cloud provider. */ | |
| 505 | +const AUTHORITATIVE_KINDS: ReadonlySet<string> = new Set<SourceKind>(["operator", "government", "filing", "utility", "cloud_provider"]); | |
| 506 | +/** Crowd-sourced or second-hand kinds: alone, they are `unverified`; corroborated by a second source, `moderate`. */ | |
| 507 | +const COMMUNITY_KINDS: ReadonlySet<string> = new Set<SourceKind>(["community", "secondary", "news"]); | |
| 339 | 508 | |
| 340 | −/** Facility-level confidence: best source kind as base, corroborated by the number of distinct source kinds. */ | |
| 509 | +/** | |
| 510 | + * Facility-level confidence: best source kind as base, corroborated by the number of distinct source kinds. | |
| 511 | + * - `high` only when an authoritative kind (operator / government / filing / utility / cloud provider) backs the record; | |
| 512 | + * a registry or dataset alone is `moderate`; | |
| 513 | + * - a single community / secondary / news source is `unverified` (OpenStreetMap features seen nowhere else); | |
| 514 | + * - `verified` needs ≥ 2 independent authoritative kinds. | |
| 515 | + */ | |
| 341 | 516 | export function facilityConfidence(i: ConfidenceSummaryInput): ConfidenceLevel { |
| 342 | 517 | const kinds = i.sourceKinds.filter((k): k is SourceKind => (KIND_ORDER as string[]).includes(k)); |
| 343 | 518 | if (!kinds.length) return "unverified"; |
| 344 | 519 | const best = KIND_ORDER.find((k) => kinds.includes(k))!; |
| 520 | + const sourceCount = i.sourceCount ?? kinds.length; | |
| 521 | + if (COMMUNITY_KINDS.has(best) && sourceCount <= 1) return i.onlyEstimates ? "estimated" : "unverified"; | |
| 345 | 522 | const ageDays = i.lastVerifiedIso ? Math.max(0, (Date.now() - Date.parse(i.lastVerifiedIso)) / 86_400_000) : 0; |
| 346 | − const primaryKinds = kinds.filter((k) => isPrimaryKind(k)); | |
| 347 | − // "verified" requires ≥ 2 independent primary-grade kinds agreeing on the record | |
| 348 | − const corroborations = primaryKinds.length >= 2 ? primaryKinds.length : Math.min(1, kinds.length - 1); | |
| 349 | − return computeConfidence({ sourceKind: best, corroborations, ageDays, isEstimate: !!i.onlyEstimates }); | |
| 523 | + const authoritative = kinds.filter((k) => AUTHORITATIVE_KINDS.has(k)); | |
| 524 | + // "verified" requires ≥ 2 independent authoritative kinds agreeing on the record | |
| 525 | + const corroborations = authoritative.length >= 2 ? authoritative.length : Math.min(1, kinds.length - 1); | |
| 526 | + const level = computeConfidence({ sourceKind: best, corroborations, ageDays, isEstimate: !!i.onlyEstimates }); | |
| 527 | + if (!authoritative.length && (level === "high" || level === "verified")) return "moderate"; | |
| 528 | + return level; | |
| 350 | 529 | } |
| 351 | 530 | |
| 352 | 531 | export function isPipelineStatus(status: string | null | undefined): boolean { |
modified
apps/worker/src/ingest/news.ts
+16 −2
@@ -32,6 +32,18 @@ function escapeRe(s: string): string { | ||
| 32 | 32 | return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | +/** Copyright guard for third-party publishers: the stored summary never exceeds this many characters. */ | |
| 36 | +export const THIRD_PARTY_SUMMARY_MAX = 400; | |
| 37 | + | |
| 38 | +/** Trim on a word boundary, appending an ellipsis (same behaviour as the news parser's `shortSummary`). */ | |
| 39 | +export function shortSummary(s: string | null | undefined, max = THIRD_PARTY_SUMMARY_MAX): string | null { | |
| 40 | + const t = cleanText(s); | |
| 41 | + if (!t) return null; | |
| 42 | + if (t.length <= max) return t; | |
| 43 | + const cut = t.slice(0, max - 1); | |
| 44 | + return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 40)).trim()}…`; | |
| 45 | +} | |
| 46 | + | |
| 35 | 47 | /** Operator lexicon: curated canonical names/aliases + operators already in the database (loaded once per batch). */ |
| 36 | 48 | async function operatorLexicon(tx: Tx, ctx: IngestContext): Promise<Array<{ id: string | null; name: string; patterns: RegExp[] }>> { |
| 37 | 49 | if (ctx.caches.operatorLexicon) return ctx.caches.operatorLexicon; |
@@ -113,8 +125,10 @@ export async function ingestNews(tx: Tx, ctx: IngestContext, n: NormalizedNewsEv | ||
| 113 | 125 | const id = stableId("news", url); |
| 114 | 126 | const publishedAt = isoOrNull(n.publishedAt); |
| 115 | 127 | const mentions = { ...(n.mentions ?? {}), linkedOperators: links.operatorNames }; |
| 128 | + // third-party publishers (kind = news): title, date, link and a ≤ 400-character summary only — never more text | |
| 129 | + const summary = ctx.run.sourceKind === "news" ? shortSummary(n.summary, THIRD_PARTY_SUMMARY_MAX) : cleanText(n.summary); | |
| 116 | 130 | const inserted = await tx.execute(sql`insert into news_items (id, source_id, connector_id, url, title, published_at, summary, page_type, event_type, mentions, operator_ids, country_iso2s, facility_ids, mw, significance) |
| 117 | − values (${id}, ${ctx.run.sourceId}, ${ctx.run.connectorId}, ${url}, ${title.slice(0, 500)}, ${publishedAt}, ${cleanText(n.summary)}, ${n.pageType ?? "unknown"}, ${eventType}, ${JSON.stringify(mentions)}::jsonb, | |
| 131 | + values (${id}, ${ctx.run.sourceId}, ${ctx.run.connectorId}, ${url}, ${title.slice(0, 500)}, ${publishedAt}, ${summary}, ${n.pageType ?? "unknown"}, ${eventType}, ${JSON.stringify(mentions)}::jsonb, | |
| 118 | 132 | ${textArray(links.operatorIds)}::text[], ${textArray(links.countryIso2s)}::text[], ${textArray(links.facilityIds)}::text[], ${links.mw}, ${significance}) |
| 119 | 133 | on conflict (url) do update set title = excluded.title, summary = coalesce(excluded.summary, news_items.summary), published_at = coalesce(excluded.published_at, news_items.published_at), |
| 120 | 134 | event_type = coalesce(excluded.event_type, news_items.event_type), mentions = excluded.mentions, operator_ids = excluded.operator_ids, country_iso2s = excluded.country_iso2s, |
@@ -133,7 +147,7 @@ export async function ingestNews(tx: Tx, ctx: IngestContext, n: NormalizedNewsEv | ||
| 133 | 147 | entityId: id, |
| 134 | 148 | eventType, |
| 135 | 149 | title, |
| 136 | − summary: cleanText(n.summary), | |
| 150 | + summary, | |
| 137 | 151 | newValue: { url, mw: links.mw, operators: links.operatorNames, countries: links.countryIso2s }, |
| 138 | 152 | significance, |
| 139 | 153 | confidence: n.provenance.confidence, |
added
apps/worker/src/ingest/operator-inference.test.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { inferOperatorFromName, OPERATOR_INFERENCE_METHOD } from "./operator-inference.js"; | |
| 3 | + | |
| 4 | +describe("operator inference from a canonical name prefix", () => { | |
| 5 | + it("recognises canonical brands and aliases at the start of a facility name", () => { | |
| 6 | + expect(inferOperatorFromName("Equinix AM3")?.name).toBe("Equinix"); | |
| 7 | + expect(inferOperatorFromName("Google Data Center, Elmina")?.name).toBe("Google"); | |
| 8 | + expect(inferOperatorFromName("Google Data Center Fredericia")?.name).toBe("Google"); | |
| 9 | + expect(inferOperatorFromName("Interxion FRA1")?.name).toBe("Digital Realty"); | |
| 10 | + expect(inferOperatorFromName("Amazon SIN2")?.name).toBe("Amazon Web Services"); | |
| 11 | + expect(inferOperatorFromName("NTT Slough 3")?.name).toBe("NTT Global Data Centers"); | |
| 12 | + expect(inferOperatorFromName("Telehouse North")?.name).toBe("Telehouse"); | |
| 13 | + expect(inferOperatorFromName("Digital Realty DUB10")?.name).toBe("Digital Realty"); | |
| 14 | + expect(inferOperatorFromName("Equinix AM3")?.method).toBe(OPERATOR_INFERENCE_METHOD); | |
| 15 | + }); | |
| 16 | + it("accepts ambiguous brands only with data-center vocabulary or a facility code", () => { | |
| 17 | + expect(inferOperatorFromName("Meta Odense Data Center")?.name).toBe("Meta"); | |
| 18 | + expect(inferOperatorFromName("Apple Data Center Viborg")?.name).toBe("Apple"); | |
| 19 | + expect(inferOperatorFromName("Switch Las Vegas 8")?.name).toBe("Switch"); | |
| 20 | + expect(inferOperatorFromName("Vantage WA11")?.name).toBe("Vantage Data Centers"); | |
| 21 | + expect(inferOperatorFromName("Aligned DC - PDX-01")?.name).toBe("Aligned Data Centers"); | |
| 22 | + expect(inferOperatorFromName("Meta Building")).toBeNull(); | |
| 23 | + expect(inferOperatorFromName("Switch")).toBeNull(); | |
| 24 | + expect(inferOperatorFromName("Apple Store Odense")).toBeNull(); | |
| 25 | + }); | |
| 26 | + it("never infers from ordinary words, place names or brands buried inside the name", () => { | |
| 27 | + expect(inferOperatorFromName("Green Data Center Norway")).toBeNull(); | |
| 28 | + expect(inferOperatorFromName("Orange County Data Center")).toBeNull(); | |
| 29 | + expect(inferOperatorFromName("Stream Building 4")).toBeNull(); | |
| 30 | + expect(inferOperatorFromName("Bell Tower Data Center")).toBeNull(); | |
| 31 | + expect(inferOperatorFromName("Bell Canada Exchange")?.name).toBe("Bell Canada"); | |
| 32 | + expect(inferOperatorFromName("Datacenter Groningen (Google)")).toBeNull(); | |
| 33 | + expect(inferOperatorFromName("Centro de Datos")).toBeNull(); | |
| 34 | + expect(inferOperatorFromName("ساختمان مهندسین")).toBeNull(); | |
| 35 | + expect(inferOperatorFromName("AWS")).toBeNull(); | |
| 36 | + expect(inferOperatorFromName("Amazon Web Services")?.name).toBe("Amazon Web Services"); | |
| 37 | + }); | |
| 38 | +}); | |
added
apps/worker/src/ingest/operator-inference.ts
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +/** | |
| 2 | + * Conservative operator inference from a facility name. OpenStreetMap / Wikidata features often carry no | |
| 3 | + * `operator` tag while the name starts with a canonical brand ("Equinix AM3", "Google Data Center, Elmina", | |
| 4 | + * "Meta Odense Data Center"). Only the curated canonical table (names + aliases) is used, matched as a whole-token | |
| 5 | + * prefix of the name — never fuzzy, never inside the name. Provenance method: `inferred:name-prefix`. | |
| 6 | + */ | |
| 7 | +import { normalizeName } from "@dci/core"; | |
| 8 | +import { CANONICAL_OPERATORS, type CanonicalOperator } from "./canonical-operators.js"; | |
| 9 | + | |
| 10 | +export const OPERATOR_INFERENCE_METHOD = "inferred:name-prefix"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Brand words that are also ordinary words or too short to stand alone ("Switch", "Meta", "Apple", "IO", "Spark"): | |
| 14 | + * accepted only when the rest of the name looks like a facility (data-center vocabulary or a facility code). | |
| 15 | + */ | |
| 16 | +/** Ambiguous brands where data-center vocabulary in the raw name is enough ("Meta Odense Data Center", "Apple Data Center Viborg"). */ | |
| 17 | +const AMBIGUOUS_VOCAB: ReadonlySet<string> = new Set(["meta", "apple", "switch", "aligned", "vantage", "compass", "lambda", "verne", "moro"]); | |
| 18 | +/** Ambiguous brands that additionally need a facility code ("Vantage WA11", "Switch Las Vegas 8"). */ | |
| 19 | +const AMBIGUOUS_CODE: ReadonlySet<string> = new Set(["stack", "stc", "esr", "kio", "gds", "cdc", "adc", "att", "mci", "cht", "telx", "skt", "ctg", "hkt", "pdg", "gdh", "ngd", "dft", "dlr", "oci", "gcp", "aws", "ovh", "ntt", "amt", "apld", "bce", "yti", "ytl", "udcs", "jio", "sify", "inap"]); | |
| 20 | +/** Ordinary words: never inferred from a single token whatever follows ("Green Data Center Norway", "Orange County"). */ | |
| 21 | +const NEVER_ALONE: ReadonlySet<string> = new Set(["green", "constant", "beyond", "stream", "bulk", "chief", "spark", "orange", "bell", "tract", "packet", "io", "tm", "kt", "bt", "tel", "tata", "reliance", "iliad", "scala", "elea", "echelon", "rowan", "takoda", "novva", "revera", "sabey", "ark", "virtus", "cirion", "nxtra", "khazna", "linode", "lumen"]); | |
| 22 | +const PLACE_WORDS = /^(county|parish|township|city|town|village|valley|street|st|road|rd|avenue|ave|park|lake|river|island|bay|beach|hills?|mountain|falls|creek)$/; | |
| 23 | +const FACILITY_HINT_RE = /\b(data ?cent(er|re)s?|datacent(er|re)s?|rechenzentrum|centre de donn[ée]es|centro de datos|campus|colocation|colo|dc|idc|hub|pop|site)\b/i; | |
| 24 | +const CODE_HINT_RE = /(^|[^A-Za-z0-9])[A-Z]{1,6}-?\d{1,3}[A-Z]?([^A-Za-z0-9]|$)|\b[A-Z][a-z]+ \d{1,2}\b/; | |
| 25 | + | |
| 26 | +interface BrandEntry { tokens: string[]; op: CanonicalOperator; canonical: boolean } | |
| 27 | +let brands: BrandEntry[] | null = null; | |
| 28 | + | |
| 29 | +function table(): BrandEntry[] { | |
| 30 | + if (brands) return brands; | |
| 31 | + const out: BrandEntry[] = []; | |
| 32 | + for (const op of CANONICAL_OPERATORS) { | |
| 33 | + const seen = new Set<string>(); | |
| 34 | + for (const [i, raw] of [op.name, ...op.aliases].entries()) { | |
| 35 | + const tokens = normalizeName(raw).split(" ").filter(Boolean); | |
| 36 | + const key = tokens.join(" "); | |
| 37 | + if (!tokens.length || seen.has(key)) continue; | |
| 38 | + // a single token shorter than 3 characters ("IO", "TM") is never a safe prefix | |
| 39 | + if (tokens.length === 1 && tokens[0]!.length < 3) continue; | |
| 40 | + seen.add(key); | |
| 41 | + out.push({ tokens, op, canonical: i === 0 }); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + // longest brand first so "Digital Realty" wins over "Digital"-prefixed shorter aliases | |
| 45 | + out.sort((a, b) => b.tokens.length - a.tokens.length || b.tokens.join(" ").length - a.tokens.join(" ").length); | |
| 46 | + brands = out; | |
| 47 | + return out; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export interface InferredOperator { name: string; matched: string; method: string } | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * Operator implied by the name's leading tokens, or null. Rules: | |
| 54 | + * - the brand tokens must be the first tokens of the normalized name (noise words like "data center" already removed); | |
| 55 | + * - the name must carry something beyond the brand (a site / code / vocabulary) unless it equals the canonical name exactly; | |
| 56 | + * - ambiguous brands require data-center vocabulary or a facility code in the raw name. | |
| 57 | + */ | |
| 58 | +export function inferOperatorFromName(name: string | null | undefined): InferredOperator | null { | |
| 59 | + if (!name) return null; | |
| 60 | + const tokens = normalizeName(name).split(" ").filter(Boolean); | |
| 61 | + if (!tokens.length) return null; | |
| 62 | + for (const b of table()) { | |
| 63 | + if (b.tokens.length > tokens.length) continue; | |
| 64 | + if (!b.tokens.every((t, i) => tokens[i] === t)) continue; | |
| 65 | + const rest = tokens.slice(b.tokens.length); | |
| 66 | + const brandKey = b.tokens.join(" "); | |
| 67 | + const nameOnly = rest.length === 0; | |
| 68 | + if (nameOnly && !b.canonical) continue; // "AWS" alone is not a facility name | |
| 69 | + if (b.tokens.length === 1) { | |
| 70 | + if (NEVER_ALONE.has(brandKey)) continue; | |
| 71 | + if (AMBIGUOUS_CODE.has(brandKey) && !CODE_HINT_RE.test(name)) continue; | |
| 72 | + if (AMBIGUOUS_VOCAB.has(brandKey) && !(FACILITY_HINT_RE.test(name) || CODE_HINT_RE.test(name))) continue; | |
| 73 | + } | |
| 74 | + // the rest must look like a site, not a place name or a different business ("Orange County", "Apple Store") | |
| 75 | + if (rest.length && (PLACE_WORDS.test(rest[0]!) || /^(store|shop|office|offices|headquarters|hq|fiber|retail|bank|insurance)\b/.test(rest.join(" ")))) continue; | |
| 76 | + return { name: b.op.name, matched: brandKey, method: OPERATOR_INFERENCE_METHOD }; | |
| 77 | + } | |
| 78 | + return null; | |
| 79 | +} | |
modified
apps/worker/src/ingest/projects.ts
+36 −3
@@ -70,7 +70,36 @@ async function resolveProject(tx: Tx, ctx: IngestContext, p: NormalizedProject, | ||
| 70 | 70 | and (${operatorId}::text is null or operator_id is null or operator_id = ${operatorId}) |
| 71 | 71 | and (normalized_name = ${norm} or similarity(normalized_name, ${norm}) >= 0.85) |
| 72 | 72 | order by (operator_id = ${operatorId}) desc nulls last, sim desc limit 1`); |
| 73 | − return rows[0] ? String(rows[0].id) : null; | |
| 73 | + if (rows[0]) return String(rows[0].id); | |
| 74 | + return sameAnnouncement(tx, ctx, p, operatorId, country); | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Announcement window: two outlets reporting the same project within this many days are one project. */ | |
| 78 | +export const ANNOUNCEMENT_WINDOW_DAYS = 30; | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * The same announcement covered by several outlets: same operator, same city (or same country when neither side | |
| 82 | + * names a city), planned MW within ±10 % and announced within 30 days → one project; the other articles become | |
| 83 | + * timeline rows / provenance. Requires an operator AND a size: without both, two "Ohio data center project" | |
| 84 | + * rows may well be different sites. | |
| 85 | + */ | |
| 86 | +async function sameAnnouncement(tx: Tx, ctx: IngestContext, p: NormalizedProject, operatorId: string | null, country: string | null): Promise<string | null> { | |
| 87 | + if (!operatorId || p.plannedMw == null || p.plannedMw <= 0 || !p.announcedOn) return null; | |
| 88 | + const day = p.announcedOn.length >= 10 ? p.announcedOn.slice(0, 10) : null; | |
| 89 | + if (!day) return null; | |
| 90 | + const city = p.city ? normalizeName(p.city) : null; | |
| 91 | + const rows = await tx.execute(sql` | |
| 92 | + select id from projects | |
| 93 | + where operator_id = ${operatorId} and planned_mw is not null | |
| 94 | + and planned_mw between ${p.plannedMw * 0.9} and ${p.plannedMw * 1.1} | |
| 95 | + and announced_on is not null and length(announced_on) >= 10 | |
| 96 | + and abs(announced_on::date - ${day}::date) <= ${ANNOUNCEMENT_WINDOW_DAYS} | |
| 97 | + and (${country}::text is null or country_iso2 is null or country_iso2 = ${country}) | |
| 98 | + and ((${city}::text is not null and city is not null and lower(regexp_replace(city, '[^A-Za-z0-9]+', ' ', 'g')) = ${city}) or (${city}::text is null and city is null)) | |
| 99 | + order by created_at asc limit 1`); | |
| 100 | + if (!rows[0]) return null; | |
| 101 | + ctx.stats.projectDedup = (ctx.stats.projectDedup ?? 0) + 1; | |
| 102 | + return String(rows[0].id); | |
| 74 | 103 | } |
| 75 | 104 | |
| 76 | 105 | function obs(value: unknown, field: string, p: NormalizedProject, ctx: IngestContext): FieldObservation { |
@@ -178,9 +207,13 @@ export async function ingestProject(tx: Tx, ctx: IngestContext, p: NormalizedPro | ||
| 178 | 207 | bump(ctx, "project"); |
| 179 | 208 | await writeProvenance(tx, ctx, "project", id, observed, p.key); |
| 180 | 209 | |
| 181 | − // timeline (deduplicated) | |
| 210 | + // timeline (deduplicated); another outlet's coverage of an existing project is kept as a "reported" row | |
| 182 | 211 | let timelineAdded = 0; |
| 183 | − for (const t of p.timeline ?? []) { | |
| 212 | + const timeline = [...(p.timeline ?? [])]; | |
| 213 | + if (existing && p.sourceUrl && existing.sourceUrl && p.sourceUrl !== existing.sourceUrl && p.announcedOn && p.description) { | |
| 214 | + timeline.push({ date: p.announcedOn, type: "reported", description: `Also reported: ${String(p.description).slice(0, 300)}`, url: p.sourceUrl }); | |
| 215 | + } | |
| 216 | + for (const t of timeline) { | |
| 184 | 217 | if (!t?.date || !t.description) continue; |
| 185 | 218 | const tid = `ptl_${sha256(`${id}|${t.date}|${t.type}|${t.description.trim().toLowerCase()}`).slice(0, 20)}`; |
| 186 | 219 | const r = await tx.execute(sql`insert into project_timeline (id, project_id, event_date, event_type, description, source_id, document_id, url) |
modified
packages/connectors/src/generic.ts
+64 −6
@@ -82,11 +82,13 @@ export class GenericConnector implements Connector { | ||
| 82 | 82 | } |
| 83 | 83 | } |
| 84 | 84 | // Newsroom / announcement pages with no dedicated extractor: produce a news_event record when relevant. |
| 85 | + // Third-party publishers (kind = news) keep only title, date, link and a ≤ 400-char summary — never the text. | |
| 85 | 86 | if (!records.length && ["press_release", "project_announcement", "acquisition", "expansion", "closure", "construction_update", "planning_document", "power_infrastructure", "cloud_region", "financial_disclosure"].includes(pageType) && isDataCenterRelevant(`${title ?? ""} ${text.slice(0, 5000)}`)) { |
| 86 | − const summary = cleanText((doc.meta?.summary as string | undefined) ?? metaDescription(html) ?? text.slice(0, 400)); | |
| 87 | + const thirdParty = this.sourceKind === "news"; | |
| 88 | + const summary = trimSummary(cleanText((doc.meta?.summary as string | undefined) ?? metaDescription(html) ?? text.slice(0, 400)), THIRD_PARTY_SUMMARY_MAX); | |
| 87 | 89 | const published = (doc.meta?.published as string | undefined) ? parsePartialDate(String(doc.meta?.published)) : html ? publishedDate(html) : null; |
| 88 | 90 | const money = parseMoney(`${title ?? ""} ${text.slice(0, 3000)}`); |
| 89 | − records.push({ kind: "news_event", key: `${this.id}:news:${urlFingerprint(doc.finalUrl)}`, data: { title: title ?? doc.finalUrl, url: doc.finalUrl, publishedAt: published, summary, pageType, eventType: cls.eventType, mw: cls.mw, status: cls.status, money, text: text.slice(0, 20_000) }, methods: { title: "html:title", publishedAt: "meta:published", mw: "regex:mw_v1" }, certainty: 0.6, url: doc.finalUrl, pageType }); | |
| 91 | + records.push({ kind: "news_event", key: `${this.id}:news:${urlFingerprint(doc.finalUrl)}`, data: { title: title ?? doc.finalUrl, url: doc.finalUrl, publishedAt: published, summary, pageType, eventType: cls.eventType, mw: cls.mw, status: cls.status, money, text: thirdParty ? null : text.slice(0, 20_000) }, methods: { title: "html:title", publishedAt: "meta:published", mw: "regex:mw_v1" }, certainty: 0.6, url: doc.finalUrl, pageType }); | |
| 90 | 92 | } |
| 91 | 93 | return records; |
| 92 | 94 | } |
@@ -122,10 +124,14 @@ export class GenericConnector implements Connector { | ||
| 122 | 124 | } else if (r.kind === "news_event") { |
| 123 | 125 | const n: NormalizedNewsEvent = { entityType: "news_event", key: r.key, title: str("title") ?? r.url, url: (str("url") ?? r.url)!, publishedAt: str("publishedAt"), summary: str("summary"), pageType: (str("pageType") as PageType | null) ?? r.pageType, eventType: (str("eventType") as NormalizedNewsEvent["eventType"]) ?? undefined, mentions: { mw: Array.isArray(x.mw) ? (x.mw as number[]) : [], operators: [...(Array.isArray(x.operators) ? (x.operators as string[]) : []), ...(d.operatorName ? [d.operatorName] : [])], countriesIso2: [...(Array.isArray(x.countries) ? (x.countries as string[]) : []), countryFromText(`${str("title")} ${str("summary")}`) ?? ""].filter((c, i, a) => c && a.indexOf(c) === i), cities: Array.isArray(x.cities) ? (x.cities as string[]) : [] }, provenance: prov }; |
| 124 | 126 | out.push(n); |
| 125 | − // Project candidate: announcement with MW figure + status keyword | |
| 126 | − const mw = (Array.isArray(x.mw) ? (x.mw as number[]) : parseAllMw(`${n.title} ${n.summary ?? ""}`))[0]; | |
| 127 | − const st = str("status") as FacilityStatus | null; | |
| 128 | − if (mw && st && ["announced", "proposed", "permitting", "approved", "under_construction", "expansion", "rumored", "delayed"].includes(st) && ["project_announcement", "expansion", "construction_update", "planning_document"].includes(n.pageType ?? "")) { | |
| 127 | + // Project candidate from a fallback news_event (no dedicated parser). Third-party publishers (kind = news) go | |
| 128 | + // through `news_article_v1`, which has the real gate — the fallback never creates projects for them. For | |
| 129 | + // operator / government newsrooms the announcement must be visible in the HEADLINE: a MW figure in the title | |
| 130 | + // and a pipeline status keyword in the title (appointments, awards and financing stories quote MW in the body). | |
| 131 | + const gate = this.sourceKind === "news" ? null : fallbackProjectGate(n.title, n.summary); | |
| 132 | + const mw = gate?.mw; | |
| 133 | + const st = gate?.status as FacilityStatus | undefined; | |
| 134 | + if (gate && mw && st && ["project_announcement", "expansion", "construction_update", "planning_document", "press_release"].includes(n.pageType ?? "")) { | |
| 129 | 135 | const money = x.money as { amount: number; currency: string } | null; |
| 130 | 136 | const p: NormalizedProject = { entityType: "project", key: `${r.key}:project`, name: n.title.slice(0, 160), operatorName: d.operatorName ?? null, countryIso2: n.mentions?.countriesIso2?.[0] ?? d.countryIso2 ?? null, status: st, announcedOn: n.publishedAt, plannedMw: mw, investmentUsd: money && money.currency === "USD" ? money.amount : null, description: n.summary, sourceUrl: n.url, timeline: n.publishedAt ? [{ date: n.publishedAt, type: st === "under_construction" ? "construction_started" : "project_announced", description: n.title, url: n.url }] : [], provenance: { ...prov, confidence: "moderate", method: "regex:announcement_v1" } }; |
| 131 | 137 | out.push(p); |
@@ -149,6 +155,39 @@ export class GenericConnector implements Connector { | ||
| 149 | 155 | async validate(_ctx: ConnectorContext, entities: NormalizedEntity[]): Promise<ValidationReport> { return validateEntities(entities); } |
| 150 | 156 | } |
| 151 | 157 | |
| 158 | +/** Third-party publishers: the stored summary never exceeds this many characters (copyright). */ | |
| 159 | +export const THIRD_PARTY_SUMMARY_MAX = 400; | |
| 160 | +export function trimSummary(s: string | null, max = THIRD_PARTY_SUMMARY_MAX): string | null { | |
| 161 | + if (!s || s.length <= max) return s; | |
| 162 | + const cut = s.slice(0, max - 1); | |
| 163 | + return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 40)).trim()}…`; | |
| 164 | +} | |
| 165 | + | |
| 166 | +/** Headlines about people, awards, deals, money, markets or policy — a MW figure in them is never a new site (fallback path). */ | |
| 167 | +export const NON_PROJECT_HEADLINE_RE = /\b(appoint|names? .+ as|joins|award|recogni[sz]ed|honou?red|named .+ of the year|certified|interview|webinar|podcast|featured in|partnership|partners with|join forces|work together|agreement|contract|ppa\b|peering|pop at|financ|refinanc|raises \$|series [a-e]\b|loan|bond|esg\b|results|earnings|revenue|report|survey|market (to|will|set to) |lawsuit|sues|bill would|tax|moratorium|opens|opened|now (open|online|live)|we are online|delivered|celebrat|headquarters|head office|offices?\b|why |how |what )/i; | |
| 168 | +/** Headlines that describe planning or building a facility (fallback path). */ | |
| 169 | +export const BUILD_HEADLINE_RE = /\b(breaks? ground|broke ground|groundbreaking|to build|will build|builds?|build of|plans? (for|to|new|\d)|planned|proposes?|proposed|approv(es|ed|al)|files? (plans|for)|rezon\w+|to develop|develops?|to construct|construction|unveils?|announces? (plans|new|a new|two|three|its|\d|\$|groundbreaking)|to invest|invests?|expansion|expands?|expanding|adds? \d+ ?\+?(mw|gw)|new (\d+ ?\+?(mw|gw)|hyperscale|data ?cent(er|re)s?|campus|facilit(y|ies))|(\d+ ?\+?(mw|gw))[- ](campus|data ?cent(er|re)|facility|site|project|hyperscale|phase)|topped out|topping out|enters? [A-Z]\w+ with|first data ?cent(er|re) in|further facilit(y|ies)|additional \d+ ?mw|second|third|fourth|fifth|phase \d)\b/i; | |
| 170 | + | |
| 171 | +/** | |
| 172 | + * Gate for the fallback project candidate (pages with no dedicated parser). The announcement must be visible up | |
| 173 | + * front: a build / plan headline that is not a people / deal / money story, a pipeline status keyword in the | |
| 174 | + * headline, and a MW figure in the headline or the summary (the first ≤ 400 characters). Returns the figure and | |
| 175 | + * status to use, or null. | |
| 176 | + */ | |
| 177 | +export function fallbackProjectGate(title: string, summary: string | null | undefined): { mw: number; status: FacilityStatus } | null { | |
| 178 | + if (!title || NON_PROJECT_HEADLINE_RE.test(title) || !BUILD_HEADLINE_RE.test(title)) return null; | |
| 179 | + const st = inferStatus(title) as FacilityStatus | null; | |
| 180 | + if (!st || !["announced", "proposed", "permitting", "approved", "under_construction", "expansion", "rumored", "delayed"].includes(st)) return null; | |
| 181 | + const plausible = (v: number) => v > 0 && v <= 20_000; | |
| 182 | + const mw = parseAllMw(title).filter(plausible)[0] ?? parseAllMw(`${title} ${summary ?? ""}`.slice(0, 600)).filter(plausible)[0]; | |
| 183 | + return mw ? { mw, status: st } : null; | |
| 184 | +} | |
| 185 | + | |
| 186 | +/** Facility names that are page / article titles or listings rather than a site ("Data Centers in Virginia | Acme", "Our Locations"). */ | |
| 187 | +export const TITLE_LIKE_NAME_RE = /\s[|]\s|\?$|^(?:how|why|what|when|where|inside|top \d+|the \d+ (?:best|largest))\b|\b(?:data cent(?:er|re)s? (?:in|near|for|across|around) [A-Z]|colocation (?:in|near) [A-Z]|(?:our|all|view|browse|explore) (?:data cent(?:er|re)s?|sites|facilities|locations)|locations\b|list of|news(?:room)?|blog|contact us|privacy|cookie|login|sign in|search results?|page not found|404|about us|careers?|press releases?|sitemap|home ?page|learn more|read more|coming soon)\b/i; | |
| 188 | +/** Names that describe a business other than a data center (crowd-sourced tags). */ | |
| 189 | +export const NOT_A_FACILITY_NAME_RE = /\b(process server|training cent(?:er|re)|kursu|web (?:design|developer|development)|software developer|repair|shop|store|cafe|restaurant|hotel|school|clinic|pharmacy|translation|law firm|accounting|real estate agency)\b/i; | |
| 190 | + | |
| 152 | 191 | /** Shared validation: hard errors reject the record; warnings are recorded. Never accept impossible figures. */ |
| 153 | 192 | export function validateEntities(entities: NormalizedEntity[]): ValidationReport { |
| 154 | 193 | const issues: ValidationIssue[] = []; |
@@ -159,18 +198,37 @@ export function validateEntities(entities: NormalizedEntity[]): ValidationReport | ||
| 159 | 198 | const err = (field: string, message: string) => errs.push({ key: e.key, field, level: "error", message }); |
| 160 | 199 | if (e.entityType === "facility") { |
| 161 | 200 | if (!e.name || e.name.length < 2 || e.name.length > 200) err("name", "missing or implausible name"); |
| 201 | + else { | |
| 202 | + if (TITLE_LIKE_NAME_RE.test(e.name)) err("name", "name looks like a page or article title, not a facility"); | |
| 203 | + if (NOT_A_FACILITY_NAME_RE.test(e.name)) err("name", "name describes another kind of business"); | |
| 204 | + if (e.name.length > 120) warn("name", "unusually long facility name"); | |
| 205 | + } | |
| 162 | 206 | if (e.geo && !validLatLng(e.geo.lat, e.geo.lng)) err("geo", "invalid coordinates"); |
| 207 | + if (e.geo && e.geo.precision === "exact" && Number.isInteger(e.geo.lat) && Number.isInteger(e.geo.lng)) err("geo", "integer coordinates cannot be exact"); | |
| 163 | 208 | for (const f of ["itCapacityMw", "totalPowerMw", "plannedPowerMw"] as const) { const v = e[f]; if (v != null && (v <= 0 || v > 10_000)) err(f, `implausible MW ${v}`); } |
| 209 | + // a single building above 1 000 MW is a campus-level or portfolio figure unless the record says "campus" | |
| 210 | + const isCampus = /\b(campus|park|cluster|hub|gigafactory|complex)\b/i.test(`${e.name} ${e.campusName ?? ""}`); | |
| 211 | + for (const f of ["itCapacityMw", "totalPowerMw"] as const) { const v = e[f]; if (v != null && v > 1_000 && !isCampus) err(f, `single-facility ${f} ${v} MW above 1 000 MW without a campus designation`); } | |
| 212 | + if (e.plannedPowerMw != null && e.plannedPowerMw > 5_000 && !isCampus) warn("plannedPowerMw", `planned ${e.plannedPowerMw} MW for a single facility`); | |
| 164 | 213 | if (e.itCapacityMw != null && e.totalPowerMw != null && e.itCapacityMw > e.totalPowerMw * 1.05) warn("itCapacityMw", "IT capacity exceeds total power"); |
| 165 | 214 | if (e.pue != null && (e.pue < 1 || e.pue > 3)) err("pue", `implausible PUE ${e.pue}`); |
| 166 | 215 | if (e.buildingSqm != null && (e.buildingSqm < 20 || e.buildingSqm > 2_000_000)) err("buildingSqm", "implausible building size"); |
| 216 | + if (e.buildingSqm != null && e.buildingSqm > 500_000 && !isCampus) err("buildingSqm", `building ${e.buildingSqm} m² above 500 000 m² without a campus designation`); | |
| 217 | + if (e.siteAreaHa != null && (e.siteAreaHa <= 0 || e.siteAreaHa > 20_000)) err("siteAreaHa", `implausible site area ${e.siteAreaHa} ha`); | |
| 218 | + if (e.rackCount != null && (e.rackCount <= 0 || e.rackCount > 100_000)) err("rackCount", `implausible rack count ${e.rackCount}`); | |
| 167 | 219 | if (e.countryIso2 && !/^[A-Z]{2}$/.test(e.countryIso2)) err("countryIso2", "bad country code"); |
| 168 | 220 | if (!e.countryIso2 && !e.geo) warn("countryIso2", "no country and no coordinates"); |
| 169 | 221 | if (e.openedOn && Number(e.openedOn.slice(0, 4)) < 1950) err("openedOn", "opening year before 1950"); |
| 222 | + if (e.openedOn && Number(e.openedOn.slice(0, 4)) < 1980 && e.provenance.connectorId !== "wikidata") warn("openedOn", "opening year before 1980 from a non-Wikidata source (building date?)"); | |
| 223 | + for (const f of ["openedOn", "constructionStartedOn", "announcedOn"] as const) { const v = e[f]; if (v && Number(v.slice(0, 4)) > new Date().getFullYear() + 12) err(f, `${f} ${v} too far in the future`); } | |
| 224 | + if (e.openedOn && e.constructionStartedOn && e.openedOn.slice(0, 4) < e.constructionStartedOn.slice(0, 4)) warn("openedOn", "opened before construction started"); | |
| 170 | 225 | } else if (e.entityType === "project") { |
| 171 | 226 | if (!e.name) err("name", "missing name"); |
| 227 | + else if (TITLE_LIKE_NAME_RE.test(e.name)) warn("name", "project name looks like an article title"); | |
| 172 | 228 | if (e.plannedMw != null && (e.plannedMw <= 0 || e.plannedMw > 20_000)) err("plannedMw", `implausible MW ${e.plannedMw}`); |
| 173 | 229 | if (e.investmentUsd != null && (e.investmentUsd < 1e5 || e.investmentUsd > 1e12)) warn("investmentUsd", "investment outside plausible range"); |
| 230 | + if (e.investmentUsd != null && e.investmentUsd > 200e9) err("investmentUsd", `investment ${e.investmentUsd} above $200B is an industry statistic`); | |
| 231 | + if (e.expectedOpening && Number(e.expectedOpening.slice(0, 4)) > new Date().getFullYear() + 15) err("expectedOpening", `expected opening ${e.expectedOpening} too far in the future`); | |
| 174 | 232 | if (!e.countryIso2 && !e.geo) warn("countryIso2", "no location"); |
| 175 | 233 | } else if (e.entityType === "cloud_region") { |
| 176 | 234 | if (!e.code || !e.name) err("code", "missing code/name"); |
modified
packages/core/src/normalize.ts
+66 −7
@@ -39,13 +39,71 @@ export function normalizeNameKeepCodes(name: string): string { | ||
| 39 | 39 | .trim(); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | −/** Extract facility codes such as DC12, FR5, AM3, LD8, SV1, NY9, TY11, CH1-CH4. */ | |
| 42 | +/** | |
| 43 | + * Words that precede a number without naming a facility ("Tier 3", "Route 7", "Suite 200") — never a facility code. | |
| 44 | + * Units and address vocabulary are excluded so that "50 MW" or "Highway 9" never become identifiers. | |
| 45 | + */ | |
| 46 | +export const FACILITY_CODE_STOPWORDS: ReadonlySet<string> = new Set([ | |
| 47 | + "TIER", "LEVEL", "FLOOR", "SUITE", "UNIT", "ROUTE", "HIGHWAY", "HWY", "ROAD", "RD", "STREET", "ST", "AVENUE", "AVE", "BOULEVARD", "BLVD", "NO", "TOP", "MW", "KW", "GW", "PUE", "RACK", "RACKS", "ZONE", "REGION", "PHONE", "TEL", "FAX", "BOX", "PO", "KM", "MI", "SQ", "SQM", "SQFT", "ACRE", "ACRES", "PHASE", | |
| 48 | + // prepositions / verbs that precede a figure in prose ("with 50 MW", "opened 2019", "built in 1998") | |
| 49 | + "WITH", "OF", "AND", "IN", "AT", "TO", "THE", "OVER", "THAN", "ABOUT", "UP", "FOR", "BY", "ON", "FROM", "OR", "NEAR", "PLUS", "AROUND", "APPROX", "CIRCA", "SINCE", "BUILT", "EST", "ESTABLISHED", "OPENED", "OPEN", "OPENING", "YEAR", "NR", "NUM", "NUMBER", "TOTAL", "ONLY", "SOME", "ALL", "PER", | |
| 50 | +]); | |
| 51 | +/** A figure immediately followed by a unit is a quantity, not a code ("50 MW", "3 acres", "12%"). */ | |
| 52 | +const UNIT_AFTER_RE = /^\s?(MW|GW|KW|MWH|KWH|TW|SQ|SQM|SQFT|M2|FT|ACRES?|HA|KM|MI|%|MILLION|BILLION|MILLIONS|RACKS?|STOREYS?|STORIES|FLOORS?|BUILDINGS?|HALLS?|PHASES?|YEARS?)(?![A-Z])/; | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Extract facility codes: `DC12`, `FR5`, `LD8`, `SV1`, `LONDON6`, `LON6`, `DC-12`, `FRA01A`, `IAD12`, `SYD01`, `SC-2`, | |
| 56 | + * `S3`, `Inzai 3`, `Fremont 2`, `Las Vegas 8` (→ `VEGAS8`). A single-letter prefix is only accepted when attached | |
| 57 | + * (`S3`, `M1`), a word prefix may be separated by a space or a hyphen. Leading zeros are dropped (`SYD01` = `SYD1`) | |
| 58 | + * so that a code compares equal across sources. Stop-words (`Tier 3`, `Route 7`, `50 MW`) never yield a code. | |
| 59 | + */ | |
| 43 | 60 | export function extractFacilityCodes(name: string): string[] { |
| 44 | 61 | const out = new Set<string>(); |
| 45 | − for (const m of name.toUpperCase().matchAll(/\b([A-Z]{2,4})\s?-?(\d{1,3}[A-Z]?)\b/g)) out.add(`${m[1]}${m[2]}`); | |
| 62 | + const up = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toUpperCase(); | |
| 63 | + const add = (prefix: string, digits: string, suffix: string, rest: string) => { | |
| 64 | + if (FACILITY_CODE_STOPWORDS.has(prefix) || UNIT_AFTER_RE.test(rest)) return; | |
| 65 | + const n = digits.replace(/^0+(?=\d)/, ""); | |
| 66 | + out.add(`${prefix}${n}${suffix}`); | |
| 67 | + }; | |
| 68 | + // attached: DC12, FRA01A, LONDON6, S3, MEL01D | |
| 69 | + for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{1,10})(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length)); | |
| 70 | + // separated by a space or a hyphen: DC-12, SC-2, Inzai 3, Paris 5, Las Vegas 8, London 4 | |
| 71 | + for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{2,10})[\s-]+(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length)); | |
| 46 | 72 | return [...out]; |
| 47 | 73 | } |
| 48 | 74 | |
| 75 | +/** Alphabetic prefix of a facility code (`LONDON6` → `LONDON`). */ | |
| 76 | +export function facilityCodePrefix(code: string): string { | |
| 77 | + return code.replace(/\d.*$/, ""); | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * Compare two code sets: `1` when a code is shared and no shared prefix disagrees, `0` when a prefix present on both | |
| 82 | + * sides carries different numbers (`LONDON6` vs `LONDON5`, `Inzai 3` vs `Inzai 4`), `null` when either side has no | |
| 83 | + * code or the prefixes do not overlap (`SYD1` vs `MEL1` says nothing — different prefixes are different naming schemes). | |
| 84 | + */ | |
| 85 | +export function compareFacilityCodes(a: string[], b: string[]): 0 | 1 | null { | |
| 86 | + if (!a.length || !b.length) return null; | |
| 87 | + const byPrefix = (codes: string[]) => { | |
| 88 | + const m = new Map<string, Set<string>>(); | |
| 89 | + for (const c of codes) { const p = facilityCodePrefix(c); if (!m.has(p)) m.set(p, new Set()); m.get(p)!.add(c); } | |
| 90 | + return m; | |
| 91 | + }; | |
| 92 | + const pa = byPrefix(a), pb = byPrefix(b); | |
| 93 | + // `SY5` and `SY5B` agree (a hall of the same building); `SY5A` vs `SY5B` do not | |
| 94 | + const base = (c: string) => c.replace(/(\d)[A-Z]$/, "$1"); | |
| 95 | + const agree = (x: string, y: string) => x === y || (base(x) === base(y) && (x === base(x) || y === base(y))); | |
| 96 | + let shared = false, conflict = false; | |
| 97 | + for (const [prefix, ca] of pa) { | |
| 98 | + const cb = pb.get(prefix); | |
| 99 | + if (!cb) continue; | |
| 100 | + if ([...ca].some((c) => [...cb].some((d) => agree(c, d)))) shared = true; | |
| 101 | + else conflict = true; | |
| 102 | + } | |
| 103 | + if (conflict) return 0; | |
| 104 | + return shared ? 1 : null; | |
| 105 | +} | |
| 106 | + | |
| 49 | 107 | export function tokenSet(s: string): Set<string> { |
| 50 | 108 | return new Set(normalizeName(s).split(" ").filter(Boolean)); |
| 51 | 109 | } |
@@ -61,7 +119,8 @@ export function jaccard(a: Set<string>, b: Set<string>): number { | ||
| 61 | 119 | export function parseMw(text: string | null | undefined): number | null { |
| 62 | 120 | if (!text) return null; |
| 63 | 121 | const s = text.replace(/ /g, " "); |
| 64 | − const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i); | |
| 122 | + // "40+MW", "150+ MW" (at least) parse as the stated figure | |
| 123 | + const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i); | |
| 65 | 124 | if (!m) return null; |
| 66 | 125 | let num = m[1]!; |
| 67 | 126 | // "36,000" → 36000 ; "1,2" (fr decimal) → 1.2 ; "1.2" → 1.2 |
@@ -79,7 +138,7 @@ export function parseMw(text: string | null | undefined): number | null { | ||
| 79 | 138 | /** All MW figures in a text, deduplicated, descending. */ |
| 80 | 139 | export function parseAllMw(text: string): number[] { |
| 81 | 140 | const out = new Set<number>(); |
| 82 | − for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) { | |
| 141 | + for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) { | |
| 83 | 142 | const v = parseMw(m[0]); |
| 84 | 143 | if (v != null && v > 0 && v < 100_000) out.add(v); |
| 85 | 144 | } |
@@ -193,13 +252,13 @@ const STATUS_PATTERNS: Array<[RegExp, FacilityStatus]> = [ | ||
| 193 | 252 | [/\b(closed|decommissioned|shut\s?down|ceased operations)\b/i, "closed"], |
| 194 | 253 | [/\b(delayed|postponed|on hold|paused)\b/i, "delayed"], |
| 195 | 254 | [/\b(partially operational|phase\s*1\s*(is\s*)?(live|operational|complete))\b/i, "partially_operational"], |
| 196 | − [/\b(under construction|being built|construction (is )?under ?way|breaking ground|broke ground|groundbreaking|topped out|construction (has )?(started|began|commenced))\b/i, "under_construction"], | |
| 255 | + [/\b(under construction|being built|construction (is )?under ?way|breaks? ground|breaking ground|broke ground|groundbreaking|topped out|topping out|construction (has )?(started|began|begins|commenced|starts))\b/i, "under_construction"], | |
| 197 | 256 | [/\b(approved|approval granted|granted planning|received approval|rezoning approved|permit(s)? (were |was )?(granted|issued))\b/i, "approved"], |
| 198 | 257 | [/\b(planning application|permit application|rezoning (request|application)|seeking approval|applied for|filed plans|submitted plans|under review by)\b/i, "permitting"], |
| 199 | − [/\b(announced|unveils?|unveiled|plans to build|will build|to build|to develop|reveals? plans|launch(es|ed)? plans)\b/i, "announced"], | |
| 258 | + [/\b(announced|announces|unveils?|unveiled|plans to build|plans? for|will build|to build|build of|to develop|to construct|set to build|reveals? plans|launch(es|ed)? plans|confirms? (the )?(build|plans?|development)|enters? [A-Z]\w+ with|doubles down)\b/i, "announced"], | |
| 200 | 259 | [/\b(proposed|proposal)\b/i, "proposed"], |
| 201 | 260 | [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks)\b/i, "rumored"], |
| 202 | − [/\b(expansion|expanding|expands|adds? capacity|new phase|additional \d+ ?MW)\b/i, "expansion"], | |
| 261 | + [/\b(expansion|expanding|expands|adds? capacity|adds? \d+ ?\+?(MW|GW)|new phase|additional \d+ ?\+?MW)\b/i, "expansion"], | |
| 203 | 262 | [/\b(now open|opened|opens|operational|live|online|commissioned|inaugurat(ed|es|ion)|ready for service|RFS)\b/i, "operational"], |
| 204 | 263 | ]; |
| 205 | 264 | |
added
scripts/quality-dedupe.ts
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +/** | |
| 2 | + * Likely-duplicate facilities still stored as separate records, scored with the production matcher. | |
| 3 | + * | |
| 4 | + * set -a; source .env; set +a | |
| 5 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-dedupe.ts # report only | |
| 6 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-dedupe.ts --apply # merge pairs scoring ≥ 0.92 (mergeFacilities) | |
| 7 | + * … --min 0.6 --limit 200 # report threshold / max rows printed | |
| 8 | + * | |
| 9 | + * Candidate pairs: same operator + same country with name similarity ≥ 0.85, or any two live facilities less than | |
| 10 | + * 300 m apart. Each pair is scored in both directions with `scoreFacilityMatch`; the best score decides. | |
| 11 | + * `--apply` folds the younger / less-sourced record into the older one for scores ≥ AUTO_MERGE_THRESHOLD (0.92), | |
| 12 | + * writing an `entity_matches` row (status approved, decided_by quality-dedupe) for audit. Nothing below 0.92 is touched. | |
| 13 | + */ | |
| 14 | +import { closeDb, getDb, sql, textArray } from "@dci/db"; | |
| 15 | +import { haversineKm, newId } from "@dci/core"; | |
| 16 | +import { AUTO_MERGE_THRESHOLD, decide, nameSimilarity, scoreFacilityMatch, type FacilityCandidate } from "../apps/worker/src/ingest/match.js"; | |
| 17 | +import { mergeFacilities } from "../apps/worker/src/ingest/facilities.js"; | |
| 18 | + | |
| 19 | +const args = process.argv.slice(2); | |
| 20 | +const APPLY = args.includes("--apply"); | |
| 21 | +const num = (flag: string, def: number) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] ? Number(args[i + 1]) : def; }; | |
| 22 | +const MIN_REPORT = num("--min", 0.6); | |
| 23 | +const LIMIT = num("--limit", 400); | |
| 24 | + | |
| 25 | +interface Row extends FacilityCandidate { sourceCount: number; createdAt: string; confidence: string } | |
| 26 | + | |
| 27 | +async function loadFacilities(): Promise<Row[]> { | |
| 28 | + const rows = await getDb().execute(sql` | |
| 29 | + select f.id, f.name, f.normalized_name, f.operator_id, o.name as operator_name, f.country_iso2, f.city, f.address, f.lat, f.lng, f.geo_precision, f.external_ids, | |
| 30 | + f.source_count, f.created_at, f.confidence, | |
| 31 | + (select coalesce(array_agg(alias), '{}'::text[]) from facility_aliases a where a.facility_id = f.id) as aliases | |
| 32 | + from facilities f left join operators o on o.id = f.operator_id where f.merged_into is null`); | |
| 33 | + return rows.map((r) => ({ | |
| 34 | + id: String(r.id), name: String(r.name), normalizedName: String(r.normalized_name), aliases: Array.isArray(r.aliases) ? (r.aliases as string[]) : [], | |
| 35 | + operatorId: r.operator_id == null ? null : String(r.operator_id), operatorName: r.operator_name == null ? null : String(r.operator_name), | |
| 36 | + countryIso2: r.country_iso2 == null ? null : String(r.country_iso2), city: r.city == null ? null : String(r.city), address: r.address == null ? null : String(r.address), | |
| 37 | + lat: r.lat == null ? null : Number(r.lat), lng: r.lng == null ? null : Number(r.lng), geoPrecision: r.geo_precision == null ? null : String(r.geo_precision), | |
| 38 | + externalIds: (r.external_ids as Record<string, string | number>) ?? null, sourceCount: Number(r.source_count ?? 0), createdAt: String(r.created_at), confidence: String(r.confidence), | |
| 39 | + })); | |
| 40 | +} | |
| 41 | + | |
| 42 | +function probeOf(f: Row) { | |
| 43 | + return { name: f.name, aliases: f.aliases, operatorId: f.operatorId, operatorName: f.operatorName, countryIso2: f.countryIso2, city: f.city, address: f.address, lat: f.lat, lng: f.lng, geoPrecision: (f.geoPrecision ?? null) as never }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +async function main(): Promise<void> { | |
| 47 | + const all = await loadFacilities(); | |
| 48 | + console.log(`live facilities: ${all.length}`); | |
| 49 | + const pairs = new Map<string, [Row, Row, string]>(); | |
| 50 | + const key = (a: Row, b: Row) => (a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`); | |
| 51 | + | |
| 52 | + // (a) same operator + same country, name similarity ≥ 0.85 | |
| 53 | + const byOpCountry = new Map<string, Row[]>(); | |
| 54 | + for (const f of all) if (f.operatorId && f.countryIso2) { const k = `${f.operatorId}|${f.countryIso2}`; (byOpCountry.get(k) ?? byOpCountry.set(k, []).get(k)!).push(f); } | |
| 55 | + for (const group of byOpCountry.values()) { | |
| 56 | + if (group.length < 2 || group.length > 600) continue; | |
| 57 | + for (let i = 0; i < group.length; i++) for (let j = i + 1; j < group.length; j++) { | |
| 58 | + const a = group[i]!, b = group[j]!; | |
| 59 | + const n = nameSimilarity(probeOf(a), b); | |
| 60 | + if (n.score >= 0.85 && !n.siblings) pairs.set(key(a, b), [a, b, `name:${n.score.toFixed(2)}`]); | |
| 61 | + } | |
| 62 | + } | |
| 63 | + // (b) any two facilities < 300 m apart (bucketed by ~1 km cells) | |
| 64 | + const cells = new Map<string, Row[]>(); | |
| 65 | + const cell = (lat: number, lng: number) => `${Math.floor(lat * 100)}|${Math.floor(lng * 100)}`; | |
| 66 | + for (const f of all) if (f.lat != null && f.lng != null) { const k = cell(f.lat, f.lng); (cells.get(k) ?? cells.set(k, []).get(k)!).push(f); } | |
| 67 | + for (const f of all) { | |
| 68 | + if (f.lat == null || f.lng == null) continue; | |
| 69 | + for (const dl of [-1, 0, 1]) for (const dg of [-1, 0, 1]) { | |
| 70 | + const bucket = cells.get(`${Math.floor(f.lat * 100) + dl}|${Math.floor(f.lng * 100) + dg}`); | |
| 71 | + if (!bucket) continue; | |
| 72 | + for (const g of bucket) { | |
| 73 | + if (g.id <= f.id) continue; | |
| 74 | + const d = haversineKm(f.lat, f.lng, g.lat!, g.lng!); | |
| 75 | + if (d < 0.3 && !pairs.has(key(f, g))) pairs.set(key(f, g), [f, g, `distance:${(d * 1000).toFixed(0)}m`]); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + console.log(`candidate pairs: ${pairs.size}`); | |
| 80 | + | |
| 81 | + const scored: Array<{ a: Row; b: Row; why: string; score: number; reasons: string[] }> = []; | |
| 82 | + for (const [a, b, why] of pairs.values()) { | |
| 83 | + const m1 = scoreFacilityMatch(probeOf(a), b); | |
| 84 | + const m2 = scoreFacilityMatch(probeOf(b), a); | |
| 85 | + const m = m1.score >= m2.score ? m1 : m2; | |
| 86 | + if (m.score >= MIN_REPORT) scored.push({ a, b, why, score: m.score, reasons: m.reasons }); | |
| 87 | + } | |
| 88 | + scored.sort((x, y) => y.score - x.score); | |
| 89 | + const mergeable = scored.filter((s) => s.score >= AUTO_MERGE_THRESHOLD); | |
| 90 | + const pending = scored.filter((s) => s.score < AUTO_MERGE_THRESHOLD); | |
| 91 | + console.log(`likely duplicates (score ≥ ${MIN_REPORT}): ${scored.length} — auto-mergeable (≥ ${AUTO_MERGE_THRESHOLD}): ${mergeable.length}, review (${MIN_REPORT}–${AUTO_MERGE_THRESHOLD}): ${pending.length}\n`); | |
| 92 | + for (const s of scored.slice(0, LIMIT)) console.log(`${s.score.toFixed(3)} ${decide(s.score).padEnd(7)} | ${s.a.name} [${s.a.operatorName ?? "-"}, ${s.a.countryIso2 ?? "-"}] ⇄ ${s.b.name} [${s.b.operatorName ?? "-"}, ${s.b.countryIso2 ?? "-"}] | ${s.why} | ${s.reasons.join(",")}`); | |
| 93 | + | |
| 94 | + if (!APPLY) { console.log(`\n(dry run — pass --apply to merge the ${mergeable.length} pairs scoring ≥ ${AUTO_MERGE_THRESHOLD})`); return; } | |
| 95 | + const gone = new Set<string>(); | |
| 96 | + let merged = 0; | |
| 97 | + for (const s of mergeable) { | |
| 98 | + if (gone.has(s.a.id) || gone.has(s.b.id)) continue; | |
| 99 | + // survivor: more sources, then better confidence, then older | |
| 100 | + const rank = (f: Row) => [f.sourceCount, ["verified", "high", "moderate", "estimated", "unverified"].indexOf(f.confidence) * -1, -Date.parse(f.createdAt)] as const; | |
| 101 | + const [into, from] = rank(s.a) >= rank(s.b) ? [s.a, s.b] : [s.b, s.a]; | |
| 102 | + await getDb().transaction(async (tx) => { | |
| 103 | + await tx.execute(sql`insert into entity_matches (id, connector_id, candidate_key, candidate, matched_facility_id, score, reasons, status, decided_by, decided_at) | |
| 104 | + values (${newId("match")}, 'quality-dedupe', ${`quality-dedupe:${from.id}`}, ${JSON.stringify({ createdFacilityId: from.id, name: from.name, operatorName: from.operatorName, countryIso2: from.countryIso2, why: s.why })}::jsonb, ${into.id}, ${s.score}, ${textArray(s.reasons)}::text[], 'approved', 'quality-dedupe', now())`); | |
| 105 | + await mergeFacilities(tx, from.id, into.id, "quality-dedupe"); | |
| 106 | + }); | |
| 107 | + gone.add(from.id); | |
| 108 | + merged++; | |
| 109 | + console.log(`merged ${from.name} (${from.id}) → ${into.name} (${into.id}) @ ${s.score}`); | |
| 110 | + } | |
| 111 | + console.log(`\nmerged ${merged} facilities`); | |
| 112 | +} | |
| 113 | + | |
| 114 | +main().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => closeDb()); | |
added
scripts/quality-facilities.ts
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +/** | |
| 2 | + * Facility sanity report: implausible values and inconsistencies, grouped by the connector that produced them. | |
| 3 | + * | |
| 4 | + * set -a; source .env; set +a | |
| 5 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-facilities.ts # report | |
| 6 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-facilities.ts --apply # apply the safe repairs (see below) | |
| 7 | + * | |
| 8 | + * Checks: single-facility MW > 1 000 without a campus designation · building_sqm > 500 000 · opened_on < 1980 from a | |
| 9 | + * non-Wikidata source · coordinates outside the stated country (Natural Earth polygons, `countryFromPoint`) · | |
| 10 | + * names that are page / article titles or another kind of business · confidence inconsistent with the source mix. | |
| 11 | + * `--apply` only performs repairs whose provenance is kept: drops pre-1980 OSM opening dates from the column, drops | |
| 12 | + * coordinates that fall in another country (geo_precision → unknown), and recomputes confidence/completeness. | |
| 13 | + */ | |
| 14 | +import { closeDb, getDb, sql } from "@dci/db"; | |
| 15 | +import { TITLE_LIKE_NAME_RE, NOT_A_FACILITY_NAME_RE } from "@dci/connectors"; | |
| 16 | +import { countryFromPoint } from "../apps/worker/src/ingest/country-lookup.js"; | |
| 17 | +import { BORDER_TOLERANT, refreshDerived } from "../apps/worker/src/ingest/facilities.js"; | |
| 18 | + | |
| 19 | +const APPLY = process.argv.includes("--apply"); | |
| 20 | + | |
| 21 | +interface F { id: string; name: string; connector: string; kind: string | null; country: string | null; lat: number | null; lng: number | null; precision: string | null; it: number | null; total: number | null; planned: number | null; sqm: number | null; opened: string | null; campus: string | null; confidence: string; sourceCount: number } | |
| 22 | + | |
| 23 | +async function main(): Promise<void> { | |
| 24 | + const db = getDb(); | |
| 25 | + const rows = await db.execute(sql` | |
| 26 | + select f.id, f.name, f.country_iso2, f.lat, f.lng, f.geo_precision, f.it_capacity_mw, f.total_power_mw, f.planned_power_mw, f.building_sqm, f.opened_on, f.confidence, f.source_count, c.name as campus, | |
| 27 | + (select k.connector_id from entity_keys k where k.entity_id = f.id and k.entity_type = 'facility' order by k.created_at limit 1) as connector, | |
| 28 | + (select string_agg(distinct s.kind::text, ',') from provenance p join sources s on s.id = p.source_id where p.entity_type = 'facility' and p.entity_id = f.id and p.is_current) as kinds | |
| 29 | + from facilities f left join campuses c on c.id = f.campus_id where f.merged_into is null`); | |
| 30 | + const fs: F[] = rows.map((r) => ({ id: String(r.id), name: String(r.name), connector: String(r.connector ?? "-"), kind: r.kinds == null ? null : String(r.kinds), country: r.country_iso2 == null ? null : String(r.country_iso2), lat: r.lat == null ? null : Number(r.lat), lng: r.lng == null ? null : Number(r.lng), precision: r.geo_precision == null ? null : String(r.geo_precision), it: r.it_capacity_mw == null ? null : Number(r.it_capacity_mw), total: r.total_power_mw == null ? null : Number(r.total_power_mw), planned: r.planned_power_mw == null ? null : Number(r.planned_power_mw), sqm: r.building_sqm == null ? null : Number(r.building_sqm), opened: r.opened_on == null ? null : String(r.opened_on), campus: r.campus == null ? null : String(r.campus), confidence: String(r.confidence), sourceCount: Number(r.source_count ?? 0) })); | |
| 31 | + console.log(`live facilities: ${fs.length}`); | |
| 32 | + | |
| 33 | + const groups = new Map<string, Array<{ f: F; detail: string }>>(); | |
| 34 | + const flag = (rule: string, f: F, detail: string) => (groups.get(rule) ?? groups.set(rule, []).get(rule)!).push({ f, detail }); | |
| 35 | + const geoDrops: F[] = []; | |
| 36 | + const openedDrops: F[] = []; | |
| 37 | + for (const f of fs) { | |
| 38 | + const isCampus = /\b(campus|park|cluster|hub|complex)\b/i.test(`${f.name} ${f.campus ?? ""}`); | |
| 39 | + for (const [k, v] of [["it_capacity_mw", f.it], ["total_power_mw", f.total]] as Array<[string, number | null]>) if (v != null && v > 1000 && !isCampus) flag("mw>1000 single facility", f, `${k}=${v}`); | |
| 40 | + if (f.planned != null && f.planned > 5000 && !isCampus) flag("planned>5000 single facility", f, `planned=${f.planned}`); | |
| 41 | + if (f.sqm != null && f.sqm > 500_000) flag("building_sqm>500000", f, `sqm=${f.sqm}`); | |
| 42 | + if (f.opened && Number(f.opened.slice(0, 4)) < 1980 && f.connector !== "wikidata") { flag("opened_on<1980 non-wikidata", f, `opened=${f.opened}`); openedDrops.push(f); } | |
| 43 | + if (f.lat != null && f.lng != null && f.country && f.precision !== "metro") { | |
| 44 | + const pc = countryFromPoint(f.lat, f.lng); | |
| 45 | + if (pc && pc !== f.country && !BORDER_TOLERANT.has(`${f.country}:${pc}`)) { flag("coordinates outside stated country", f, `stated=${f.country} point=${pc} (${f.lat},${f.lng}) ${f.precision}`); geoDrops.push(f); } | |
| 46 | + } | |
| 47 | + if (TITLE_LIKE_NAME_RE.test(f.name)) flag("name looks like a page title", f, ""); | |
| 48 | + if (NOT_A_FACILITY_NAME_RE.test(f.name)) flag("name describes another business", f, ""); | |
| 49 | + const kinds = new Set((f.kind ?? "").split(",").filter(Boolean)); | |
| 50 | + const authoritative = ["operator", "government", "filing", "utility", "cloud_provider"].some((k) => kinds.has(k)); | |
| 51 | + if ((f.confidence === "high" || f.confidence === "verified") && !authoritative) flag("confidence high without an authoritative source", f, `kinds=${f.kind}`); | |
| 52 | + if (f.sourceCount <= 1 && ["community", "secondary", "news"].some((k) => kinds.has(k)) && kinds.size === 1 && f.confidence !== "unverified" && f.confidence !== "estimated") flag("single community source not unverified", f, `confidence=${f.confidence}`); | |
| 53 | + } | |
| 54 | + for (const [rule, items] of [...groups].sort((a, b) => b[1].length - a[1].length)) { | |
| 55 | + const byConnector = new Map<string, number>(); | |
| 56 | + for (const { f } of items) byConnector.set(f.connector, (byConnector.get(f.connector) ?? 0) + 1); | |
| 57 | + console.log(`\n== ${rule}: ${items.length} (${[...byConnector].map(([c, n]) => `${c} ${n}`).join(", ")})`); | |
| 58 | + for (const { f, detail } of items.slice(0, 25)) console.log(` ${f.connector.padEnd(18)} ${f.name.slice(0, 60).padEnd(60)} ${detail}`); | |
| 59 | + if (items.length > 25) console.log(` … ${items.length - 25} more`); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (!APPLY) { console.log("\n(dry run — pass --apply to drop pre-1980 non-Wikidata opening dates and out-of-country coordinates, then recompute confidence)"); return; } | |
| 63 | + let n = 0; | |
| 64 | + for (const f of openedDrops) { await db.execute(sql`update facilities set opened_on = null, updated_at = now() where id = ${f.id}`); n++; } | |
| 65 | + for (const f of geoDrops) { await db.execute(sql`update facilities set lat = null, lng = null, geo_precision = 'unknown', geo_source = null, geohash = null, updated_at = now() where id = ${f.id}`); n++; } | |
| 66 | + console.log(`\nrepaired ${n} columns; recomputing confidence / completeness for ${fs.length} facilities…`); | |
| 67 | + let i = 0; | |
| 68 | + for (const f of fs) { await db.transaction(async (tx) => refreshDerived(tx, f.id)); if (++i % 500 === 0) console.log(` ${i}`); } | |
| 69 | + console.log("done"); | |
| 70 | +} | |
| 71 | + | |
| 72 | +main().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => closeDb()); | |
added
scripts/quality-news.ts
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * News relevance audit for third-party publishers (sources.kind = news): which stored items would the tightened | |
| 3 | + * `news_article_v1` gate still index today? An item is on-topic when its title + summary carry strong data-center | |
| 4 | + * vocabulary in the lead (`relevanceOf` = strong and `leadRelevanceOf`), off-topic otherwise (industry chatter, | |
| 5 | + * newsletters, job boards, chips, opinion). Also checks the ≤ 400-character summary rule. | |
| 6 | + * | |
| 7 | + * set -a; source .env; set +a | |
| 8 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-news.ts # report (share by connector + sample) | |
| 9 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-news.ts --apply # delete off-topic kind=news items (+ their events), trim long summaries | |
| 10 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-news.ts --sample 60 # random sample for hand classification | |
| 11 | + */ | |
| 12 | +import { closeDb, getDb, sql } from "@dci/db"; | |
| 13 | +import { leadRelevanceOf, relevanceOf, isNonProjectTitle } from "../apps/worker/src/connectors/news/extract-project.js"; | |
| 14 | +import { shortSummary, THIRD_PARTY_SUMMARY_MAX } from "../apps/worker/src/ingest/news.js"; | |
| 15 | + | |
| 16 | +const args = process.argv.slice(2); | |
| 17 | +const APPLY = args.includes("--apply"); | |
| 18 | +const SAMPLE = args.includes("--sample") ? Number(args[args.indexOf("--sample") + 1] ?? 60) : 0; | |
| 19 | + | |
| 20 | +async function main(): Promise<void> { | |
| 21 | + const db = getDb(); | |
| 22 | + const rows = await db.execute(sql`select n.id, n.connector_id, n.title, n.summary, n.event_type, n.page_type, s.kind from news_items n join sources s on s.id = n.source_id order by n.created_at`); | |
| 23 | + const items = rows.map((r) => ({ id: String(r.id), connector: String(r.connector_id), title: String(r.title), summary: r.summary == null ? null : String(r.summary), eventType: r.event_type == null ? null : String(r.event_type), kind: String(r.kind) })); | |
| 24 | + const third = items.filter((i) => i.kind === "news"); | |
| 25 | + if (SAMPLE) { | |
| 26 | + for (const i of [...third].sort(() => Math.random() - 0.5).slice(0, SAMPLE)) console.log(`${i.connector} | ${i.eventType ?? "-"} | ${i.title.slice(0, 110)} | ${(i.summary ?? "").slice(0, 120)}`); | |
| 27 | + return; | |
| 28 | + } | |
| 29 | + const judge = (i: { title: string; summary: string | null }) => { | |
| 30 | + const text = i.summary ?? ""; | |
| 31 | + const rel = relevanceOf(i.title, text); | |
| 32 | + if (rel !== "strong") return `off-topic:${rel}`; | |
| 33 | + if (!leadRelevanceOf(i.title, text)) return "off-topic:body-only"; | |
| 34 | + return isNonProjectTitle(i.title) ? "on-topic:non-project" : "on-topic"; | |
| 35 | + }; | |
| 36 | + const perConnector = new Map<string, { total: number; off: number }>(); | |
| 37 | + const off: typeof third = []; | |
| 38 | + const longSummaries = third.filter((i) => (i.summary?.length ?? 0) > THIRD_PARTY_SUMMARY_MAX); | |
| 39 | + for (const i of third) { | |
| 40 | + const v = judge(i); | |
| 41 | + const s = perConnector.get(i.connector) ?? { total: 0, off: 0 }; | |
| 42 | + s.total++; | |
| 43 | + if (v.startsWith("off-topic")) { s.off++; off.push(i); } | |
| 44 | + perConnector.set(i.connector, s); | |
| 45 | + } | |
| 46 | + console.log(`third-party (kind=news) items: ${third.length} — off-topic by the current gate: ${off.length} (${((off.length / Math.max(1, third.length)) * 100).toFixed(0)} %) — summaries > ${THIRD_PARTY_SUMMARY_MAX} chars: ${longSummaries.length}`); | |
| 47 | + for (const [c, s] of [...perConnector].sort((a, b) => b[1].off - a[1].off)) console.log(` ${c.padEnd(24)} ${s.off}/${s.total}`); | |
| 48 | + console.log("\noff-topic sample:"); | |
| 49 | + for (const i of off.slice(0, 40)) console.log(` ${i.connector.padEnd(22)} ${i.title.slice(0, 100)}`); | |
| 50 | + // operator newsrooms are kept whatever the topic (the source's own news), but report how many carry a non-project headline with a wrong event type | |
| 51 | + const opWrong = items.filter((i) => i.kind !== "news" && i.eventType && !["news", "page_changed", "acquisition", "closure"].includes(i.eventType) && isNonProjectTitle(i.title)); | |
| 52 | + console.log(`\noperator / government items with a people/deal/money headline but a specific event type: ${opWrong.length} (event type is reset to 'news' on reprocess)`); | |
| 53 | + | |
| 54 | + if (!APPLY) { console.log("\n(dry run — pass --apply to delete off-topic third-party items and trim long summaries)"); return; } | |
| 55 | + let deleted = 0, trimmed = 0; | |
| 56 | + for (const i of off) { | |
| 57 | + await db.transaction(async (tx) => { | |
| 58 | + await tx.execute(sql`delete from events where entity_type = 'news_event' and entity_id = ${i.id}`); | |
| 59 | + await tx.execute(sql`delete from news_items where id = ${i.id}`); | |
| 60 | + }); | |
| 61 | + deleted++; | |
| 62 | + } | |
| 63 | + for (const i of longSummaries) { | |
| 64 | + if (off.includes(i)) continue; | |
| 65 | + await db.execute(sql`update news_items set summary = ${shortSummary(i.summary)} where id = ${i.id}`); | |
| 66 | + trimmed++; | |
| 67 | + } | |
| 68 | + console.log(`deleted ${deleted} off-topic items, trimmed ${trimmed} summaries`); | |
| 69 | +} | |
| 70 | + | |
| 71 | +main().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => closeDb()); | |
added
scripts/quality-projects.ts
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +/** | |
| 2 | + * Project-pipeline precision audit. Every project is re-judged from the headline / summary of the article it came | |
| 3 | + * from, with the same gates the extractors now apply: | |
| 4 | + * | |
| 5 | + * veto:title people / awards / partnerships / financing / market research / policy / opinion headline | |
| 6 | + * existing-facility opening, "we are online", delivered — the article is about an operating facility | |
| 7 | + * fallback:no-signal created by the GenericConnector fallback (key …:project) without a MW figure AND a pipeline | |
| 8 | + * status in the headline (the new fallback gate) | |
| 9 | + * news-kind:fallback fallback project from a third-party publisher (the fallback no longer creates those) | |
| 10 | + * keep passes every gate | |
| 11 | + * | |
| 12 | + * set -a; source .env; set +a | |
| 13 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-projects.ts # report | |
| 14 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-projects.ts --apply # delete flagged projects (keys, provenance, timeline, events) | |
| 15 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-projects.ts --sample 60 # print a random sample for hand classification | |
| 16 | + */ | |
| 17 | +import { closeDb, getDb, sql } from "@dci/db"; | |
| 18 | +import { fallbackProjectGate } from "@dci/connectors"; | |
| 19 | +import { isNonProjectTitle, OPENING_TITLE_RE, STATUS_RULES } from "../apps/worker/src/connectors/news/extract-project.js"; | |
| 20 | + | |
| 21 | +const args = process.argv.slice(2); | |
| 22 | +const APPLY = args.includes("--apply"); | |
| 23 | +const SAMPLE = args.includes("--sample") ? Number(args[args.indexOf("--sample") + 1] ?? 60) : 0; | |
| 24 | +const FUTURE_OPEN_RE = /\b(to|will|set to|plans? to|due to|expected to|slated to) (open|be online|go live)\b|\b(open|opening|online|live) (in|by) (Q[1-4]|20\d\d|early|late|mid)\b/i; | |
| 25 | + | |
| 26 | +interface P { id: string; key: string | null; connector: string | null; kind: string | null; name: string; status: string; mw: number | null; title: string | null; summary: string | null } | |
| 27 | + | |
| 28 | +function verdict(p: P): string { | |
| 29 | + const title = p.title ?? p.name; | |
| 30 | + if (isNonProjectTitle(title)) return "veto:title"; | |
| 31 | + if (OPENING_TITLE_RE.test(title) && !FUTURE_OPEN_RE.test(title) && !STATUS_RULES.slice(0, 3).some(([re]) => re.test(title))) return "existing-facility"; | |
| 32 | + if (p.key?.endsWith(":project")) { | |
| 33 | + if (p.kind === "news") return "news-kind:fallback"; | |
| 34 | + if (!fallbackProjectGate(title, p.summary)) return "fallback:no-signal"; | |
| 35 | + } | |
| 36 | + return "keep"; | |
| 37 | +} | |
| 38 | + | |
| 39 | +async function main(): Promise<void> { | |
| 40 | + const db = getDb(); | |
| 41 | + const rows = await db.execute(sql` | |
| 42 | + select p.id, p.name, p.status, p.planned_mw, k.key, k.connector_id, s.kind, n.title, n.summary | |
| 43 | + from projects p | |
| 44 | + left join entity_keys k on k.entity_id = p.id and k.entity_type = 'project' | |
| 45 | + left join sources s on s.connector_id = k.connector_id | |
| 46 | + left join news_items n on n.url = p.source_url | |
| 47 | + order by p.created_at`); | |
| 48 | + const projects: P[] = rows.map((r) => ({ id: String(r.id), key: r.key == null ? null : String(r.key), connector: r.connector_id == null ? null : String(r.connector_id), kind: r.kind == null ? null : String(r.kind), name: String(r.name), status: String(r.status), mw: r.planned_mw == null ? null : Number(r.planned_mw), title: r.title == null ? null : String(r.title), summary: r.summary == null ? null : String(r.summary) })); | |
| 49 | + | |
| 50 | + if (SAMPLE) { | |
| 51 | + const shuffled = [...projects].sort(() => Math.random() - 0.5).slice(0, SAMPLE); | |
| 52 | + for (const p of shuffled) console.log(`${p.id} | ${p.connector} | ${p.status} | ${p.mw ?? "-"} MW | ${p.name} | ${p.title ?? "-"} | ${(p.summary ?? "").slice(0, 140)}`); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const counts = new Map<string, number>(); | |
| 57 | + const flagged: Array<P & { verdict: string }> = []; | |
| 58 | + for (const p of projects) { | |
| 59 | + const v = verdict(p); | |
| 60 | + counts.set(v, (counts.get(v) ?? 0) + 1); | |
| 61 | + if (v !== "keep") flagged.push({ ...p, verdict: v }); | |
| 62 | + } | |
| 63 | + console.log(`projects: ${projects.length}`); | |
| 64 | + for (const [v, n] of [...counts].sort((a, b) => b[1] - a[1])) console.log(` ${v.padEnd(20)} ${n}`); | |
| 65 | + console.log("\nflagged:"); | |
| 66 | + for (const p of flagged) console.log(` ${p.verdict.padEnd(20)} ${p.connector ?? "-"} | ${p.status} | ${p.mw ?? "-"} MW | ${p.name.slice(0, 70)} | ${(p.title ?? "").slice(0, 90)}`); | |
| 67 | + const kept = projects.length - flagged.length; | |
| 68 | + console.log(`\nprecision proxy: kept ${kept}/${projects.length} (${((kept / Math.max(1, projects.length)) * 100).toFixed(0)} % of stored projects pass the headline gates)`); | |
| 69 | + | |
| 70 | + if (!APPLY) { console.log("(dry run — pass --apply to delete the flagged projects)"); return; } | |
| 71 | + let deleted = 0; | |
| 72 | + for (const p of flagged) { | |
| 73 | + await db.transaction(async (tx) => { | |
| 74 | + await tx.execute(sql`update events set project_id = null where project_id = ${p.id}`); | |
| 75 | + await tx.execute(sql`delete from events where entity_type = 'project' and entity_id = ${p.id}`); | |
| 76 | + await tx.execute(sql`update news_items set project_id = null where project_id = ${p.id}`); | |
| 77 | + await tx.execute(sql`delete from provenance where entity_type = 'project' and entity_id = ${p.id}`); | |
| 78 | + await tx.execute(sql`delete from entity_keys where entity_type = 'project' and entity_id = ${p.id}`); | |
| 79 | + await tx.execute(sql`delete from projects where id = ${p.id}`); // project_timeline cascades | |
| 80 | + }); | |
| 81 | + deleted++; | |
| 82 | + } | |
| 83 | + console.log(`deleted ${deleted} projects`); | |
| 84 | +} | |
| 85 | + | |
| 86 | +main().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => closeDb()); | |
added
scripts/quality-unfold.ts
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +/** | |
| 2 | + * Repair facilities that swallowed several connector keys through a shared, non-identifying external id | |
| 3 | + * (`operator_wikidata`, `google_location`, `investment_currency`…): 121 AWS OpenStreetMap features folded into one | |
| 4 | + * "Amazon Web Services" row, 28 Meta sites into "Meta Cheyenne Data Center", Google's Texas sites into Wilbarger County. | |
| 5 | + * | |
| 6 | + * set -a; source .env; set +a | |
| 7 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-unfold.ts # report | |
| 8 | + * node node_modules/tsx/dist/cli.mjs scripts/quality-unfold.ts --apply # detach the extra keys | |
| 9 | + * then: pnpm dci run <connector> --task reprocess (re-extracts from archived bodies → the detached keys become facilities again) | |
| 10 | + * | |
| 11 | + * For each (facility, connector) holding > 1 key the script keeps the key that names the facility (the `osm` | |
| 12 | + * external id for OSM, the key slug closest to the current name otherwise), deletes the other keys, the connector's | |
| 13 | + * aliases (all renames from the fold) and the connector's provenance rows (restored by the reprocess), and clears the | |
| 14 | + * facility's external ids. Nothing else is touched; reprocessing recreates the detached facilities through the normal | |
| 15 | + * reconciliation path, which now only links on identifying external ids. | |
| 16 | + */ | |
| 17 | +import { closeDb, getDb, sql } from "@dci/db"; | |
| 18 | +import { normalizeName } from "@dci/core"; | |
| 19 | +import { refreshDerived } from "../apps/worker/src/ingest/facilities.js"; | |
| 20 | + | |
| 21 | +const APPLY = process.argv.includes("--apply"); | |
| 22 | +const ONLY = process.argv.includes("--connector") ? process.argv[process.argv.indexOf("--connector") + 1] : null; | |
| 23 | + | |
| 24 | +async function main(): Promise<void> { | |
| 25 | + const db = getDb(); | |
| 26 | + const rows = await db.execute(sql` | |
| 27 | + select f.id, f.name, f.external_ids, k.connector_id, (select s.id from sources s where s.connector_id = k.connector_id limit 1) as source_id, array_agg(k.key order by k.key) as keys | |
| 28 | + from entity_keys k join facilities f on f.id = k.entity_id | |
| 29 | + where k.entity_type = 'facility' and f.merged_into is null ${ONLY ? sql`and k.connector_id = ${ONLY}` : sql``} | |
| 30 | + group by f.id, f.name, f.external_ids, k.connector_id having count(*) > 1 | |
| 31 | + order by count(*) desc`); | |
| 32 | + console.log(`facilities holding several keys of one connector: ${rows.length}`); | |
| 33 | + const perConnector = new Map<string, { facilities: number; extraKeys: number }>(); | |
| 34 | + let detached = 0; | |
| 35 | + for (const r of rows) { | |
| 36 | + const id = String(r.id), name = String(r.name), connector = String(r.connector_id); | |
| 37 | + const keys = r.keys as string[]; | |
| 38 | + const ext = (r.external_ids as Record<string, string | number>) ?? {}; | |
| 39 | + // the key that names this facility | |
| 40 | + let keep: string | undefined; | |
| 41 | + if (ext.osm) keep = keys.find((k) => k === `osm:${ext.osm}`); | |
| 42 | + if (!keep) { | |
| 43 | + const nameTokens = new Set(normalizeName(name).split(" ").filter(Boolean)); | |
| 44 | + let best = -1; | |
| 45 | + for (const k of keys) { | |
| 46 | + const slug = k.split(":").pop() ?? ""; | |
| 47 | + const toks = slug.split("-").filter(Boolean); | |
| 48 | + const overlap = toks.filter((t) => nameTokens.has(t)).length; | |
| 49 | + if (overlap > best) { best = overlap; keep = k; } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + keep ??= keys[0]!; | |
| 53 | + const extra = keys.filter((k) => k !== keep); | |
| 54 | + const stat = perConnector.get(connector) ?? { facilities: 0, extraKeys: 0 }; | |
| 55 | + stat.facilities++; stat.extraKeys += extra.length; perConnector.set(connector, stat); | |
| 56 | + console.log(`${connector.padEnd(18)} ${name} — ${keys.length} keys, keep ${keep}, detach ${extra.length}`); | |
| 57 | + if (!APPLY) continue; | |
| 58 | + await db.transaction(async (tx) => { | |
| 59 | + await tx.execute(sql`delete from entity_keys where entity_type = 'facility' and entity_id = ${id} and connector_id = ${connector} and key <> ${keep}`); | |
| 60 | + if (r.source_id != null) await tx.execute(sql`delete from facility_aliases where facility_id = ${id} and source_id = ${String(r.source_id)}`); | |
| 61 | + await tx.execute(sql`delete from provenance where entity_type = 'facility' and entity_id = ${id} and connector_id = ${connector}`); | |
| 62 | + await tx.execute(sql`update facilities set external_ids = '{}'::jsonb, updated_at = now() where id = ${id}`); | |
| 63 | + await refreshDerived(tx, id); | |
| 64 | + }); | |
| 65 | + detached += extra.length; | |
| 66 | + } | |
| 67 | + console.log("\nper connector:"); | |
| 68 | + for (const [c, s] of perConnector) console.log(` ${c.padEnd(18)} facilities ${s.facilities}, keys to detach ${s.extraKeys}`); | |
| 69 | + console.log(APPLY ? `\ndetached ${detached} keys — now run: pnpm dci run <connector> --task reprocess for ${[...perConnector.keys()].join(", ")}` : "\n(dry run — pass --apply to detach)"); | |
| 70 | +} | |
| 71 | + | |
| 72 | +main().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => closeDb()); | |
| 73 | ||