chore(data): vendor submarine cable geojson/details

This commit is contained in:
htlee 2026-02-15 20:59:45 +09:00
부모 3ba6c02ba0
커밋 621a5037c2
4개의 변경된 파일180개의 추가작업 그리고 1개의 파일을 삭제

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -10,7 +10,8 @@
"build:web": "npm -w @wing/web run build",
"build:api": "npm -w @wing/api run build",
"lint": "npm -w @wing/web run lint",
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs",
"prepare:subcables": "node scripts/prepare-subcables.mjs"
},
"devDependencies": {
"xlsx": "^0.18.5"

파일 보기

@ -0,0 +1,176 @@
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;
});