Files
tsv08kulmbach-website/bin/chronik-add.php
fs a101b0c3ec Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00

492 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Fundstücke für die Vereinschronik 2028 einsammeln und ablegen.
*
* Holt zu einer Web-Adresse Überschrift, Datum, Teaser, Bildnachweis und das Aufmacherbild,
* legt beides im richtigen Jahrzehnt-Ordner unter Vereinshistorie/ ab, schreibt einen
* Quellen-Beleg daneben und eine Zeile ins Register (docs/chronik/register.csv).
*
* Warum überhaupt ein Skript und kein „Rechtsklick, Bild speichern": das Bild allein ist
* in fünf Jahren wertlos. Woher es stammt, wann es entstand und wem es gehört, steht nirgends
* im JPEG — und genau das braucht man, wenn die Chronik in den Druck geht. Das Skript hält
* den Zusammenhang fest, in dem Moment, in dem er noch bekannt ist.
*
* Es wird NICHTS massenhaft abgegriffen: pro Aufruf eine Seite, mit Pause zwischen den
* Abrufen. Die Presseportale haben keine Übersichtsseite mehr, über die man den Verein
* vollständig abfragen könnte — die Fundstellen kommen aus gezielter Suche, nicht aus
* einem Crawler.
*
* NUR CLI, nie im Request-Pfad (wie alle bin/-Skripte).
*
* Aufruf:
* php bin/chronik-add.php <URL> [--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=<pfad> --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.*?</\1>#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, '/<meta property="og:title" content="([^"]+)"/i')
?? chronik_match($html, '/<h1[^>]*>(.*?)<\/h1>/is')
?? chronik_match($html, '/<title[^>]*>(.*?)<\/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, '/<meta property="article:published_time" content="(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/"datePublished"\s*:\s*"(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/datetime="(\d{4}-\d{2}-\d{2})/i');
$teaser = chronik_match($html, '/<meta property="og:description" content="([^"]+)"/i')
?? chronik_match($html, '/<meta name="description" content="([^"]+)"/i');
$bild = chronik_match($html, '/<meta property="og:image" content="([^"]+)"/i');
// Bildnachweis steht bei Presseseiten fast immer in der Bildunterschrift
// („Foto: Max Mustermann") — genau die Angabe, die später fehlt.
$urheber = null;
if (preg_match_all('/<figcaption[^>]*>(.*?)<\/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, '/<meta name="author" content="([^"]+)"/i');
return compact('titel', 'datum', 'teaser', 'bild', 'urheber');
}
// --- Ein Fundstück verarbeiten ---------------------------------------------
function chronik_add(string $url, array $opts, array $override = []): bool
{
$root = chronik_root();
$heute = date('Y-m-d');
if (chronik_register_has_url($url) && !isset($opts['erneut'])) {
echo "· schon im Register: {$url}\n";
return true;
}
// Bei Wayback-Adressen zählt für Rechte und Quellenkürzel die ARCHIVIERTE Seite,
// nicht das Archiv — abgerufen wird trotzdem der Schnappschuss.
$quellUrl = chronik_origin_url($url);
$host = (string) (parse_url($quellUrl, PHP_URL_HOST) ?: '');
if ($host === '') {
fwrite(STDERR, "✗ Keine gültige Adresse: {$url}\n");
return false;
}
$rights = chronik_rights($host);
$meta = ['titel' => 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=<Host>
// 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 <URL> | --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);