import { load, type CheerioAPI } from "cheerio"; import type { GeoPrecision, NormalizedCloudRegion, NormalizedEntity, PageType } from "@dci/core"; import { cleanText, countryFromText, parsePartialDate, validLatLng, urlFingerprint } from "@dci/core"; import { GenericConnector, type ConnectorConfig, type ConnectorContext, type DiscoveredUrl, type ExtractedRecord, type RawDocument } from "@dci/connectors"; import { lookupCity, countryFromStateName, stateDisplayName, normKey, type CityEntry } from "./city-coords.js"; /** A cheerio selection (avoids depending on domhandler types directly). */ type Sel = ReturnType; /** Text of a cheerio node, whitespace-collapsed, BOM/zero-width characters removed. */ export function txt(el: Sel): string { return cleanText(el.text().replace(/[\uFEFF\u200B\u00A0]/g, " ")) ?? ""; } /** Rows of an HTML table as arrays of cell texts (th + td). */ export function tableRows($: CheerioAPI, table: Sel): string[][] { return table .find("tr") .toArray() .map((tr) => $(tr).find("th,td").toArray().map((c) => txt($(c)))); } export function loadHtml(doc: RawDocument): CheerioAPI { return load(doc.text); } /** "Canada (Central)", "Mexico (Central)": a compass word, not a place. */ export function isDirectional(label: string): boolean { return /^(central|north|south|east|west|north ?east|north ?west|south ?east|south ?west|northern|southern|eastern|western|\d)$/i.test(label.trim()); } /** "US East (N. Virginia)" → "N. Virginia"; "Singapore" → null */ export function parenthetical(name: string): string | null { const m = name.match(/\(([^()]+)\)/); return m ? cleanText(m[1]) : null; } export interface Place { city: string | null; regionName: string | null; countryIso2: string | null; lat: number | null; lng: number | null; geoPrecision: GeoPrecision | null; } /** * Resolve a provider-published place label ("St. Ghislain", "N. Virginia", "Virginia", "Ireland") into * city / region / country / curated coordinates. Never geocodes: unknown labels yield a country at best. */ export function resolvePlace(label: string | null | undefined, isoHint?: string | null, regionHint?: string | null): Place { const empty: Place = { city: null, regionName: null, countryIso2: isoHint ?? null, lat: null, lng: null, geoPrecision: null }; if (!label || isDirectional(label)) return empty; let hit: CityEntry | null = lookupCity(label, isoHint ?? undefined) ?? (isoHint ? null : lookupCity(label)); // Same town name in another state ("Lebanon", "Columbus", "Douglas County"): refuse the table entry when the // provider's own state/province disagrees — better no coordinates than the wrong town. if (hit && regionHint && hit.region && normKey(hit.region) !== normKey(regionHint)) hit = null; if (hit && (!isoHint || hit.iso === isoHint)) { return { city: hit.city, regionName: hit.region ?? null, countryIso2: hit.iso, lat: hit.lat, lng: hit.lng, geoPrecision: hit.precision ?? "city" }; } // A US state / Canadian province name → region only, no coordinates (we do not place markers on state centroids) const stateIso = countryFromStateName(label); if (stateIso) return { city: null, regionName: stateDisplayName(label), countryIso2: stateIso, lat: null, lng: null, geoPrecision: null }; // A country name → country only const iso = countryFromText(label); if (iso) return { city: null, regionName: null, countryIso2: iso, lat: null, lng: null, geoPrecision: null }; return { ...empty, city: cleanText(label) }; } /** Build a cloud_region ExtractedRecord with the field names CloudConnector.normalize expects. */ export function regionRecord(opts: { connectorId: string; providerName: string; code: string; name: string; place?: Partial | null; availabilityZones?: number | null; launchedOn?: string | null; status?: NormalizedCloudRegion["status"]; isSovereign?: boolean; externalIds?: Record; url: string; methods?: Record; certainty?: number; keyPrefix?: string; /** where the coordinates came from when not the curated city table (e.g. "provider-marker:azure") */ geoSource?: string; }): ExtractedRecord { const p = opts.place ?? {}; return { kind: "cloud_region", key: `${opts.keyPrefix ?? opts.connectorId}:${opts.code.toLowerCase()}`, url: opts.url, pageType: "cloud_region", certainty: opts.certainty ?? 0.95, methods: opts.methods ?? { code: "html:table", name: "html:table" }, data: { providerName: opts.providerName, code: opts.code, name: opts.name, city: p.city ?? null, regionName: p.regionName ?? null, countryIso2: p.countryIso2 ?? null, lat: p.lat ?? null, lng: p.lng ?? null, geoPrecision: p.geoPrecision ?? null, availabilityZones: opts.availabilityZones ?? null, launchedOn: opts.launchedOn ?? null, status: opts.status ?? "operational", isSovereign: opts.isSovereign ?? false, externalIds: opts.externalIds ?? {}, geoSource: opts.geoSource ?? null, }, }; } const REGION_STATUSES: ReadonlySet = new Set(["announced", "operational", "restricted", "retired"]); /** * Cloud-provider connector: the generic pipeline plus a `cloud_region` normalizer that keeps * externalIds, the full status vocabulary (announced | operational | restricted | retired), the * curated geo precision and the provider's own source URL. Facilities / news / projects go through * the generic normalizer unchanged. */ export class CloudConnector extends GenericConnector { override async normalize(ctx: ConnectorContext, records: ExtractedRecord[]): Promise { const regions = records.filter((r) => r.kind === "cloud_region"); const others = records.filter((r) => r.kind !== "cloud_region"); const out: NormalizedEntity[] = others.length ? await super.normalize(ctx, others) : []; const d = this.cfg.defaults ?? {}; for (const r of regions) { const x = r.data; const str = (k: string): string | null => (x[k] == null ? null : cleanText(String(x[k]))); const num = (k: string): number | null => { const v = x[k]; if (v == null || v === "") return null; const n = typeof v === "number" ? v : Number(String(v).replace(/[^\d.-]/g, "")); return Number.isFinite(n) ? n : null; }; const code = str("code"), name = str("name"); if (!code || !name) continue; const lat = num("lat"), lng = num("lng"); const precision = (str("geoPrecision") as GeoPrecision | null) ?? "city"; const statusRaw = str("status"); const status = (statusRaw && REGION_STATUSES.has(statusRaw) ? statusRaw : "operational") as NormalizedCloudRegion["status"]; const prov = ctx.provenance(r.url, { method: r.methods ? Object.values(r.methods).slice(0, 3).join(",") : undefined, extractorVersion: this.parserVersion, note: precision === "city" || precision === null ? "coordinates are city-level markers from a curated city table, not facility locations" : `coordinates are ${precision}-level markers, not facility locations` }); const region: NormalizedCloudRegion = { entityType: "cloud_region", key: r.key, providerName: str("providerName") ?? d.providerName ?? this.sourceName, code, name, city: str("city"), regionName: str("regionName"), countryIso2: str("countryIso2") ?? d.countryIso2 ?? null, geo: validLatLng(lat, lng) ? { lat: lat!, lng: lng!, precision, source: str("geoSource") ?? `curated-city-table:${this.id}` } : null, availabilityZones: num("availabilityZones"), launchedOn: str("launchedOn") ? parsePartialDate(str("launchedOn")) : null, status, isSovereign: x.isSovereign === true, sourceUrl: str("sourceUrl") ?? r.url, externalIds: (x.externalIds as Record | undefined) ?? {}, provenance: prov, }; out.push(region); } return out; } } /** * Newsroom connector for hyperscaler feeds: items are filtered on their RSS title/summary * (params.include / params.exclude regexes) BEFORE fetching, so a 100-item "What's New" feed only costs * the handful of infrastructure-related fetches. When the generic extractor yields nothing for a * matched item, a news_event is still produced from the feed metadata (title, summary, date). */ export class CloudNewsroomConnector extends CloudConnector { private readonly include: RegExp | null; private readonly exclude: RegExp | null; constructor(cfg: ConnectorConfig) { super(cfg); const p = cfg.params ?? {}; this.include = typeof p.include === "string" ? new RegExp(p.include, "i") : null; this.exclude = typeof p.exclude === "string" ? new RegExp(p.exclude, "i") : null; } /** Feed items are judged on title + summary; URLs found without feed metadata (sitemaps) pass through to the generic relevance gate. */ private matches(u: DiscoveredUrl): boolean { if (!u.meta?.title) return true; const hay = `${String(u.meta.title)} ${String(u.meta.summary ?? "").replace(/<[^>]+>/g, " ")}`; if (this.exclude && this.exclude.test(hay)) return false; return this.include ? this.include.test(hay) : true; } override async discover(ctx: ConnectorContext): Promise { const all = await super.discover(ctx); const kept = all.filter((u) => u.group !== "newsroom" || this.matches(u)).map((u) => (u.meta?.title ? { ...u, meta: { ...(u.meta ?? {}), feedMatched: true } } : u)); ctx.log("info", `newsroom filter: ${kept.length}/${all.length} items match`); return kept; } override async extract(ctx: ConnectorContext, doc: RawDocument): Promise { const recs = await super.extract(ctx, doc); if (recs.length || doc.error || doc.status >= 400 || !doc.meta?.feedMatched) return recs; const title = cleanText(String(doc.meta?.title ?? "")) ?? cleanText(String(doc.meta?.pageTitle ?? "")) ?? doc.finalUrl; const summary = cleanText(String(doc.meta?.summary ?? "").replace(/<[^>]+>/g, " ")); const published = doc.meta?.published ? parsePartialDate(String(doc.meta.published)) ?? isoDay(String(doc.meta.published)) : null; const pageType: PageType = (doc.meta?.pageType as PageType | undefined) && doc.meta?.pageType !== "unknown" ? (doc.meta!.pageType as PageType) : "press_release"; return [{ kind: "news_event", key: `${this.id}:news:${urlFingerprint(doc.finalUrl)}`, url: doc.finalUrl, pageType, certainty: 0.5, methods: { title: "rss:title", summary: "rss:description", publishedAt: "rss:pubDate" }, data: { title, url: doc.finalUrl, publishedAt: published, summary, pageType, eventType: "news" } }]; } } function isoDay(s: string): string | null { const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); }