import type { Parser, ExtractedRecord, RawDocument, ConnectorContext } from "@dci/connectors"; import { countryFromText } from "@dci/core"; import { loadHtml, tableRows, parenthetical, resolvePlace, regionRecord, type Place } from "./shared.js"; import { countryFromStateName, stateDisplayName } from "./city-coords.js"; /** * AWS — static documentation pages (no JS): * Regions: https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html * table Code | Name | AZs | Geography | Opt-in status * AZs: https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-availability-zones.html * table AZ ID | Region | Geography ("Virginia, United States of America") → state per region * Local Zones: https://docs.aws.amazon.com/local-zones/latest/ug/available-local-zones.html * table Group Long Name | Zone Name | Zone ID | Network Border Group | Parent Region | Parent Zone ID | Geography */ const AZ_PAGE = "https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-availability-zones.html"; /** "Virginia, United States of America" → { state: "Virginia", iso: "US" } ; "Japan" → { iso: "JP" } */ function geography(g: string): { state: string | null; iso: string | null } { const parts = g.split(",").map((s) => s.trim()).filter(Boolean); if (parts.length >= 2) { const state = parts[0]!; const iso = countryFromText(parts.slice(1).join(" ")) ?? countryFromStateName(state); return { state: countryFromStateName(state) ? stateDisplayName(state) : state, iso }; } return { state: null, iso: countryFromText(g) }; } /** Per-region state/province names from the Availability Zones docs page (best effort; cached in state). */ async function regionStates(ctx: ConnectorContext): Promise> { const cached = await ctx.getState>("aws:regionStates"); if (cached) return new Map(Object.entries(cached)); const map = new Map(); const doc = await ctx.fetch(AZ_PAGE, { group: "regions" }); if (doc.error || doc.status !== 200) { ctx.log("warn", `aws AZ page unavailable (${doc.error?.code ?? doc.status}); regionName left null`); return map; } const $ = loadHtml(doc); for (const row of tableRows($, $("table"))) { if (row.length < 3 || row[0] === "AZ ID") continue; const region = row[1]!.replace(/[†*]/g, "").trim(); const { state } = geography(row[2]!); if (state && !map.has(region)) map.set(region, state); } await ctx.setState("aws:regionStates", Object.fromEntries(map)); return map; } export const awsDocsRegionsParser: Parser = { name: "cloud_aws_docs_regions_v1", version: "1.0.0", pageTypes: ["cloud_region"], async parse(doc: RawDocument, ctx): Promise { const $ = loadHtml(doc); const states = await regionStates(ctx); const out: ExtractedRecord[] = []; for (const row of tableRows($, $("table"))) { if (row.length < 4) continue; const code = row[0]!.trim(); if (!/^[a-z]{2}-[a-z]+-\d$/.test(code)) continue; const name = row[1]!; const azs = Number.parseInt(row[2]!.replace(/[^\d]/g, ""), 10); const geo = geography(row[3]!); const optIn = row[4] ?? null; const label = parenthetical(name); const state = states.get(code) ?? geo.state; // Place: the parenthetical when it is a city/metro (checked against the docs' own state); otherwise country / state only let place: Place = resolvePlace(label, geo.iso, state); if (!place.city && !place.regionName && geo.iso) place = { ...place, countryIso2: geo.iso }; if (!place.regionName && state) place.regionName = state; if (!place.countryIso2) place.countryIso2 = geo.iso; const externalIds: Record = { aws_region: code, kind: "region" }; if (optIn) externalIds.opt_in = optIn; if (label) externalIds.published_location = label; out.push( regionRecord({ connectorId: ctx.connectorId, keyPrefix: "aws:region", providerName: "AWS", code, name, place, availabilityZones: Number.isFinite(azs) && azs > 0 ? azs : null, status: "operational", externalIds, url: doc.finalUrl, methods: { code: "html:table.Code", name: "html:table.Name", availabilityZones: "html:table.AZs", countryIso2: "html:table.Geography", city: "curated-city-table" }, }), ); } ctx.log("info", `aws: ${out.length} regions`); return out; }, }; export const awsLocalZonesParser: Parser = { name: "cloud_aws_local_zones_v1", version: "1.0.0", pageTypes: ["cloud_region"], parse(doc: RawDocument, ctx): ExtractedRecord[] { const $ = loadHtml(doc); const groups = new Map(); for (const row of tableRows($, $("table"))) { if (row.length < 7 || row[0] === "Local Zone Group Long Name") continue; const [longName, zoneName, zoneId, group, parent, , geo] = row as [string, string, string, string, string, string, string]; if (!/^[a-z]{2}-[a-z]+-\d-[a-z]{3}-\d$/.test(group)) continue; const g = groups.get(group) ?? { name: longName.replace(/\*+$/, "").trim(), parent, geography: geo, zones: [], zoneIds: [] }; g.zones.push(zoneName); g.zoneIds.push(zoneId); groups.set(group, g); } const out: ExtractedRecord[] = []; for (const [group, g] of groups) { const geo = geography(g.geography); const label = parenthetical(g.name); // Local Zones are named after large metros; no state hint on purpose: AWS files "New York City" under New Jersey. let place: Place = resolvePlace(label, geo.iso); if (!place.countryIso2) place.countryIso2 = geo.iso; if (geo.state) place.regionName = geo.state; out.push( regionRecord({ connectorId: ctx.connectorId, keyPrefix: "aws:localzone", providerName: "AWS", code: group, name: `${g.name} (Local Zone)`, place, availabilityZones: g.zones.length, status: "operational", externalIds: { kind: "local_zone", aws_parent_region: g.parent, aws_zone_names: g.zones.join(","), aws_zone_ids: g.zoneIds.join(","), ...(label ? { published_location: label } : {}) }, url: doc.finalUrl, methods: { code: "html:table.NetworkBorderGroup", name: "html:table.GroupLongName", availabilityZones: "count:zones", countryIso2: "html:table.Geography", city: "curated-city-table" }, certainty: 0.9, }), ); } ctx.log("info", `aws: ${out.length} local zone groups`); return out; }, };