Files
SAT/api/tle.php
T
2026-06-25 21:36:46 +00:00

210 lines
6.8 KiB
PHP

<?php
// sat.thedarkelite.com - TLE proxy + cache (fast + stale fallback)
//
// CelesTrak provides 3-line sets (name + line1 + line2).[6](https://celestrak.org/norad/documentation/tle-fmt.php)
// CelesTrak recommends updated access patterns for GP data and supports multiple formats.[2](https://www.celestrak.org/NORAD/documentation/gp-data-formats.php)[1](https://celestrak.org/NORAD/elements/)
header('Content-Type: application/json; charset=utf-8');
$start = microtime(true);
$group = isset($_GET['group']) ? strtolower(trim($_GET['group'])) : 'starlink';
function gpUrl(string $group, string $format = 'tle'): string {
// Modern group endpoint (preferred for reliability)
// See CelesTrak GP data format guidance + Current GP element sets index.[2](https://www.celestrak.org/NORAD/documentation/gp-data-formats.php)[1](https://celestrak.org/NORAD/elements/)
return 'https://celestrak.org/NORAD/elements/gp.php?GROUP=' . rawurlencode($group) . '&FORMAT=' . rawurlencode($format);
}
// Use gp.php for groups that have shown 404s on static .txt,
// keep starlink static since it commonly exists and is huge.
$allowed = [
'starlink' => ['name' => 'Starlink', 'url' => 'https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=tle'],
'noaa' => ['name' => 'NOAA', 'url' => 'https://celestrak.org/NORAD/elements/gp.php?GROUP=noaa&FORMAT=tle'],
'weather' => ['name' => 'Weather', 'url' => gpUrl('weather', 'tle')],
'active' => ['name' => 'Active', 'url' => gpUrl('active', 'tle')],
'stations' => ['name' => 'Space Stations', 'url' => gpUrl('stations', 'tle')],
];
if (!isset($allowed[$group])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid group'], JSON_PRETTY_PRINT);
exit;
}
$cacheDir = __DIR__ . '/cache';
if (!is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
$cacheKey = preg_replace('/[^a-z0-9_\-]/', '_', $group);
$cacheFile = $cacheDir . "/tle_{$cacheKey}.json";
// TTL: 2 hours default (CelesTrak updates on a multi-hour cadence; frequent pulls can cause blocks).[7](https://usradioguy.com/wxtoimg-kepler-fix/)[1](https://celestrak.org/NORAD/elements/)
$ttlSeconds = 2 * 60 * 60;
$now = time();
$emitCache = function(array $cached, bool $isFresh) use ($ttlSeconds, $cacheFile, $now, $start) {
$age = is_file($cacheFile) ? ($now - filemtime($cacheFile)) : null;
$cached['meta']['cached'] = true;
$cached['meta']['cacheAgeSeconds'] = $age;
$cached['meta']['cacheTtlSeconds'] = $ttlSeconds;
$cached['meta']['servedFrom'] = $isFresh ? 'cache_fresh' : 'cache_stale';
$cached['meta']['serverMs'] = (int)round((microtime(true) - $start) * 1000);
header('Cache-Control: public, max-age=60');
echo json_encode($cached, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
};
// Serve fresh cache
if (is_file($cacheFile)) {
$age = $now - filemtime($cacheFile);
if ($age < $ttlSeconds) {
$cached = json_decode(@file_get_contents($cacheFile), true);
if (is_array($cached)) $emitCache($cached, true);
}
}
// Fetch upstream (fail fast)
$url = $allowed[$group]['url'];
$ua = 'sat.thedarkelite.com (TLE proxy; contact: admin@thedarkelite.com)';
$text = null;
$httpCode = 0;
$curlErr = null;
$connectTimeout = 4;
$totalTimeout = 12;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $totalTimeout,
CURLOPT_USERAGENT => $ua,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
CURLOPT_ENCODING => '',
]);
$text = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($text === false || $httpCode >= 400) {
$text = null;
}
}
if ($text === null) {
// Serve stale cache if possible
if (is_file($cacheFile)) {
$cached = json_decode(@file_get_contents($cacheFile), true);
if (is_array($cached)) {
$cached['meta']['upstreamError'] = $curlErr ?: "Upstream fetch failed (HTTP $httpCode)";
$cached['meta']['upstream'] = $url;
$emitCache($cached, false);
}
}
http_response_code(502);
echo json_encode([
'error' => 'Failed to fetch TLEs from upstream',
'meta' => [
'group' => $group,
'upstream' => $url,
'httpCode' => $httpCode,
'curlError' => $curlErr,
'serverMs' => (int)round((microtime(true) - $start) * 1000),
]
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
$parsed = parseTleText($text);
$fetchedAtUtc = gmdate('Y-m-d H:i:s');
$out = [
'meta' => [
'source' => 'CelesTrak',
'group' => $group,
'groupName' => $allowed[$group]['name'],
'upstream' => $url,
'cached' => false,
'fetchedAtUtc' => $fetchedAtUtc,
'cacheTtlSeconds' => $ttlSeconds,
'count' => count($parsed),
'upstreamHttpCode' => $httpCode,
'serverMs' => (int)round((microtime(true) - $start) * 1000),
],
'satellites' => $parsed
];
@file_put_contents($cacheFile, json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
header('Cache-Control: public, max-age=60');
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// ---- Helpers ----
function parseTleText(string $text): array {
// TLE sets are typically name line + line1 + line2.[6](https://celestrak.org/norad/documentation/tle-fmt.php)
$lines = preg_split("/\r\n|\n|\r/", trim($text));
$lines = array_values(array_filter($lines, fn($l) => trim($l) !== ''));
$out = [];
$i = 0;
while ($i < count($lines)) {
$name = trim($lines[$i] ?? '');
$l1 = trim($lines[$i+1] ?? '');
$l2 = trim($lines[$i+2] ?? '');
if ($name !== '' && $l1 !== '' && $l2 !== '' && $l1[0] === '1' && $l2[0] === '2') {
// NORAD number is columns 03-07 in line 1.[6](https://celestrak.org/norad/documentation/tle-fmt.php)
$norad = trim(substr($l1, 2, 5));
if ($norad === '') $norad = '0';
// Epoch year/day are in line1 columns 19-32.[6](https://celestrak.org/norad/documentation/tle-fmt.php)
$epochRaw = trim(substr($l1, 18, 14));
$epochStr = epochToIso($epochRaw);
$out[] = [
'name' => $name,
'norad' => $norad,
'line1' => $l1,
'line2' => $l2,
'epoch' => $epochStr
];
$i += 3;
continue;
}
$i += 1;
}
return $out;
}
function epochToIso(string $epochRaw): ?string {
if ($epochRaw === '' || !preg_match('/^(\d{2})(\d{3}\.\d+)$/', $epochRaw, $m)) return null;
$yy = intval($m[1], 10);
$day = floatval($m[2]);
$year = ($yy >= 57) ? (1900 + $yy) : (2000 + $yy);
$dayInt = (int)floor($day);
$frac = $day - $dayInt;
$dt = new DateTime("$year-01-01 00:00:00", new DateTimeZone("UTC"));
$dt->modify('+' . ($dayInt - 1) . ' days');
$seconds = (int)round($frac * 86400);
$dt->modify('+' . $seconds . ' seconds');
return $dt->format('Y-m-d H:i:s');
}