|
- <?php
- namespace VPSManager;
-
- final class BackupParser
- {
- private static function text(\DOMNode $node): string
- {
- return trim(preg_replace('/\s+/u', ' ', $node->textContent));
- }
-
- public static function number(?string $value): ?float
- {
- if ($value === null || !preg_match('/\A[0-9]+(?:\.[0-9]+)?\z/', $value)) return null;
- $n = (float) $value;
- return is_finite($n) ? $n : null;
- }
-
- public static function timestamp(string $value, \DateTimeZone $zone): int
- {
- $date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $value, $zone);
- if (!$date || $date->format('Y-m-d H:i') !== $value) throw new \RuntimeException('DATE');
- // A repeated local minute cannot be assigned an offset from these HTML exports.
- $stamp = $date->getTimestamp();
- $transitions = $zone->getTransitions($stamp - 86400, $stamp + 86400);
- foreach ($transitions ?: [] as $transition) {
- $candidate = $stamp + $date->getOffset() - $transition['offset'];
- if ($candidate !== $stamp && (new \DateTimeImmutable('@' . $candidate))->setTimezone($zone)->format('Y-m-d H:i') === $value) {
- throw new \RuntimeException('DATE_AMBIGUOUS');
- }
- }
- return $stamp;
- }
-
- public static function parse(string $html, string $host, string $timezone): array
- {
- if (!Backups::validHost($host) || !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) throw new \RuntimeException('CONFIG');
- $zone = new \DateTimeZone($timezone);
- if (!class_exists('DOMDocument')) throw new \RuntimeException('DOM');
- $previous = libxml_use_internal_errors(true);
- try {
- $doc = new \DOMDocument();
- $doc->resolveExternals = false;
- $doc->substituteEntities = false;
- if (!$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) throw new \RuntimeException('HTML');
- } finally {
- libxml_clear_errors();
- libxml_use_internal_errors($previous);
- }
- $xp = new \DOMXPath($doc);
- $headings = $xp->query('//h1');
- if ($headings->length !== 1 || self::text($headings->item(0)) !== 'Host ' . $host . ' Backup Summary') throw new \RuntimeException('IDENTITY');
- $sets = [];
- foreach ($xp->query('//table') as $table) {
- $headers = null; $kind = null; $rows = [];
- foreach ($xp->query('./tr|./thead/tr|./tbody/tr', $table) as $row) {
- $cells = [];
- foreach ($xp->query('./td|./th', $row) as $cell) $cells[] = self::text($cell);
- if (($cells[0] ?? '') === 'Backup#') {
- if ($headers !== null) throw new \RuntimeException('DUPLICATE_HEADER');
- $headers = $cells;
- if (in_array('Start Date', $cells, true) && in_array('Duration/mins', $cells, true)) $kind = 'copies';
- elseif (in_array('#Xfer errs', $cells, true)) $kind = 'errors';
- elseif (array_slice($cells, 0, 5) === ['Backup#', 'Type', '#Files', 'Size/MiB', 'MiB/sec']) {
- // Verify that the first size column actually belongs to Totals.
- $first = $xp->query('./tr|./thead/tr|./tbody/tr', $table)->item(0);
- $groups = $xp->query('./td|./th', $first);
- if ($groups->length >= 2 && self::text($groups->item(1)) === 'Totals'
- && $groups->item(0)->getAttribute('colspan') === '2'
- && $groups->item(1)->getAttribute('colspan') === '3') $kind = 'sizes';
- }
- continue;
- }
- if ($kind === null) continue;
- if (!preg_match('/\A[0-9]+\z/', $cells[0] ?? '')) throw new \RuntimeException('ROW');
- $id = $cells[0];
- if (isset($rows[$id])) throw new \RuntimeException('DUPLICATE');
- if (count($cells) !== count($headers)) {
- if ($kind === 'copies') throw new \RuntimeException('ROW_WIDTH');
- $rows[$id] = [];
- continue;
- }
- $data = [];
- foreach ($headers as $i => $name) if (!array_key_exists($name, $data)) $data[$name] = $cells[$i] ?? null;
- $rows[$id] = $data;
- }
- if ($kind !== null) {
- if (isset($sets[$kind])) throw new \RuntimeException('TABLE_DUPLICATE');
- $sets[$kind] = $rows;
- }
- }
- if (!isset($sets['copies'])) throw new \RuntimeException('MISSING_TABLE');
- // Recognized empty summary table is explicit evidence of zero retained rows.
- $copies = []; $full = 0; $incremental = 0;
- foreach ($sets['copies'] as $id => $row) {
- $type = ['full' => 'Completa', 'incr' => 'Incremental'][$row['Type'] ?? ''] ?? 'Información no disponible';
- if ($type === 'Completa') $full++;
- if ($type === 'Incremental') $incremental++;
- $errors = $sets['errors'][$id] ?? [];
- $counters = [];
- foreach (['#Xfer errs', '#bad files', '#bad share', '#tar errs'] as $key) {
- $v = $errors[$key] ?? null;
- $counters[] = is_string($v) && preg_match('/\A[0-9]+\z/', $v) ? self::number($v) : null;
- }
- $incidents = 'Información no disponible';
- if (count(array_filter($counters, static fn($v) => $v !== null && $v > 0))) $incidents = 'Con incidencias';
- elseif (!in_array(null, $counters, true)) $incidents = 'Sin incidencias registradas';
- $size = self::number($sets['sizes'][$id]['Size/MiB'] ?? null);
- $copies[] = [
- 'start' => self::timestamp($row['Start Date'] ?? '', $zone),
- 'type' => $type,
- 'duration_minutes' => self::number($row['Duration/mins'] ?? null),
- 'size_gib' => $size === null ? null : $size / 1024,
- 'incidents' => $incidents,
- ];
- }
- usort($copies, static fn($a, $b) => $b['start'] <=> $a['start']);
- $lastFull = null;
- foreach ($copies as $copy) if ($copy['type'] === 'Completa') { $lastFull = $copy['start']; break; }
- $activity = null;
- foreach ($xp->query('//li') as $li) {
- if (preg_match('/\ALast status is state "idle" \(done\) as of [0-9 :.-]+\.\z/', self::text($li))) $activity = 'Sin actividad en curso';
- }
- return ['total' => count($copies), 'full' => $full, 'incremental' => $incremental,
- 'last_full' => $lastFull, 'activity' => $activity, 'timezone' => $timezone, 'copies' => $copies];
- }
- }
|