From 92c45a60af186ecf5bfd1af66fd0d9bae34df4aa Mon Sep 17 00:00:00 2001 From: Eskimue Date: Fri, 10 Apr 2026 14:45:02 +0200 Subject: [PATCH] MPC Kometen Import verbessert --- CLAUDE.md | 2 +- config/skyview.sql | 37 +- scripts/import/import_mpc_comets.php | 696 +++++++++++++++++++-------- 3 files changed, 518 insertions(+), 217 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 337b016..a68369b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,7 +299,7 @@ Höhe 380px, volle Breite, abgerundete Ecken, Gold-Border. |---|---| | `star_hipparcos` | ~118k Sterne aus dem Hipparcos-Katalog (ra, dec, mag, proper, spect) | | `dso_objects` | ~14k Deepsky-Objekte aus OpenNGC (NGC, IC, Messier, Typ, ra/dec, Helligkeit) | -| `comets_mpc` | ~4600 Kometen vom Minor Planet Center (Bahnelemente) | +| `comets_mpc` | Kometen aus MPC `Soft00Cmt.txt` (Fixed-width-Bahnelemente mit Epoche, H/G, Name, Referenz) | | `constellations` | Sternbilder (ID = 3-Buchstaben-Kürzel, z. B. `ORI`) | | `constellation_names` | Deutsche + lateinische Namen pro Sternbild | | `constellation_lines` | Verbindungslinien zwischen Sternen | diff --git a/config/skyview.sql b/config/skyview.sql index b943a85..d943e7b 100644 --- a/config/skyview.sql +++ b/config/skyview.sql @@ -119,31 +119,34 @@ CREATE TABLE IF NOT EXISTS `app_user_satellites` ( -- Exportiere Struktur von Tabelle d0467f49.comets_mpc CREATE TABLE IF NOT EXISTS `comets_mpc` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, - `orbit_type` char(1) NOT NULL COMMENT 'MPC Orbit_type, z.B. C fuer cometary orbit', - `provisional_packed_desig` varchar(32) DEFAULT NULL COMMENT 'Gepackte MPC-Bezeichnung, z.B. .53P010', - `year_of_perihelion` smallint(6) NOT NULL COMMENT 'Jahr des Periheldurchgangs, kann auch negativ sein', + `designation_packed` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT 'Fixed-width MPC-Packbezeichnung aus Soft00Cmt.txt, inkl. Fragmentkennung', + `orbit_type` char(1) NOT NULL COMMENT 'Abgeleiteter Kometentyp, z. B. C, P oder I', + `year_of_perihelion` smallint(6) NOT NULL COMMENT 'Jahr des Periheldurchgangs', `month_of_perihelion` tinyint(3) unsigned NOT NULL COMMENT 'Monat des Periheldurchgangs', - `day_of_perihelion` decimal(8,5) NOT NULL COMMENT 'Tag des Periheldurchgangs inklusive Tagesbruchteil', - `perihelion_dist` decimal(12,8) NOT NULL COMMENT 'Periheldistanz q in AE', - `eccentricity` decimal(12,8) NOT NULL COMMENT 'Exzentrizitaet e', - `arg_perihelion_deg` decimal(10,6) NOT NULL COMMENT 'Argument des Perihels in Grad', - `ascending_node_deg` decimal(10,6) NOT NULL COMMENT 'Laenge des aufsteigenden Knotens in Grad', - `inclination_deg` decimal(10,6) NOT NULL COMMENT 'Bahnneigung in Grad', - `absolute_magnitude_h` decimal(8,3) DEFAULT NULL COMMENT 'H-Magnitude laut MPC', - `slope_parameter_g` decimal(8,3) DEFAULT NULL COMMENT 'G-Parameter laut MPC', - `designation_and_name` varchar(255) NOT NULL COMMENT 'Lesbare Bezeichnung, z.B. C/-146 P1', - `reference_text` varchar(64) DEFAULT NULL COMMENT 'Referenzfeld aus MPC, z.B. 31, 257', - `raw_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Originaler JSON-Datensatz fuer Nachvollziehbarkeit' CHECK (json_valid(`raw_json`)), + `day_of_perihelion` decimal(8,4) NOT NULL COMMENT 'Tag des Periheldurchgangs inklusive Tagesbruchteil', + `perihelion_dist_au` decimal(12,6) NOT NULL COMMENT 'Periheldistanz q in AE', + `eccentricity` decimal(12,6) NOT NULL COMMENT 'Exzentrizitaet e', + `arg_perihelion_deg` decimal(9,4) NOT NULL COMMENT 'Argument des Perihels in Grad', + `ascending_node_deg` decimal(9,4) NOT NULL COMMENT 'Laenge des aufsteigenden Knotens in Grad', + `inclination_deg` decimal(9,4) NOT NULL COMMENT 'Bahnneigung in Grad', + `epoch_compact` char(8) DEFAULT NULL COMMENT 'Epoche des Elementsatzes als YYYYMMDD; kann bei Fragmenten fehlen', + `epoch_date` date DEFAULT NULL COMMENT 'Epoche des Elementsatzes als SQL-Datum', + `absolute_magnitude_h` decimal(5,1) DEFAULT NULL COMMENT 'Absolute Helligkeit H laut MPC', + `slope_parameter_g` decimal(4,1) DEFAULT NULL COMMENT 'Steigungsparameter G laut MPC', + `designation_and_name` varchar(255) NOT NULL COMMENT 'Lesbare Bezeichnung, z. B. C/1995 O1 (Hale-Bopp)', + `reference_text` varchar(64) DEFAULT NULL COMMENT 'Referenz aus der MPC-Datei, z. B. MPEC 2026-FC3', + `raw_record` varchar(255) DEFAULT NULL COMMENT 'Originale Fixed-width-Zeile aus Soft00Cmt.txt', `source_url` varchar(255) DEFAULT NULL COMMENT 'Quelle des letzten Imports', `imported_at` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'Zeitpunkt des letzten Imports', `created_at` datetime NOT NULL DEFAULT current_timestamp(), `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), - UNIQUE KEY `uq_mpc_comets_designation_name` (`designation_and_name`), + UNIQUE KEY `uq_mpc_comets_designation_packed` (`designation_packed`), KEY `idx_mpc_comets_orbit_type` (`orbit_type`), KEY `idx_mpc_comets_perihelion_year_month` (`year_of_perihelion`,`month_of_perihelion`), - KEY `idx_mpc_comets_provisional_packed_desig` (`provisional_packed_desig`) -) ENGINE=InnoDB AUTO_INCREMENT=4630 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Kometenelemente vom Minor Planet Center'; + KEY `idx_mpc_comets_epoch_compact` (`epoch_compact`), + KEY `idx_mpc_comets_designation_and_name` (`designation_and_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Kometenelemente aus Soft00Cmt.txt des Minor Planet Center'; -- Daten-Export vom Benutzer nicht ausgewählt diff --git a/scripts/import/import_mpc_comets.php b/scripts/import/import_mpc_comets.php index ebef925..cdea286 100644 --- a/scripts/import/import_mpc_comets.php +++ b/scripts/import/import_mpc_comets.php @@ -4,25 +4,39 @@ declare(strict_types=1); /** * Minor Planet Center Comet Import * Quelle: - * https://www.minorplanetcenter.net/Extended_Files/allcometels.json.gz + * https://www.minorplanetcenter.net/iau/Ephemerides/Comets/Soft00Cmt.txt * * Zieltabelle: * - comets_mpc + * + * Nutzung: + * php scripts/import/import_mpc_comets.php + * php scripts/import/import_mpc_comets.php C:\pfad\zu\Soft00Cmt.txt */ -ini_set('display_errors', '1'); error_reporting(E_ALL); +ini_set('display_errors', '1'); +set_time_limit(0); date_default_timezone_set('UTC'); -$config = require __DIR__ . '/../../config/database.php'; +$rootDir = dirname(__DIR__, 2); +$config = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'database.php'; -$sourceUrl = 'https://www.minorplanetcenter.net/Extended_Files/allcometels.json.gz'; +if (!is_array($config)) { + writeError("config/database.php muss ein Array mit DB-Zugangsdaten zurueckgeben."); + exit(1); +} + +$config = normalizeDbConfig($config); +$sourceUrl = 'https://www.minorplanetcenter.net/iau/Ephemerides/Comets/Soft00Cmt.txt'; +$localSourcePath = $argv[1] ?? ($_GET['source'] ?? null); $dsn = sprintf( - 'mysql:host=%s;dbname=%s;charset=%s', + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', $config['host'], + (int) $config['port'], $config['dbname'], - $config['charset'] ?? 'utf8mb4' + $config['charset'] ); $pdo = new PDO( @@ -35,7 +49,163 @@ $pdo = new PDO( ] ); -function fetchRemoteJson(string $url): array +$recordsReceived = 0; +$recordsImported = 0; +$recordsSkipped = 0; + +try { + rebuildCometsTable($pdo); + + [$lines, $resolvedSource] = resolveSourceLines($sourceUrl, $localSourcePath); + $statement = $pdo->prepare( + 'INSERT INTO `comets_mpc` ( + `designation_packed`, + `orbit_type`, + `year_of_perihelion`, + `month_of_perihelion`, + `day_of_perihelion`, + `perihelion_dist_au`, + `eccentricity`, + `arg_perihelion_deg`, + `ascending_node_deg`, + `inclination_deg`, + `epoch_compact`, + `epoch_date`, + `absolute_magnitude_h`, + `slope_parameter_g`, + `designation_and_name`, + `reference_text`, + `raw_record`, + `source_url`, + `imported_at`, + `created_at`, + `updated_at` + ) VALUES ( + :designation_packed, + :orbit_type, + :year_of_perihelion, + :month_of_perihelion, + :day_of_perihelion, + :perihelion_dist_au, + :eccentricity, + :arg_perihelion_deg, + :ascending_node_deg, + :inclination_deg, + :epoch_compact, + :epoch_date, + :absolute_magnitude_h, + :slope_parameter_g, + :designation_and_name, + :reference_text, + :raw_record, + :source_url, + NOW(), + NOW(), + NOW() + )' + ); + + $pdo->beginTransaction(); + + foreach ($lines as $lineNumber => $line) { + $trimmedLine = rtrim($line, "\r\n"); + if (trim($trimmedLine) === '') { + continue; + } + + $recordsReceived++; + + try { + $record = parseSoft00CmtLine($trimmedLine); + $record[':source_url'] = $resolvedSource; + $statement->execute($record); + $recordsImported++; + } catch (Throwable $rowError) { + $recordsSkipped++; + echo 'Uebersprungen Zeile ' . ($lineNumber + 1) . ': ' . $rowError->getMessage() . "\n"; + } + } + + $pdo->commit(); + + echo "Import erfolgreich\n"; + echo "Quelle: {$resolvedSource}\n"; + echo "Empfangen: {$recordsReceived}\n"; + echo "Importiert: {$recordsImported}\n"; + echo "Uebersprungen: {$recordsSkipped}\n"; +} catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + writeError('Fehler: ' . $e->getMessage()); + exit(1); +} + +function writeError(string $message): void +{ + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, $message . PHP_EOL); + return; + } + + echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . "
\n"; +} + +function fetchRemoteLines(string $url): array +{ + if (function_exists('curl_init')) { + return fetchRemoteLinesViaCurl($url); + } + + try { + return fetchRemoteLinesViaStream($url); + } catch (Throwable $streamError) { + return fetchRemoteLinesViaSocket($url, $streamError->getMessage()); + } +} + +function resolveSourceLines(string $remoteUrl, ?string $localSourcePath = null): array +{ + if ($localSourcePath !== null && trim($localSourcePath) !== '') { + $lines = fetchLocalLines($localSourcePath); + return [$lines, realpath($localSourcePath) ?: $localSourcePath]; + } + + try { + $lines = fetchRemoteLines($remoteUrl); + return [$lines, $remoteUrl]; + } catch (Throwable $remoteError) { + $defaultLocalPath = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'Soft00Cmt.txt'; + if (is_file($defaultLocalPath)) { + $lines = fetchLocalLines($defaultLocalPath); + return [$lines, realpath($defaultLocalPath) ?: $defaultLocalPath]; + } + + throw new RuntimeException( + $remoteError->getMessage() + . ' | Lege alternativ eine lokale Datei unter scripts/import/Soft00Cmt.txt ab ' + . 'oder uebergib ihren Pfad als Argument bzw. ?source=' + ); + } +} + +function fetchLocalLines(string $path): array +{ + if (!is_file($path)) { + throw new RuntimeException('Lokale Datei nicht gefunden: ' . $path); + } + + $contents = @file_get_contents($path); + if ($contents === false) { + $error = error_get_last(); + throw new RuntimeException('Lokale Datei konnte nicht gelesen werden: ' . ($error['message'] ?? $path)); + } + + return preg_split("/\r\n|\n|\r/", $contents) ?: []; +} + +function fetchRemoteLinesViaCurl(string $url): array { $ch = curl_init($url); @@ -44,10 +214,9 @@ function fetchRemoteJson(string $url): array CURLOPT_FOLLOWLOCATION => true, CURLOPT_CONNECTTIMEOUT => 20, CURLOPT_TIMEOUT => 300, - CURLOPT_USERAGENT => 'AstroMINT MPC Comet Importer/1.0', + CURLOPT_USERAGENT => 'AstroMINT MPC Comet Importer/2.0', CURLOPT_HTTPHEADER => [ - 'Accept: application/json', - 'Accept-Encoding: gzip', + 'Accept: text/plain', ], ]); @@ -58,234 +227,363 @@ function fetchRemoteJson(string $url): array } $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - if ($httpCode !== 200) { throw new RuntimeException('HTTP-Fehler beim Abruf: ' . $httpCode); } - $isGzip = str_ends_with(strtolower($url), '.gz') - || str_contains(strtolower($contentType), 'gzip') - || str_starts_with($response, "\x1f\x8b"); - - if ($isGzip) { - $decoded = gzdecode($response); - if ($decoded === false) { - throw new RuntimeException('GZip-Datei konnte nicht entpackt werden'); - } - $response = $decoded; - } - - $data = json_decode($response, true); - if (!is_array($data)) { - throw new RuntimeException('Ungültige JSON-Antwort'); - } - - return $data; + return preg_split("/\r\n|\n|\r/", $response) ?: []; } -function toNullableString(mixed $value): ?string +function fetchRemoteLinesViaStream(string $url): array { - if ($value === null) { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 300, + 'ignore_errors' => true, + 'header' => implode("\r\n", [ + 'User-Agent: AstroMINT MPC Comet Importer/2.0', + 'Accept: text/plain', + ]), + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $response = @file_get_contents($url, false, $context); + if ($response === false) { + $error = error_get_last(); + throw new RuntimeException('Download fehlgeschlagen: ' . ($error['message'] ?? 'unbekannter Fehler')); + } + + $statusLine = null; + foreach (($http_response_header ?? []) as $headerLine) { + if (stripos($headerLine, 'HTTP/') === 0) { + $statusLine = $headerLine; + break; + } + } + + if ($statusLine !== null && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) { + $httpCode = (int) $matches[1]; + if ($httpCode !== 200) { + throw new RuntimeException('HTTP-Fehler beim Abruf: ' . $httpCode); + } + } + + return preg_split("/\r\n|\n|\r/", $response) ?: []; +} + +function fetchRemoteLinesViaSocket(string $url, ?string $previousError = null): array +{ + $parts = parse_url($url); + if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || !isset($parts['host'])) { + throw new RuntimeException('Socket-Fallback unterstuetzt nur HTTPS-URLs.'); + } + + $host = (string) $parts['host']; + $path = (string) ($parts['path'] ?? '/'); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $target = 'ssl://' . $host . ':443'; + + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'SNI_enabled' => true, + 'peer_name' => $host, + ], + ]); + + $errno = 0; + $errstr = ''; + $socket = @stream_socket_client($target, $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context); + + if ($socket === false) { + $detail = $errstr !== '' ? $errstr : 'Verbindung konnte nicht aufgebaut werden'; + if ($previousError !== null && $previousError !== '') { + $detail .= ' | file_get_contents: ' . $previousError; + } + throw new RuntimeException('Download fehlgeschlagen: ' . $detail); + } + + stream_set_timeout($socket, 300); + + $request = implode("\r\n", [ + 'GET ' . $path . $query . ' HTTP/1.1', + 'Host: ' . $host, + 'User-Agent: AstroMINT MPC Comet Importer/2.0', + 'Accept: text/plain', + 'Connection: close', + '', + '', + ]); + + fwrite($socket, $request); + $response = stream_get_contents($socket); + fclose($socket); + + if ($response === false || $response === '') { + throw new RuntimeException('Download fehlgeschlagen: leere Antwort vom Server'); + } + + $separatorPos = strpos($response, "\r\n\r\n"); + if ($separatorPos === false) { + throw new RuntimeException('Download fehlgeschlagen: ungueltige HTTP-Antwort'); + } + + $headerText = substr($response, 0, $separatorPos); + $body = substr($response, $separatorPos + 4); + $headerLines = explode("\r\n", $headerText); + $statusLine = $headerLines[0] ?? ''; + + if (preg_match('/\s(\d{3})\s/', $statusLine, $matches) !== 1) { + throw new RuntimeException('Download fehlgeschlagen: HTTP-Status unbekannt'); + } + + $httpCode = (int) $matches[1]; + if ($httpCode !== 200) { + throw new RuntimeException('HTTP-Fehler beim Abruf: ' . $httpCode); + } + + $headers = []; + foreach (array_slice($headerLines, 1) as $headerLine) { + $parts = explode(':', $headerLine, 2); + if (count($parts) === 2) { + $headers[strtolower(trim($parts[0]))] = trim($parts[1]); + } + } + + if (($headers['transfer-encoding'] ?? '') === 'chunked') { + $body = decodeChunkedHttpBody($body); + } + + return preg_split("/\r\n|\n|\r/", $body) ?: []; +} + +function decodeChunkedHttpBody(string $body): string +{ + $decoded = ''; + $offset = 0; + $length = strlen($body); + + while ($offset < $length) { + $lineEnd = strpos($body, "\r\n", $offset); + if ($lineEnd === false) { + break; + } + + $hexLength = trim(substr($body, $offset, $lineEnd - $offset)); + if ($hexLength === '') { + break; + } + + $chunkLength = hexdec($hexLength); + $offset = $lineEnd + 2; + + if ($chunkLength === 0) { + break; + } + + $decoded .= substr($body, $offset, $chunkLength); + $offset += $chunkLength + 2; + } + + return $decoded; +} + +function parseSoft00CmtLine(string $line): array +{ + $padded = str_pad($line, 172, ' '); + + $designationPacked = requireStringField(readFixedField($padded, 1, 12), 'designation_packed'); + $designationAndName = requireStringField(readFixedField($padded, 103, 158), 'designation_and_name'); + $epochCompact = toNullableCompactDate(readFixedField($padded, 82, 89)); + + return [ + ':designation_packed' => $designationPacked, + ':orbit_type' => deriveOrbitType($designationAndName, $designationPacked), + ':year_of_perihelion' => requireIntField(readFixedField($padded, 15, 18), 'year_of_perihelion'), + ':month_of_perihelion' => requireIntField(readFixedField($padded, 20, 21), 'month_of_perihelion'), + ':day_of_perihelion' => requireFloatField(readFixedField($padded, 23, 29), 'day_of_perihelion'), + ':perihelion_dist_au' => requireFloatField(readFixedField($padded, 32, 39), 'perihelion_dist_au'), + ':eccentricity' => requireFloatField(readFixedField($padded, 42, 49), 'eccentricity'), + ':arg_perihelion_deg' => requireFloatField(readFixedField($padded, 52, 59), 'arg_perihelion_deg'), + ':ascending_node_deg' => requireFloatField(readFixedField($padded, 62, 69), 'ascending_node_deg'), + ':inclination_deg' => requireFloatField(readFixedField($padded, 72, 79), 'inclination_deg'), + ':epoch_compact' => $epochCompact, + ':epoch_date' => $epochCompact !== null ? compactDateToSql($epochCompact) : null, + ':absolute_magnitude_h' => toNullableFloat(readFixedField($padded, 91, 95)), + ':slope_parameter_g' => toNullableFloat(readFixedField($padded, 97, 101)), + ':designation_and_name' => $designationAndName, + ':reference_text' => toNullableString(readFixedField($padded, 160, 200)), + ':raw_record' => $line, + ]; +} + +function deriveOrbitType(string $designationAndName, string $designationPacked): string +{ + if (preg_match('/^\d+([A-Z])\//', $designationAndName, $matches) === 1) { + return $matches[1]; + } + + if (preg_match('/^([A-Z])\//', $designationAndName, $matches) === 1) { + return $matches[1]; + } + + if (preg_match('/([A-Z])/', ltrim($designationPacked), $matches) === 1) { + return $matches[1]; + } + + throw new RuntimeException('orbit_type konnte nicht abgeleitet werden'); +} + +function readFixedField(string $line, int $startColumn, int $endColumn): ?string +{ + $offset = $startColumn - 1; + $length = $endColumn - $startColumn + 1; + $value = substr($line, $offset, $length); + + if ($value === false) { return null; } - $value = trim((string) $value); + $value = trim($value); return $value === '' ? null : $value; } -function toNullableInt(mixed $value): ?int +function requireStringField(?string $value, string $fieldName): string { if ($value === null || $value === '') { - return null; + throw new RuntimeException("Pflichtfeld fehlt oder ist ungueltig: {$fieldName}"); } - if (!is_numeric($value)) { - return null; + return $value; +} + +function requireIntField(?string $value, string $fieldName): int +{ + $value = trim((string) $value); + if ($value === '' || preg_match('/^-?\d+$/', $value) !== 1) { + throw new RuntimeException("Pflichtfeld fehlt oder ist ungueltig: {$fieldName}"); } return (int) $value; } -function toNullableFloat(mixed $value): ?float +function requireFloatField(?string $value, string $fieldName): float { - if ($value === null || $value === '') { + $value = trim((string) $value); + if ($value === '' || !is_numeric($value)) { + throw new RuntimeException("Pflichtfeld fehlt oder ist ungueltig: {$fieldName}"); + } + + return (float) $value; +} + +function toNullableCompactDate(?string $value): ?string +{ + $value = trim((string) $value); + if ($value === '') { return null; } - if (!is_numeric($value)) { + if (preg_match('/^\d{8}$/', $value) !== 1) { + throw new RuntimeException('epoch_compact ist ungueltig'); + } + + return $value; +} + +function toNullableString(?string $value): ?string +{ + if ($value === null) { + return null; + } + + $value = trim($value); + return $value === '' ? null : $value; +} + +function toNullableFloat(?string $value): ?float +{ + if ($value === null) { + return null; + } + + $value = trim($value); + if ($value === '' || !is_numeric($value)) { return null; } return (float) $value; } -function requireIntField(array $row, string $fieldName): int +function compactDateToSql(string $value): ?string { - $value = toNullableInt($row[$fieldName] ?? null); - if ($value === null) { - throw new RuntimeException("Pflichtfeld fehlt oder ist ungültig: {$fieldName}"); + if (preg_match('/^\d{8}$/', $value) !== 1) { + return null; } - return $value; + $year = (int) substr($value, 0, 4); + $month = (int) substr($value, 4, 2); + $day = (int) substr($value, 6, 2); + + if (!checkdate($month, $day, $year)) { + return null; + } + + return sprintf('%04d-%02d-%02d', $year, $month, $day); } -function requireFloatField(array $row, string $fieldName): float +function normalizeDbConfig(array $config): array { - $value = toNullableFloat($row[$fieldName] ?? null); - if ($value === null) { - throw new RuntimeException("Pflichtfeld fehlt oder ist ungültig: {$fieldName}"); - } - - return $value; + return [ + 'host' => (string) ($config['host'] ?? '127.0.0.1'), + 'port' => (int) ($config['port'] ?? 3306), + 'dbname' => (string) ($config['dbname'] ?? ''), + 'user' => (string) ($config['user'] ?? ''), + 'pass' => (string) ($config['pass'] ?? ''), + 'charset' => (string) ($config['charset'] ?? 'utf8mb4'), + ]; } -function requireStringField(array $row, string $fieldName): string +function rebuildCometsTable(PDO $pdo): void { - $value = toNullableString($row[$fieldName] ?? null); - if ($value === null) { - throw new RuntimeException("Pflichtfeld fehlt oder ist ungültig: {$fieldName}"); - } - - return $value; -} - -$recordsReceived = 0; -$recordsInserted = 0; -$recordsUpdated = 0; -$recordsSkipped = 0; - -try { - $rows = fetchRemoteJson($sourceUrl); - - if ($rows === [] || array_is_list($rows) === false) { - throw new RuntimeException('Es wurde keine gültige Kometenliste empfangen'); - } - - $stmtExists = $pdo->prepare(" - SELECT id - FROM comets_mpc - WHERE designation_and_name = :designation_and_name - LIMIT 1 - "); - - $stmtUpsert = $pdo->prepare(" - INSERT INTO comets_mpc ( - orbit_type, - provisional_packed_desig, - year_of_perihelion, - month_of_perihelion, - day_of_perihelion, - perihelion_dist, - eccentricity, - arg_perihelion_deg, - ascending_node_deg, - inclination_deg, - absolute_magnitude_h, - slope_parameter_g, - designation_and_name, - reference_text, - raw_json, - source_url, - imported_at, - created_at, - updated_at - ) VALUES ( - :orbit_type, - :provisional_packed_desig, - :year_of_perihelion, - :month_of_perihelion, - :day_of_perihelion, - :perihelion_dist, - :eccentricity, - :arg_perihelion_deg, - :ascending_node_deg, - :inclination_deg, - :absolute_magnitude_h, - :slope_parameter_g, - :designation_and_name, - :reference_text, - :raw_json, - :source_url, - NOW(), - NOW(), - NOW() - ) - ON DUPLICATE KEY UPDATE - orbit_type = VALUES(orbit_type), - provisional_packed_desig = VALUES(provisional_packed_desig), - year_of_perihelion = VALUES(year_of_perihelion), - month_of_perihelion = VALUES(month_of_perihelion), - day_of_perihelion = VALUES(day_of_perihelion), - perihelion_dist = VALUES(perihelion_dist), - eccentricity = VALUES(eccentricity), - arg_perihelion_deg = VALUES(arg_perihelion_deg), - ascending_node_deg = VALUES(ascending_node_deg), - inclination_deg = VALUES(inclination_deg), - absolute_magnitude_h = VALUES(absolute_magnitude_h), - slope_parameter_g = VALUES(slope_parameter_g), - reference_text = VALUES(reference_text), - raw_json = VALUES(raw_json), - source_url = VALUES(source_url), - imported_at = NOW(), - updated_at = NOW() - "); - - $pdo->beginTransaction(); - - foreach ($rows as $row) { - try { - $designationAndName = requireStringField($row, 'Designation_and_name'); - - $recordsReceived++; - - $stmtExists->execute([ - ':designation_and_name' => $designationAndName, - ]); - $exists = (bool) $stmtExists->fetchColumn(); - - $stmtUpsert->execute([ - ':orbit_type' => substr(requireStringField($row, 'Orbit_type'), 0, 1), - ':provisional_packed_desig' => toNullableString($row['Provisional_packed_desig'] ?? null), - ':year_of_perihelion' => requireIntField($row, 'Year_of_perihelion'), - ':month_of_perihelion' => requireIntField($row, 'Month_of_perihelion'), - ':day_of_perihelion' => requireFloatField($row, 'Day_of_perihelion'), - ':perihelion_dist' => requireFloatField($row, 'Perihelion_dist'), - ':eccentricity' => requireFloatField($row, 'e'), - ':arg_perihelion_deg' => requireFloatField($row, 'Peri'), - ':ascending_node_deg' => requireFloatField($row, 'Node'), - ':inclination_deg' => requireFloatField($row, 'i'), - ':absolute_magnitude_h' => toNullableFloat($row['H'] ?? null), - ':slope_parameter_g' => toNullableFloat($row['G'] ?? null), - ':designation_and_name' => $designationAndName, - ':reference_text' => toNullableString($row['Ref'] ?? null), - ':raw_json' => json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ':source_url' => $sourceUrl, - ]); - - if ($exists) { - $recordsUpdated++; - } else { - $recordsInserted++; - } - } catch (Throwable $rowError) { - $recordsSkipped++; - echo "Übersprungen: " . $rowError->getMessage() . "\n"; - } - } - - $pdo->commit(); - - echo "Import erfolgreich\n"; - echo "Empfangen: {$recordsReceived}\n"; - echo "Neu: {$recordsInserted}\n"; - echo "Aktualisiert: {$recordsUpdated}\n"; - echo "Übersprungen: {$recordsSkipped}\n"; -} catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - - echo "Fehler: " . $e->getMessage() . "\n"; - exit(1); + $pdo->exec('DROP TABLE IF EXISTS `comets_mpc`'); + $pdo->exec(<<<'SQL' +CREATE TABLE `comets_mpc` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `designation_packed` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT 'Fixed-width MPC-Packbezeichnung aus Soft00Cmt.txt, inkl. Fragmentkennung', + `orbit_type` char(1) NOT NULL COMMENT 'Abgeleiteter Kometentyp, z. B. C, P oder I', + `year_of_perihelion` smallint(6) NOT NULL COMMENT 'Jahr des Periheldurchgangs', + `month_of_perihelion` tinyint(3) unsigned NOT NULL COMMENT 'Monat des Periheldurchgangs', + `day_of_perihelion` decimal(8,4) NOT NULL COMMENT 'Tag des Periheldurchgangs inklusive Tagesbruchteil', + `perihelion_dist_au` decimal(12,6) NOT NULL COMMENT 'Periheldistanz q in AE', + `eccentricity` decimal(12,6) NOT NULL COMMENT 'Exzentrizitaet e', + `arg_perihelion_deg` decimal(9,4) NOT NULL COMMENT 'Argument des Perihels in Grad', + `ascending_node_deg` decimal(9,4) NOT NULL COMMENT 'Laenge des aufsteigenden Knotens in Grad', + `inclination_deg` decimal(9,4) NOT NULL COMMENT 'Bahnneigung in Grad', + `epoch_compact` char(8) DEFAULT NULL COMMENT 'Epoche des Elementsatzes als YYYYMMDD; kann bei Fragmenten fehlen', + `epoch_date` date DEFAULT NULL COMMENT 'Epoche des Elementsatzes als SQL-Datum', + `absolute_magnitude_h` decimal(5,1) DEFAULT NULL COMMENT 'Absolute Helligkeit H laut MPC', + `slope_parameter_g` decimal(4,1) DEFAULT NULL COMMENT 'Steigungsparameter G laut MPC', + `designation_and_name` varchar(255) NOT NULL COMMENT 'Lesbare Bezeichnung, z. B. C/1995 O1 (Hale-Bopp)', + `reference_text` varchar(64) DEFAULT NULL COMMENT 'Referenz aus der MPC-Datei, z. B. MPEC 2026-FC3', + `raw_record` varchar(255) DEFAULT NULL COMMENT 'Originale Fixed-width-Zeile aus Soft00Cmt.txt', + `source_url` varchar(255) DEFAULT NULL COMMENT 'Quelle des letzten Imports', + `imported_at` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'Zeitpunkt des letzten Imports', + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_mpc_comets_designation_packed` (`designation_packed`), + KEY `idx_mpc_comets_orbit_type` (`orbit_type`), + KEY `idx_mpc_comets_perihelion_year_month` (`year_of_perihelion`,`month_of_perihelion`), + KEY `idx_mpc_comets_epoch_compact` (`epoch_compact`), + KEY `idx_mpc_comets_designation_and_name` (`designation_and_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Kometenelemente aus Soft00Cmt.txt des Minor Planet Center' +SQL + ); }