170 lines
6.5 KiB
TypeScript

import { load } from "cheerio";
import { XMLParser } from "fast-xml-parser";
import type { NormalizedItem, SourceDefinition, SourceId } from "./types.js";
export const SOURCES: SourceDefinition[] = [
{
id: "geonet",
name: "GeoNet earthquakes",
type: "geojson",
url: "https://api.geonet.org.nz/quake?MMI=3"
},
{
id: "parliament",
name: "NZ Parliament evidence and submissions",
type: "atom",
url: "https://www3.parliament.nz/en/channellisting/rss/9343/e37f168d-4521-4437-a6fd-6bb7ff54e800"
},
{
id: "stats-nz",
name: "Stats NZ information releases",
type: "html",
url: "https://www.stats.govt.nz/information-releases/"
},
{
id: "data-govt-nz",
name: "data.govt.nz catalogue",
type: "ckan",
url: "https://catalogue.data.govt.nz/api/3/action/package_search?sort=metadata_modified%20desc&rows=25"
}
];
const UA = "Arrowstack-Media-Intelligence/0.1 (+https://media.arrowstack.dev)";
async function getText(url: string, accept = "*/*") {
const response = await fetch(url, {
headers: { accept, "user-agent": UA },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`);
return response.text();
}
async function getJson<T>(url: string, accept = "application/json") {
const response = await fetch(url, {
headers: { accept, "user-agent": UA },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`);
return response.json() as Promise<T>;
}
export async function fetchSource(id: SourceId): Promise<NormalizedItem[]> {
if (id === "geonet") return fetchGeoNet();
if (id === "parliament") return fetchParliament();
if (id === "stats-nz") return fetchStatsNz();
return fetchDataGovtNz();
}
async function fetchGeoNet(): Promise<NormalizedItem[]> {
const source = SOURCES.find((s) => s.id === "geonet")!;
const data = await getJson<any>(source.url, "application/vnd.geo+json;version=2");
return (data.features ?? []).slice(0, 30).map((feature: any) => {
const p = feature.properties ?? {};
const coords = feature.geometry?.coordinates ?? [];
return {
sourceId: "geonet" as const,
externalId: String(p.publicID ?? feature.id),
title: `M${p.magnitude ?? "?"} earthquake — ${p.locality ?? "New Zealand"}`,
summary: `Magnitude ${p.magnitude ?? "unknown"}, depth ${p.depth ?? "unknown"} km, MMI ${p.mmi ?? "unknown"}, quality ${p.quality ?? "unknown"}.`,
url: p.publicID ? `https://www.geonet.org.nz/earthquake/${p.publicID}` : source.url,
publishedAt: p.time ?? null,
topics: ["natural-hazards", "earthquake"],
metrics: {
magnitude: Number(p.magnitude ?? 0),
mmi: Number(p.mmi ?? 0),
depthKm: Number(p.depth ?? 0),
longitude: coords[0] ?? null,
latitude: coords[1] ?? null
},
raw: feature
};
});
}
function firstLink(value: any): string {
if (Array.isArray(value)) {
const alt = value.find((v) => v?.["@_rel"] === "alternate") ?? value[0];
return alt?.["@_href"] ?? "";
}
return value?.["@_href"] ?? "";
}
async function fetchParliament(): Promise<NormalizedItem[]> {
const source = SOURCES.find((s) => s.id === "parliament")!;
const xml = await getText(source.url, "application/atom+xml, application/xml, text/xml");
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
const parsed = parser.parse(xml);
const entries = parsed?.feed?.entry ?? parsed?.rss?.channel?.item ?? [];
const list = Array.isArray(entries) ? entries : [entries];
return list.slice(0, 30).map((entry: any, index: number) => {
const title = String(entry.title?.["#text"] ?? entry.title ?? "Parliament item").trim();
const summary = String(entry.summary?.["#text"] ?? entry.summary ?? entry.description ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const url = firstLink(entry.link) || String(entry.link ?? source.url);
return {
sourceId: "parliament" as const,
externalId: String(entry.id ?? entry.guid ?? url ?? index),
title,
summary,
url,
publishedAt: String(entry.updated ?? entry.published ?? entry.pubDate ?? "") || null,
topics: ["parliament", "public-policy"],
raw: entry
};
});
}
function parseDate(text: string): string | null {
const match = text.match(/\b(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(20\d{2})\b/i);
if (!match) return null;
const d = new Date(`${match[1]} ${match[2]} ${match[3]} 12:00:00 +1200`);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
async function fetchStatsNz(): Promise<NormalizedItem[]> {
const source = SOURCES.find((s) => s.id === "stats-nz")!;
const html = await getText(source.url, "text/html");
const $ = load(html);
const seen = new Set<string>();
const items: NormalizedItem[] = [];
$('a[href*="/information-releases/"]').each((_i, el) => {
if (items.length >= 30) return;
const href = $(el).attr("href");
const title = $(el).text().replace(/\s+/g, " ").trim();
if (!href || !title || title.length < 8) return;
const url = new URL(href, source.url).toString().split("?")[0];
if (seen.has(url)) return;
seen.add(url);
const container = $(el).closest("article, li, div");
const surrounding = container.text().replace(/\s+/g, " ").trim();
const summary = surrounding.replace(title, "").trim().slice(0, 900);
items.push({
sourceId: "stats-nz",
externalId: url,
title,
summary,
url,
publishedAt: parseDate(surrounding),
topics: ["statistics", "new-zealand"],
raw: { title, summary, url }
});
});
return items;
}
async function fetchDataGovtNz(): Promise<NormalizedItem[]> {
const source = SOURCES.find((s) => s.id === "data-govt-nz")!;
const data = await getJson<any>(source.url);
if (!data.success) throw new Error("data.govt.nz CKAN returned success=false");
return (data.result?.results ?? []).slice(0, 30).map((pkg: any) => ({
sourceId: "data-govt-nz" as const,
externalId: String(pkg.id ?? pkg.name),
title: String(pkg.title ?? pkg.name ?? "Dataset"),
summary: String(pkg.notes ?? "").replace(/\s+/g, " ").trim().slice(0, 1200),
url: pkg.url || `https://catalogue.data.govt.nz/dataset/${pkg.name}`,
publishedAt: pkg.metadata_modified ?? pkg.metadata_created ?? null,
topics: ["open-data", ...(pkg.tags ?? []).slice(0, 6).map((tag: any) => String(tag.display_name ?? tag.name).toLowerCase())],
raw: pkg
}));
}