- OSINT: GDELT + Google News RSS 수집기 (@Scheduled 2분) - Satellite: CelesTrak TLE 수집기 (@Scheduled 10분) - Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일) - 프론트: 백엔드 API 우선 호출 + CelesTrak/GDELT fallback
314 lines
11 KiB
TypeScript
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);
|
|
}
|