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]; } }