kcg-monitoring/frontend/src/services/celestrak.ts
htlee 69b2aeb3b3 feat(backend): OSINT/Satellite 수집기 + Caffeine 캐시 통일 + REST API
- OSINT: GDELT + Google News RSS 수집기 (@Scheduled 2분)
- Satellite: CelesTrak TLE 수집기 (@Scheduled 10분)
- Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일)
- 프론트: 백엔드 API 우선 호출 + CelesTrak/GDELT fallback
2026-03-18 04:04:18 +09:00

314 lines
11 KiB
TypeScript

import * as satellite from 'satellite.js';
import type { Satellite, SatellitePosition } from '../types';
// CelesTrak TLE groups to fetch — relevant to Middle East theater
const CELESTRAK_GROUPS: { group: string; category: Satellite['category'] }[] = [
{ group: 'military', category: 'reconnaissance' },
{ group: 'gps-ops', category: 'navigation' },
{ group: 'geo', category: 'communications' },
{ group: 'weather', category: 'weather' },
{ group: 'stations', category: 'other' },
];
// Category override by satellite name keywords
function refineSatCategory(name: string, defaultCat: Satellite['category']): Satellite['category'] {
const n = name.toUpperCase();
if (n.includes('SBIRS') || n.includes('NROL') || n.includes('USA') || n.includes('KEYHOLE') || n.includes('LACROSSE')) return 'reconnaissance';
if (n.includes('WGS') || n.includes('AEHF') || n.includes('MUOS') || n.includes('STARLINK') || n.includes('MILSTAR')) return 'communications';
if (n.includes('GPS') || n.includes('NAVSTAR') || n.includes('GALILEO') || n.includes('BEIDOU') || n.includes('GLONASS')) return 'navigation';
if (n.includes('GOES') || n.includes('METOP') || n.includes('NOAA') || n.includes('METEOR') || n.includes('DMSP')) return 'weather';
if (n.includes('ISS')) return 'other';
return defaultCat;
}
// Parse 3-line TLE format (name + line1 + line2)
function parseTLE(text: string, defaultCategory: Satellite['category']): Satellite[] {
const lines = text.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0);
const sats: Satellite[] = [];
for (let i = 0; i < lines.length - 2; i++) {
// TLE line 1 starts with "1 ", line 2 starts with "2 "
if (lines[i + 1].startsWith('1 ') && lines[i + 2].startsWith('2 ')) {
const name = lines[i];
const tle1 = lines[i + 1];
const tle2 = lines[i + 2];
// Extract NORAD catalog number from line 1 (columns 3-7)
const noradId = parseInt(tle1.substring(2, 7).trim(), 10);
if (isNaN(noradId)) continue;
sats.push({
noradId,
name,
tle1,
tle2,
category: refineSatCategory(name, defaultCategory),
});
i += 2; // skip the 2 TLE lines
}
}
return sats;
}
// Middle East bounding box for filtering LEO satellites
// Only keep satellites whose ground track passes near the region (lat 15-45, lon 25-65)
function isNearMiddleEast(sat: Satellite): boolean {
try {
const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2);
const now = new Date();
// Check current position and ±45min positions
for (const offsetMin of [0, -45, 45, -90, 90]) {
const t = new Date(now.getTime() + offsetMin * 60_000);
const pv = satellite.propagate(satrec, t);
if (!pv || typeof pv.position === 'boolean' || !pv.position) continue;
const gmst = satellite.gstime(t);
const geo = satellite.eciToGeodetic(pv.position, gmst);
const lat = satellite.degreesLat(geo.latitude);
const lng = satellite.degreesLong(geo.longitude);
// Generous bounding: lat -5 to 55, lon 15 to 75
if (lat >= -5 && lat <= 55 && lng >= 15 && lng <= 75) return true;
}
return false;
} catch {
return false;
}
}
// Satellite cache — avoid re-fetching within 10 minutes
let satCache: { sats: Satellite[]; ts: number } | null = null;
const SAT_CACHE_TTL = 10 * 60_000;
async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise<Satellite[]> {
try {
const res = await fetch(`/api/kcg/satellites?region=${region}`, { credentials: 'include' });
if (!res.ok) return [];
const data = await res.json();
return (data.satellites ?? []) as Satellite[];
} catch {
return [];
}
}
async function fetchSatelliteTLEFromCelesTrak(): Promise<Satellite[]> {
const allSats: Satellite[] = [];
const seenIds = new Set<number>();
for (const { group, category } of CELESTRAK_GROUPS) {
try {
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
const res = await fetch(url);
if (!res.ok) {
console.warn(`CelesTrak ${group}: ${res.status}`);
continue;
}
const text = await res.text();
const parsed = parseTLE(text, category);
for (const sat of parsed) {
if (!seenIds.has(sat.noradId)) {
seenIds.add(sat.noradId);
allSats.push(sat);
}
}
} catch (err) {
console.warn(`CelesTrak ${group} fetch failed:`, err);
}
}
return allSats;
}
function filterSatellitesByRegion(allSats: Satellite[], isNearFn: (sat: Satellite) => boolean): Satellite[] {
const filtered: Satellite[] = [];
for (const sat of allSats) {
try {
const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2);
const pv = satellite.propagate(satrec, new Date());
if (!pv || typeof pv.position === 'boolean' || !pv.position) continue;
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date()));
const altKm = geo.height;
if (altKm > 5000) {
filtered.push(sat);
} else {
if (isNearFn(sat)) {
filtered.push(sat);
}
}
} catch {
// skip bad TLE
}
}
return filtered.slice(0, 100);
}
export async function fetchSatelliteTLE(): Promise<Satellite[]> {
if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) {
return satCache.sats;
}
// 백엔드 API 우선
let allSats = await fetchSatellitesFromBackend('iran');
// 백엔드 실패 시 CelesTrak 직접 호출 fallback
if (allSats.length === 0) {
allSats = await fetchSatelliteTLEFromCelesTrak();
}
if (allSats.length === 0) {
console.warn('CelesTrak: no data fetched, using fallback');
return FALLBACK_SATELLITES;
}
const capped = filterSatellitesByRegion(allSats, isNearMiddleEast);
satCache = { sats: capped, ts: Date.now() };
console.log(`Satellites: loaded ${capped.length} (from ${allSats.length} total)`);
return capped;
}
// ═══ Korea region satellite fetch ═══
function isNearKorea(sat: Satellite): boolean {
try {
const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2);
const now = new Date();
for (const offsetMin of [0, -45, 45, -90, 90]) {
const t = new Date(now.getTime() + offsetMin * 60_000);
const pv = satellite.propagate(satrec, t);
if (!pv || typeof pv.position === 'boolean' || !pv.position) continue;
const gmst = satellite.gstime(t);
const geo = satellite.eciToGeodetic(pv.position, gmst);
const lat = satellite.degreesLat(geo.latitude);
const lng = satellite.degreesLong(geo.longitude);
if (lat >= 10 && lat <= 50 && lng >= 110 && lng <= 150) return true;
}
return false;
} catch {
return false;
}
}
let satCacheKorea: { sats: Satellite[]; ts: number } | null = null;
export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
if (satCacheKorea && Date.now() - satCacheKorea.ts < SAT_CACHE_TTL) {
return satCacheKorea.sats;
}
// 백엔드 API 우선
let allSats = await fetchSatellitesFromBackend('korea');
// 백엔드 실패 시 CelesTrak 직접 호출 fallback
if (allSats.length === 0) {
allSats = await fetchSatelliteTLEFromCelesTrak();
}
if (allSats.length === 0) return FALLBACK_SATELLITES;
const capped = filterSatellitesByRegion(allSats, isNearKorea);
satCacheKorea = { sats: capped, ts: Date.now() };
console.log(`Satellites Korea: loaded ${capped.length} (from ${allSats.length} total)`);
return capped;
}
// Fallback satellites if CelesTrak is unreachable
const FALLBACK_SATELLITES: Satellite[] = [
{ noradId: 25544, name: 'ISS (ZARYA)', category: 'other',
tle1: '1 25544U 98067A 26060.50000000 .00016717 00000-0 10270-3 0 9999',
tle2: '2 25544 51.6400 210.0000 0005000 350.0000 10.0000 15.49000000 10000' },
{ noradId: 37481, name: 'SBIRS GEO-1', category: 'reconnaissance',
tle1: '1 37481U 11019A 26060.50000000 .00000010 00000-0 00000-0 0 9999',
tle2: '2 37481 3.5000 60.0000 0003000 90.0000 270.0000 1.00270000 10000' },
{ noradId: 44478, name: 'WGS-10', category: 'communications',
tle1: '1 44478U 19060A 26060.50000000 .00000010 00000-0 00000-0 0 9999',
tle2: '2 44478 0.1000 60.0000 0002000 270.0000 90.0000 1.00270000 10000' },
{ noradId: 55268, name: 'GPS III-06', category: 'navigation',
tle1: '1 55268U 23008A 26060.50000000 .00000010 00000-0 00000-0 0 9999',
tle2: '2 55268 55.0000 60.0000 0050000 100.0000 260.0000 2.00600000 10000' },
{ noradId: 43689, name: 'MetOp-C', category: 'weather',
tle1: '1 43689U 18096A 26060.50000000 .00000400 00000-0 20000-3 0 9999',
tle2: '2 43689 98.7000 110.0000 0002000 90.0000 270.0000 14.21000000 10000' },
];
// Cache satrec objects (expensive to create)
const satrecCache = new Map<number, ReturnType<typeof satellite.twoline2satrec>>();
function getSatrec(sat: Satellite) {
let rec = satrecCache.get(sat.noradId);
if (!rec) {
rec = satellite.twoline2satrec(sat.tle1, sat.tle2);
satrecCache.set(sat.noradId, rec);
}
return rec;
}
// Cache ground tracks — only recompute every 60s
const trackCache = new Map<number, { time: number; track: [number, number][] }>();
const TRACK_CACHE_MS = 60_000;
export function propagateSatellite(
sat: Satellite,
time: Date,
trackMinutes: number = 90,
): SatellitePosition | null {
try {
const satrec = getSatrec(sat);
const posVel = satellite.propagate(satrec, time);
if (!posVel || typeof posVel.position === 'boolean' || !posVel.position) return null;
const pos = posVel.position;
const gmst = satellite.gstime(time);
const geo = satellite.eciToGeodetic(pos, gmst);
const lat = satellite.degreesLat(geo.latitude);
const lng = satellite.degreesLong(geo.longitude);
const altitude = geo.height;
// Ground track — use cache if fresh enough
const cached = trackCache.get(sat.noradId);
let groundTrack: [number, number][];
if (cached && Math.abs(cached.time - time.getTime()) < TRACK_CACHE_MS) {
groundTrack = cached.track;
} else {
groundTrack = [];
const steps = 20; // reduced from 60
const stepMs = (trackMinutes * 60 * 1000) / steps;
for (let i = -steps / 2; i <= steps / 2; i++) {
const t = new Date(time.getTime() + i * stepMs);
const pv = satellite.propagate(satrec, t);
if (!pv || typeof pv.position === 'boolean' || !pv.position) continue;
const g = satellite.gstime(t);
const gd = satellite.eciToGeodetic(pv.position, g);
groundTrack.push([
satellite.degreesLat(gd.latitude),
satellite.degreesLong(gd.longitude),
]);
}
trackCache.set(sat.noradId, { time: time.getTime(), track: groundTrack });
}
return {
noradId: sat.noradId,
name: sat.name,
lat,
lng,
altitude,
category: sat.category,
groundTrack,
};
} catch {
return null;
}
}
export function propagateAll(
satellites: Satellite[],
time: Date,
): SatellitePosition[] {
return satellites
.map(s => propagateSatellite(s, time))
.filter((p): p is SatellitePosition => p !== null);
}