Files

492 lines
19 KiB
PHP
Raw Permalink Normal View History

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
<?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);