initial commit
This commit is contained in:
+209
@@ -0,0 +1,209 @@
|
||||
<?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');
|
||||
}
|
||||
Reference in New Issue
Block a user