177 lines
5.5 KiB
JavaScript
177 lines
5.5 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const OUT_DIR = path.resolve(__dirname, "..", "apps", "web", "public", "data", "subcables");
|
|
const GEO_URL = "https://www.submarinecablemap.com/api/v3/cable/cable-geo.json";
|
|
const DETAILS_URL_BASE = "https://www.submarinecablemap.com/api/v3/cable/";
|
|
|
|
const CONCURRENCY = Math.max(1, Math.min(24, Number(process.env.CONCURRENCY || 12)));
|
|
const TIMEOUT_MS = Math.max(5_000, Math.min(60_000, Number(process.env.TIMEOUT_MS || 20_000)));
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fetchText(url, { timeoutMs = TIMEOUT_MS } = {}) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
accept: "application/json",
|
|
},
|
|
});
|
|
const text = await res.text();
|
|
const contentType = res.headers.get("content-type") || "";
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status} (${res.statusText})`);
|
|
}
|
|
return { text, contentType };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const { text, contentType } = await fetchText(url);
|
|
if (!contentType.toLowerCase().includes("application/json")) {
|
|
const snippet = text.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
throw new Error(`Unexpected content-type (${contentType || "unknown"}): ${snippet || "<empty>"}`);
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function fetchJsonWithRetry(url, attempts = 2) {
|
|
let lastErr = null;
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
try {
|
|
return await fetchJson(url);
|
|
} catch (e) {
|
|
lastErr = e;
|
|
if (i < attempts - 1) {
|
|
await sleep(250 * (i + 1));
|
|
}
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
function pickCableDetails(raw) {
|
|
const obj = raw && typeof raw === "object" ? raw : {};
|
|
const landingPoints = Array.isArray(obj.landing_points) ? obj.landing_points : [];
|
|
return {
|
|
id: String(obj.id || ""),
|
|
name: String(obj.name || ""),
|
|
length: obj.length == null ? null : String(obj.length),
|
|
rfs: obj.rfs == null ? null : String(obj.rfs),
|
|
rfs_year: typeof obj.rfs_year === "number" ? obj.rfs_year : null,
|
|
is_planned: Boolean(obj.is_planned),
|
|
owners: obj.owners == null ? null : String(obj.owners),
|
|
suppliers: obj.suppliers == null ? null : String(obj.suppliers),
|
|
landing_points: landingPoints.map((lp) => {
|
|
const p = lp && typeof lp === "object" ? lp : {};
|
|
return {
|
|
id: String(p.id || ""),
|
|
name: String(p.name || ""),
|
|
country: String(p.country || ""),
|
|
is_tbd: p.is_tbd === true,
|
|
};
|
|
}),
|
|
notes: obj.notes == null ? null : String(obj.notes),
|
|
url: obj.url == null ? null : String(obj.url),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
|
|
|
console.log(`[subcables] fetching geojson: ${GEO_URL}`);
|
|
const geo = await fetchJsonWithRetry(GEO_URL, 3);
|
|
const geoPath = path.join(OUT_DIR, "cable-geo.json");
|
|
await fs.writeFile(geoPath, JSON.stringify(geo));
|
|
|
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
|
const ids = Array.from(
|
|
new Set(
|
|
features
|
|
.map((f) => f?.properties?.id)
|
|
.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
.map((v) => v.trim()),
|
|
),
|
|
).sort();
|
|
|
|
console.log(`[subcables] cables: ${ids.length} (concurrency=${CONCURRENCY}, timeoutMs=${TIMEOUT_MS})`);
|
|
|
|
const byId = {};
|
|
const failures = [];
|
|
let cursor = 0;
|
|
let completed = 0;
|
|
const startedAt = Date.now();
|
|
|
|
const worker = async () => {
|
|
for (;;) {
|
|
const idx = cursor;
|
|
cursor += 1;
|
|
if (idx >= ids.length) return;
|
|
const id = ids[idx];
|
|
const url = new URL(`${id}.json`, DETAILS_URL_BASE).toString();
|
|
try {
|
|
const raw = await fetchJsonWithRetry(url, 2);
|
|
const picked = pickCableDetails(raw);
|
|
if (!picked.id) {
|
|
throw new Error("Missing id in details response");
|
|
}
|
|
byId[id] = picked;
|
|
} catch (e) {
|
|
failures.push({ id, error: e instanceof Error ? e.message : String(e) });
|
|
} finally {
|
|
completed += 1;
|
|
if (completed % 25 === 0 || completed === ids.length) {
|
|
const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
const rate = (completed / sec).toFixed(1);
|
|
console.log(`[subcables] ${completed}/${ids.length} (${rate}/s)`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
|
|
const detailsOut = {
|
|
version: 1,
|
|
generated_at: new Date().toISOString(),
|
|
by_id: byId,
|
|
};
|
|
const detailsPath = path.join(OUT_DIR, "cable-details.min.json");
|
|
await fs.writeFile(detailsPath, JSON.stringify(detailsOut));
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`[subcables] failures: ${failures.length}`);
|
|
for (const f of failures.slice(0, 30)) {
|
|
console.error(`- ${f.id}: ${f.error}`);
|
|
}
|
|
if (failures.length > 30) {
|
|
console.error(`- ... +${failures.length - 30} more`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
console.log(`[subcables] wrote: ${geoPath}`);
|
|
console.log(`[subcables] wrote: ${detailsPath}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(`[subcables] fatal: ${e instanceof Error ? e.stack || e.message : String(e)}`);
|
|
process.exitCode = 1;
|
|
});
|
|
|