SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
2 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%

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>
Simon-Pierre Boucher committed 20 days ago (Sep 12, 2026) parent ff67cee

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&apos;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