[--datum=1972-07-15] [--titel="…"] [--art=Zeitungsartikel] * [--notiz="…"] [--urheber="…"] [--ohne-bild] [--nur-register] * php bin/chronik-add.php --liste=urls.txt Zeilen: URL | Datum | Titel (| optional) * php bin/chronik-add.php --datei= --datum=1983-06-11 --titel="Festzug 75 Jahre" * [--quelle="Album Familie Rausch"] [--urheber="…"] [--art=Foto] * [--rechte-klaeren] * php bin/chronik-add.php --rechte nur 00_RECHTE-ZU-KLAEREN.txt neu schreiben * php bin/chronik-add.php --bericht Registerübersicht auf stdout * * Optionen --datum/--titel gewinnen immer gegen das, was die Seite selbst angibt. * --datei benennt die Datei um und verschiebt sie in den passenden Jahrzehnt-Ordner. */ if (PHP_SAPI !== 'cli') { exit(1); } require dirname(__DIR__) . '/app/bootstrap.php'; require __DIR__ . '/chronik-lib.php'; $opts = []; $positional = []; foreach (array_slice($argv, 1) as $arg) { if (preg_match('/^--([a-z-]+)(?:=(.*))?$/s', $arg, $m)) { $opts[$m[1]] = $m[2] ?? true; } else { $positional[] = $arg; } } $root = chronik_root(); if (!is_dir($root)) { fwrite(STDERR, "Vereinshistorie/ fehlt — erst 'php bin/chronik-init.php' laufen lassen.\n"); exit(1); } // --- Nebenmodi ------------------------------------------------------------- if (isset($opts['rechte'])) { $n = chronik_write_rights_todo(); echo "00_RECHTE-ZU-KLAEREN.txt geschrieben — {$n} offene Posten.\n"; exit(0); } if (isset($opts['bericht'])) { chronik_report(); exit(0); } // --- HTTP ------------------------------------------------------------------ /** * Browser-typische Header. Ohne sie antworten mehrere der Quellen mit 403 — * dieselbe Erfahrung wie beim BFV-Widget (dort HTTP 418), siehe matchcenter-sync.php. */ function chronik_fetch(string $url, ?string $referer = null): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => 25, CURLOPT_ENCODING => '', CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' . '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', CURLOPT_HTTPHEADER => array_filter([ 'Accept: text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8', 'Accept-Language: de-DE,de;q=0.9', $referer !== null ? 'Referer: ' . $referer : null, ]), ]); $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); return [ 'ok' => $body !== false && $status === 200, 'status' => $status, 'body' => $body === false ? '' : $body, 'type' => $type, 'url' => $final !== '' ? $final : $url, ]; } /** Ersten Treffer eines Musters liefern, HTML-Entities aufgelöst. */ function chronik_match(string $html, string $pattern): ?string { if (!preg_match($pattern, $html, $m)) { return null; } $val = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return $val !== '' ? $val : null; } /** * Fließtext einer Seite herausschälen. * * Grob und absichtlich: Skripte und Styles raus, Tags raus, alles behalten, was länger als * ein Navigationspunkt ist. Für saubere Extraktion bräuchte es pro Portal eine eigene Regel — * das lohnt hier nicht, weil der Text als Beleg dient und nicht als Layout. */ function chronik_body_text(string $html): string { $html = (string) preg_replace('#<(script|style|nav|header|footer|form)\b.*?#is', ' ', $html); $html = (string) preg_replace('#<(br|/p|/div|/h[1-6]|/li|/td|/tr)[^>]*>#i', "\n", $html); $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $keep = []; $seen = []; foreach (explode("\n", $text) as $line) { $line = trim((string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $line)); // 45 Zeichen trennt Fließtext zuverlässig von Menüpunkten und Buttons. if (mb_strlen($line) < 45 || isset($seen[$line])) { continue; } $seen[$line] = true; $keep[] = $line; } return implode("\n\n", $keep); } /** Seite auswerten: was an Metadaten drinsteht, ohne Layout-Annahmen. */ function chronik_parse(string $html): array { $strip = static fn(string $s): string => trim((string) preg_replace('/\s+/', ' ', strip_tags($s))); $titel = chronik_match($html, '/]*>(.*?)<\/h1>/is') ?? chronik_match($html, '/]*>(.*?)<\/title>/is'); if ($titel !== null) { $titel = $strip($titel); // Seitentitel tragen oft " | Portalname" mit — für den Dateinamen stört das nur. $titel = (string) preg_replace('/\s*[|–-]\s*(inFranken\.de|BFV|FUSSBALL\.DE|Deutsche Turnliga)\s*$/iu', '', $titel); } $datum = chronik_match($html, '/]*>(.*?)<\/figcaption>/is', $html, $caps)) { foreach ($caps[1] as $cap) { $cap = $strip(html_entity_decode($cap, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if (preg_match('/\b(Foto|Bild|Quelle|©)\s*:?\s*(.{2,80})$/ui', $cap, $m)) { $urheber = trim($m[2], " .;"); break; } } } $urheber ??= chronik_match($html, '/ null, 'datum' => null, 'teaser' => null, 'bild' => null, 'urheber' => null]; $volltext = ''; if (!isset($opts['nur-register'])) { $res = chronik_fetch($url); if (!$res['ok']) { fwrite(STDERR, "✗ HTTP {$res['status']}: {$url}\n"); log_write('chronik', 'WARN', "Abruf fehlgeschlagen (HTTP {$res['status']}): {$url}"); return false; } $meta = chronik_parse($res['body']); // Die Rechtelage entscheidet, wie viel Text bleibt: eigenes Vereinsmaterial sichern // wir vollständig (sonst ist es weg — die alten Vereinsseiten sind offline), fremde // Presseartikel nur als Nachweis mit Überschrift und Anriss. if (!$rights['klaeren']) { $volltext = chronik_body_text($res['body']); } } $titel = (string) ($override['titel'] ?? $opts['titel'] ?? $meta['titel'] ?? 'Ohne Titel'); $datum = $override['datum'] ?? $opts['datum'] ?? $meta['datum'] ?? null; $urheber = (string) ($opts['urheber'] ?? $meta['urheber'] ?? ''); $art = (string) ($opts['art'] ?? 'Zeitungsartikel'); $notiz = (string) ($opts['notiz'] ?? ''); if ($quellUrl !== $url) { $notiz = trim($notiz . ' Archivfassung (Internet Archive), Original nicht mehr online: ' . $quellUrl); } $tag = chronik_source_tag($host); $bucket = chronik_bucket(is_string($datum) ? $datum : null); $dnorm = chronik_date_norm(is_string($datum) ? $datum : null); $base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag; $dir = $root . '/' . $bucket; if (!is_dir($dir)) { mkdir($dir, 0775, true); } // Namenskollision auflösen. Passiert wirklich: zwei Seiten „Bundesliga" und // „Bundesliga-1" mit gleicher Überschrift und gleichem Jahr ergeben denselben Namen — // ohne Suffix überschreibt der zweite Fund den ersten stillschweigend. // Anker ist die .quelle.txt, die zu jedem Fundstück entsteht. if (is_file($dir . '/' . $base . '.quelle.txt')) { $n = 2; while (is_file($dir . '/' . $base . '-' . $n . '.quelle.txt')) { $n++; } $base .= '-' . $n; } // --- Aufmacherbild --- $bildDatei = ''; if (!isset($opts['ohne-bild']) && !isset($opts['nur-register']) && is_string($meta['bild'])) { $img = chronik_fetch($meta['bild'], $url); $ext = match (true) { str_contains($img['type'], 'png') => 'png', str_contains($img['type'], 'webp') => 'webp', str_contains($img['type'], 'gif') => 'gif', default => 'jpg', }; // 6 KB Untergrenze: Portale liefern bei fehlendem Aufmacher ein Platzhalter-Pixel aus. if ($img['ok'] && strlen($img['body']) > 6144) { $bildDatei = $base . '.' . $ext; file_put_contents($dir . '/' . $bildDatei, $img['body']); } } // --- Textbeleg: das Fundstück bleibt auffindbar, auch wenn die Seite verschwindet --- $belegDatei = $base . '.artikel.txt'; $beleg = implode("\n", array_filter([ $titel, str_repeat('=', min(72, max(8, strlen($titel)))), '', $dnorm !== '0000-00-00' ? 'Erschienen: ' . $dnorm : null, 'Quelle: ' . $host, 'URL: ' . $url, 'Abgerufen: ' . $heute, '', // Bei Volltext ist der Teaser nur dessen erster Absatz — nicht doppelt hinschreiben. ($meta['teaser'] !== null && $volltext === '') ? wordwrap((string) $meta['teaser'], 88, "\n") : null, $volltext === '' ? '' : null, '--', $volltext !== '' ? "Eigenes Vereinsmaterial — vollständig gesichert, weil die Quellseite offline ist." : 'Nur Überschrift, Datum und Anrisstext festgehalten — der vollständige Artikeltext', $volltext !== '' ? '' : 'gehört dem Verlag und wird hier nicht gespeichert. Für die Chronik über die URL', $volltext !== '' ? '' : 'oder das Zeitungsarchiv im Original nachlesen.', $volltext !== '' ? "\n" . wordwrap($volltext, 88, "\n") : null, ], static fn($l): bool => $l !== null)); file_put_contents($dir . '/' . $belegDatei, $beleg . "\n"); // --- Register + Beleg-Sidecar --- $row = [ 'datei' => $bucket . '/' . ($bildDatei !== '' ? $bildDatei : $belegDatei), 'datum' => $dnorm, 'titel' => $titel, 'art' => $art, 'quelle' => $host, 'url' => $url, 'urheber' => $urheber, 'rechte' => $rights['status'], 'klaeren' => $rights['klaeren'] ? 'ja' : 'nein', 'abgerufen' => $heute, 'notiz' => $notiz, ]; chronik_register_append($row); file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis'])); $flag = $rights['klaeren'] ? ' [Rechte klären]' : ''; echo "✓ {$bucket}/{$base}" . ($bildDatei !== '' ? ' (+Bild)' : ' (nur Text)') . $flag . "\n"; log_write('chronik', 'INFO', "Fundstück aufgenommen: {$bucket}/{$base} ({$host})"); return true; } /** * Eine Datei aufnehmen, die schon da ist — Scan aus einer Festschrift, Foto aus einem * privaten Album, per Hand gespeichertes Pressebild. * * Genau derselbe Weg wie bei einer Web-Adresse: umbenennen nach Konvention, ins richtige * Jahrzehnt einsortieren, Beleg daneben, Zeile ins Register. Ohne das landen die Scans aus * dem Sammelaufruf als IMG_4711.jpg im Ordner und sind in einem Jahr nicht mehr zuzuordnen. */ function chronik_add_file(string $path, array $opts): bool { if (!is_file($path)) { fwrite(STDERR, "✗ Datei nicht gefunden: {$path}\n"); return false; } $root = chronik_root(); $heute = date('Y-m-d'); $datum = is_string($opts['datum'] ?? null) ? $opts['datum'] : null; $titel = (string) ($opts['titel'] ?? pathinfo($path, PATHINFO_FILENAME)); $quelle = (string) ($opts['quelle'] ?? 'Vereinsbestand'); $urheber = (string) ($opts['urheber'] ?? ''); $art = (string) ($opts['art'] ?? 'Foto'); $notiz = (string) ($opts['notiz'] ?? ''); // Ohne --quelle gilt: es kommt aus dem Verein und ist nutzbar. Mit --quelle= // greift dieselbe Einschätzung wie bei einem Abruf aus dem Netz. $rights = str_contains($quelle, '.') ? chronik_rights($quelle) : ['status' => 'Vereinsbestand — Herkunft ' . $quelle, 'klaeren' => false, 'hinweis' => 'Aus dem Bestand des Vereins bzw. von Mitgliedern. Bei erkennbaren Personen ' . 'die Einwilligung zur Veröffentlichung einholen, bei Kindern die Eltern fragen.']; if (isset($opts['rechte-klaeren'])) { $rights['klaeren'] = true; } $tag = str_contains($quelle, '.') ? chronik_source_tag($quelle) : strtoupper(chronik_slug($quelle, 12)); $bucket = chronik_bucket($datum); $dnorm = chronik_date_norm($datum); $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)) ?: 'bin'; $base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag; $dir = $root . '/' . $bucket; if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { fwrite(STDERR, "✗ Zielordner nicht anlegbar: {$bucket}\n"); return false; } $ziel = $dir . '/' . $base . '.' . $ext; if (realpath($path) !== realpath($ziel) && !rename($path, $ziel)) { fwrite(STDERR, "✗ Verschieben fehlgeschlagen: {$path}\n"); return false; } $row = [ 'datei' => $bucket . '/' . $base . '.' . $ext, 'datum' => $dnorm, 'titel' => $titel, 'art' => $art, 'quelle' => $quelle, 'url' => (string) ($opts['url'] ?? ''), 'urheber' => $urheber, 'rechte' => $rights['status'], 'klaeren' => $rights['klaeren'] ? 'ja' : 'nein', 'abgerufen' => $heute, 'notiz' => $notiz, ]; chronik_register_append($row); file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis'])); echo "✓ {$bucket}/{$base}.{$ext}" . ($rights['klaeren'] ? ' [Rechte klären]' : '') . "\n"; return true; } /** Registerübersicht — was haben wir, wo klafft die Lücke. */ function chronik_report(): void { $path = chronik_register_path(); if (!is_file($path)) { echo "Register ist noch leer.\n"; return; } $fh = fopen($path, 'r'); fgetcsv($fh, 0, ';', '"', '\\'); $perBucket = array_fill_keys(array_merge(array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]), 0); $perQuelle = []; $offen = 0; $gesamt = 0; while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) { $gesamt++; $bucket = explode('/', (string) ($r[0] ?? ''))[0]; if (isset($perBucket[$bucket])) { $perBucket[$bucket]++; } $perQuelle[$r[4] ?? '?'] = ($perQuelle[$r[4] ?? '?'] ?? 0) + 1; if (($r[8] ?? '') === 'ja') { $offen++; } } fclose($fh); echo "Chronik-Register — {$gesamt} Fundstücke, {$offen} mit offenen Rechten\n\n"; echo "Nach Zeitraum:\n"; foreach ($perBucket as $b => $n) { printf(" %-28s %3d %s\n", $b, $n, $n === 0 ? '← Lücke' : str_repeat('▌', min(40, $n))); } echo "\nNach Quelle:\n"; arsort($perQuelle); foreach ($perQuelle as $q => $n) { printf(" %-28s %3d\n", $q, $n); } } // --- Hauptlauf ------------------------------------------------------------- if (isset($opts['datei'])) { $ok = chronik_add_file((string) $opts['datei'], $opts); $offen = chronik_write_rights_todo(); echo "\n{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n"; exit($ok ? 0 : 1); } $jobs = []; if (isset($opts['liste'])) { $listFile = (string) $opts['liste']; if (!is_file($listFile)) { fwrite(STDERR, "Liste nicht gefunden: {$listFile}\n"); exit(1); } foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#')) { continue; } $parts = array_map('trim', explode('|', $line)); $jobs[] = [ 'url' => $parts[0], 'datum' => ($parts[1] ?? '') !== '' ? $parts[1] : null, 'titel' => ($parts[2] ?? '') !== '' ? $parts[2] : null, ]; } } elseif ($positional !== []) { $jobs[] = ['url' => $positional[0], 'datum' => null, 'titel' => null]; } else { fwrite(STDERR, "Aufruf: php bin/chronik-add.php | --liste=urls.txt | --rechte | --bericht\n"); exit(1); } $ok = 0; $fehler = 0; foreach ($jobs as $i => $job) { if ($i > 0) { // Pause zwischen den Abrufen: das hier ist Recherche, kein Crawl. usleep(1_500_000); } $override = array_filter(['datum' => $job['datum'], 'titel' => $job['titel']], static fn($v): bool => $v !== null); chronik_add($job['url'], $opts, $override) ? $ok++ : $fehler++; } $offen = chronik_write_rights_todo(); echo "\n{$ok} aufgenommen" . ($fehler > 0 ? ", {$fehler} fehlgeschlagen" : '') . ".\n"; echo "{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n"; exit($fehler > 0 ? 1 : 0);