Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen
bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden verworfen. Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt; das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und --bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb war an Startseite, News-Feed und Stadionzeitung nichts zu aendern. Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von bin/logo-build.php und bin/instagram-add.php. data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen, drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt 16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
This commit is contained in:
63
bin/chrome-lib.php
Normal file
63
bin/chrome-lib.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Headless Chrome für die CLI-Werkzeuge (nur CLI, bewusst NICHT in app/helpers.php).
|
||||
*
|
||||
* Zwei Skripte brauchen einen echten Browser, beide laufen ausschließlich lokal auf einem
|
||||
* Arbeitsrechner und nie auf dem Server:
|
||||
* - bin/logo-build.php rastert SVG (GD kann kein SVG) und druckt Vektor-PDF
|
||||
* - bin/instagram-add.php liest mit --profil die Beitragsliste vom Instagram-Profil
|
||||
*
|
||||
* Auf dem All-Inkl-Webspace gibt es kein Chrome. Das ist kein Mangel, sondern die Grenze:
|
||||
* was diesen Helfer braucht, ist Chat-Arbeit und kein Cron-Job.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfad zu Chrome/Chromium, oder null. Reihenfolge: Umgebungsvariable CHROME, dann die
|
||||
* üblichen Installationsorte.
|
||||
*/
|
||||
function chrome_binary(): ?string
|
||||
{
|
||||
$env = getenv('CHROME');
|
||||
if (is_string($env) && $env !== '' && is_executable($env)) {
|
||||
return $env;
|
||||
}
|
||||
foreach ([
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
] as $candidate) {
|
||||
if (is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome einmal aufrufen und stdout zurückgeben.
|
||||
*
|
||||
* KEIN --user-data-dir: naheliegend wäre ein eigener Profilordner, damit ein offenes
|
||||
* Chrome-Fenster den Lauf nicht stört. Chrome 152 bleibt damit aber beim ERSTEN Aufruf
|
||||
* endlos hängen (gemessen: kein Abbruch nach zwei Minuten, kein Ergebnis, keine Meldung).
|
||||
* Ohne den Schalter läuft jeder Aufruf in Sekunden durch, auch bei geöffnetem Chrome.
|
||||
* Wer hier etwas ändert, misst die Laufzeit nach.
|
||||
*
|
||||
* @param string[] $args zusätzliche Schalter, bereits shell-escaped
|
||||
*/
|
||||
function chrome_run(string $chrome, array $args): string
|
||||
{
|
||||
$cmd = escapeshellarg($chrome)
|
||||
. ' --headless --disable-gpu --hide-scrollbars --force-color-profile=srgb'
|
||||
. ' --no-first-run --no-default-browser-check'
|
||||
. ' ' . implode(' ', $args) . ' 2>/dev/null';
|
||||
|
||||
return (string) shell_exec($cmd);
|
||||
}
|
||||
393
bin/instagram-add.php
Normal file
393
bin/instagram-add.php
Normal file
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Instagram-Beitrag in den Feed aufnehmen (nur CLI).
|
||||
*
|
||||
* php bin/instagram-add.php --profil ALLE neuen Beiträge holen (Normalfall)
|
||||
* php bin/instagram-add.php <post-url> [<post-url> …]
|
||||
* php bin/instagram-add.php --liste=links.txt eine URL je Zeile
|
||||
* php bin/instagram-add.php --entfernen=<shortcode> Eintrag und Bild löschen
|
||||
* php bin/instagram-add.php --bild=<pfad> <url> Bild von Hand, siehe „Notfall“ unten
|
||||
*
|
||||
* ERSETZT bin/instagram-sync.php (gelöscht am 17.09.2026). Der Scraper holte die letzten
|
||||
* Beiträge des öffentlichen Profils; das geht seit dem 03.09.2026 nicht mehr: Instagram
|
||||
* antwortet auf `feed/user` und `web_profile_info` mit 401 `require_login`, das Profil-HTML
|
||||
* enthält keine Beitragsdaten mehr, und auch die `/embed/`-Seiten liefern nur noch eine leere
|
||||
* App-Hülle, die ihren Inhalt erst im Browser nachlädt. Von einem privaten Anschluss geprüft,
|
||||
* es liegt also NICHT an der Server-IP — ein Lauf von woanders hilft nicht.
|
||||
*
|
||||
* WAS NOCH GEHT: der oEmbed-Endpunkt für EINEN Beitrag antwortet weiterhin ohne Token und
|
||||
* liefert alles, was wir brauchen — Bild, Caption und über die media_id sogar das Datum.
|
||||
* Deshalb dieser Weg: Felix wirft die Links der Beiträge in den Chat, dieses Skript holt den
|
||||
* Rest. Die Datei data/instagram.json behält ihre Struktur EXAKT bei, an Startseite,
|
||||
* News-Feed und Stadionzeitung ändert sich dadurch nichts.
|
||||
*
|
||||
* --profil NIMMT DAS LINKSAMMELN AB. Was per curl tot ist, zeigt ein echter Browser weiterhin:
|
||||
* headless Chrome bekommt auf der ausgeloggten Profilseite die Liste der letzten zwölf
|
||||
* Beitrags-Codes (nachgewiesen am 17.09.2026 — im reinen HTML stehen sie NICHT, sie entstehen
|
||||
* erst durch das JavaScript der Seite). Das Skript liest sie aus dem gerenderten DOM und holt
|
||||
* zu jedem neuen Code Bild, Text und Datum über oEmbed. Der Aufruf, den der Browser dafür
|
||||
* macht, ist mit Sitzungs-Token abgesichert und bewusst NICHT nachgebaut: das wäre eine
|
||||
* Bastelei, die beim nächsten Meta-Update wieder bricht.
|
||||
*
|
||||
* KEIN CRON. Auf dem All-Inkl-Webspace gibt es kein Chrome, --profil läuft also nur lokal
|
||||
* auf einem Arbeitsrechner — wie bin/logo-build.php. Das ist die ehrliche Grenze: die
|
||||
* Aktualisierung braucht einen Anstoß im Chat, dafür hängt die Seite an nichts mehr, das
|
||||
* unbemerkt von außen kaputtgehen kann. Ohne Chrome bleiben die Einzel-URLs.
|
||||
*
|
||||
* Notfall: verschwindet der oEmbed-Endpunkt (er ist Instagrams eigener, nicht der offiziell
|
||||
* dokumentierte — der liefert kein Bild), bleibt alles Eingetragene liegen und nichts bricht
|
||||
* rückwirkend. Für neue Beiträge dann `--bild=<pfad>` mit einem Screenshot; Caption und Datum
|
||||
* werden in dem Fall im Chat nachgetragen.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
require_once __DIR__ . '/chrome-lib.php';
|
||||
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
$username = (string) config('instagram.username', 'tsv08kulmbach');
|
||||
$imgDir = PUBLIC_PATH . '/assets/img/instagram';
|
||||
$cacheFile = DATA_PATH . '/instagram.json';
|
||||
|
||||
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
|
||||
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
|
||||
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
|
||||
log_write('instagram', $level, $msg);
|
||||
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
|
||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
|
||||
exit(1);
|
||||
};
|
||||
|
||||
// --- Argumente ---
|
||||
$urls = [];
|
||||
$removeCode = '';
|
||||
$manualImg = '';
|
||||
$vomProfil = false;
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '-v') {
|
||||
continue;
|
||||
}
|
||||
if ($arg === '--profil') {
|
||||
$vomProfil = true;
|
||||
} elseif (preg_match('/^--entfernen=(.+)$/', $arg, $m) === 1) {
|
||||
$removeCode = trim($m[1]);
|
||||
} elseif (preg_match('/^--bild=(.+)$/', $arg, $m) === 1) {
|
||||
$manualImg = $m[1];
|
||||
} elseif (preg_match('/^--liste=(.+)$/', $arg, $m) === 1) {
|
||||
if (!is_file($m[1])) {
|
||||
$fail("Liste nicht gefunden: {$m[1]}");
|
||||
}
|
||||
foreach (file($m[1], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line !== '' && !str_starts_with($line, '#')) {
|
||||
$urls[] = $line;
|
||||
}
|
||||
}
|
||||
} elseif (str_starts_with($arg, '--')) {
|
||||
$fail("Unbekannter Schalter: {$arg}");
|
||||
} else {
|
||||
$urls[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
if ($urls === [] && $removeCode === '' && !$vomProfil) {
|
||||
fwrite(STDERR, "Nutzung: php bin/instagram-add.php --profil | <post-url> [...] | --liste=<datei> | --entfernen=<shortcode>\n");
|
||||
exit(1);
|
||||
}
|
||||
if ($manualImg !== '' && count($urls) !== 1) {
|
||||
$fail('--bild gilt genau für EINE URL.');
|
||||
}
|
||||
|
||||
// --- Beitragsliste vom Profil holen (--profil) ---
|
||||
// Der einzige Schritt, der einen echten Browser braucht: die Codes stehen nicht im
|
||||
// ausgelieferten HTML, sie entstehen erst durch das JavaScript der Profilseite.
|
||||
if ($vomProfil) {
|
||||
$chrome = chrome_binary();
|
||||
if ($chrome === null) {
|
||||
$fail('--profil braucht Chrome, hier ist keines installiert (auf dem Server gibt es keines). '
|
||||
. 'Entweder Pfad setzen: CHROME="/Pfad/zu/Chrome" php bin/instagram-add.php --profil — '
|
||||
. 'oder die Beitrags-URLs einzeln übergeben.');
|
||||
}
|
||||
|
||||
$dom = chrome_run($chrome, [
|
||||
'--virtual-time-budget=15000',
|
||||
'--dump-dom',
|
||||
escapeshellarg('https://www.instagram.com/' . $username . '/'),
|
||||
]);
|
||||
|
||||
if (preg_match_all('#/p/([A-Za-z0-9_-]{8,})/#', $dom, $treffer) !== 0) {
|
||||
// Reihenfolge im DOM ist die des Profils (neueste zuerst), Duplikate raus.
|
||||
foreach (array_unique($treffer[1]) as $code) {
|
||||
$urls[] = "https://www.instagram.com/p/{$code}/";
|
||||
}
|
||||
}
|
||||
|
||||
if ($urls === []) {
|
||||
$fail('Profilseite gelesen, aber keine Beiträge gefunden — hat Instagram die Seite wieder geändert? '
|
||||
. 'Notfalls die Beitrags-URLs einzeln übergeben.');
|
||||
}
|
||||
$log('Profil gelesen: ' . count($urls) . ' Beiträge gefunden.');
|
||||
}
|
||||
|
||||
// --- Lock gegen parallele Läufe ---
|
||||
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- HTTP-Helfer, unverändert aus bin/instagram-sync.php übernommen ---
|
||||
$fetch = static function (string $url, array $headers = []): string|false {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
CURLOPT_HTTPHEADER => array_merge(['Accept-Language: de-DE,de;q=0.9'], $headers),
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
return ($body !== false && $status === 200) ? $body : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aufnahmezeitpunkt aus der media_id rechnen.
|
||||
*
|
||||
* Instagram kodiert den Zeitpunkt in den oberen Bits der 64-Bit-ID: die unteren 23 Bit sind
|
||||
* Shard und Sequenz, darüber stehen Millisekunden seit der Instagram-Epoche (01.09.2011).
|
||||
* Gegen die echten taken_at-Werte aller neun Beiträge aus dem alten Cache gerechnet: sechs
|
||||
* exakt, drei mit 2 bis 8 Minuten Abweichung (Aufnahme- gegen Veröffentlichungszeit).
|
||||
* Sichtbar ist ohnehin nur der Tag.
|
||||
*/
|
||||
const IG_EPOCH_MS = 1314220021721;
|
||||
|
||||
$takenAtFromMediaId = static function (string $mediaId): int {
|
||||
$id = (int) explode('_', $mediaId)[0];
|
||||
|
||||
return $id > 0 ? intdiv(($id >> 23) + IG_EPOCH_MS, 1000) : time();
|
||||
};
|
||||
|
||||
/** Shortcode aus einer Beitrags-URL. Reels tragen /reel/ statt /p/. */
|
||||
$shortcodeFromUrl = static function (string $url): string {
|
||||
return preg_match('#/(?:p|reel|tv)/([A-Za-z0-9_-]+)#', $url, $m) === 1 ? $m[1] : '';
|
||||
};
|
||||
|
||||
// --- Bestand laden ---
|
||||
$cache = is_file($cacheFile)
|
||||
? (json_decode((string) file_get_contents($cacheFile), true) ?: [])
|
||||
: [];
|
||||
$posts = $cache['posts'] ?? [];
|
||||
|
||||
// Shortcode → Position, das ist die ganze Dedupe-Logik: der Shortcode ist Instagrams eigener
|
||||
// Primärschlüssel, ein Textvergleich oder eine Heuristik wäre nur eine Fehlerquelle mehr.
|
||||
$index = [];
|
||||
foreach ($posts as $i => $post) {
|
||||
if (!empty($post['shortcode'])) {
|
||||
$index[$post['shortcode']] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
$speichern = static function () use (&$posts, $cacheFile, $username, $fail): void {
|
||||
// Neueste zuerst — dieselbe Reihenfolge, die der alte Sync von Instagram bekam.
|
||||
usort($posts, static fn (array $a, array $b): int => ($b['taken_at'] ?? 0) <=> ($a['taken_at'] ?? 0));
|
||||
|
||||
$payload = json_encode([
|
||||
'fetched_at' => time(),
|
||||
'username' => $username,
|
||||
'posts' => array_values($posts),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($payload === false) {
|
||||
$fail('Cache-Encode fehlgeschlagen');
|
||||
}
|
||||
$tmp = $cacheFile . '.tmp';
|
||||
if (file_put_contents($tmp, $payload . "\n") === false || !rename($tmp, $cacheFile)) {
|
||||
@unlink($tmp);
|
||||
$fail("Cache-Schreiben fehlgeschlagen ({$cacheFile})");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Entfernen ---
|
||||
if ($removeCode !== '') {
|
||||
if (!isset($index[$removeCode])) {
|
||||
$fail("Kein Eintrag mit Shortcode „{$removeCode}“.");
|
||||
}
|
||||
unset($posts[$index[$removeCode]]);
|
||||
$bild = "{$imgDir}/{$removeCode}.jpg";
|
||||
if (is_file($bild)) {
|
||||
unlink($bild);
|
||||
}
|
||||
$speichern();
|
||||
$log("Entfernt: {$removeCode} (Eintrag und Bild).");
|
||||
if ($urls === []) {
|
||||
exit(0);
|
||||
}
|
||||
// Index neu aufbauen, falls im selben Lauf noch etwas dazukommt.
|
||||
$index = [];
|
||||
foreach (array_values($posts) as $i => $post) {
|
||||
$index[$post['shortcode']] = $i;
|
||||
}
|
||||
$posts = array_values($posts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild auf Instagram-Portrait 4:5 (640×800) bringen.
|
||||
* Unverändert aus bin/instagram-sync.php übernommen — die einzige Stelle im Projekt, die GD
|
||||
* direkt bedient, weil resize_image_variants() nur proportional skaliert und keinen mittigen
|
||||
* Crop auf ein festes Seitenverhältnis kann.
|
||||
*/
|
||||
$bildSchreiben = static function (string $rohdaten, string $ziel): bool {
|
||||
$src = imagecreatefromstring($rohdaten);
|
||||
if ($src === false) {
|
||||
return false;
|
||||
}
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
if ($w / $h > 4 / 5) {
|
||||
$cropH = $h;
|
||||
$cropW = (int) round($h * 4 / 5);
|
||||
} else {
|
||||
$cropW = $w;
|
||||
$cropH = (int) round($w * 5 / 4);
|
||||
}
|
||||
$out = imagecreatetruecolor(640, 800);
|
||||
imagecopyresampled($out, $src, 0, 0, (int) (($w - $cropW) / 2), (int) (($h - $cropH) / 2), 640, 800, $cropW, $cropH);
|
||||
|
||||
return imagejpeg($out, $ziel, 80);
|
||||
};
|
||||
|
||||
if (!is_dir($imgDir) && !mkdir($imgDir, 0755, true) && !is_dir($imgDir)) {
|
||||
$fail("Bildordner nicht anlegbar: {$imgDir}");
|
||||
}
|
||||
|
||||
// --- Beiträge aufnehmen ---
|
||||
$neu = 0;
|
||||
$aktualisiert = 0;
|
||||
$uebersprungen = 0;
|
||||
|
||||
$bekannt = 0;
|
||||
foreach ($urls as $url) {
|
||||
$shortcode = $shortcodeFromUrl($url);
|
||||
// Beim Profil-Lauf zählt nur, was neu ist: die Liste enthält jedes Mal dieselben zwölf
|
||||
// Beiträge, und jeder bekannte wäre ein überflüssiger Abruf bei Instagram.
|
||||
if ($vomProfil && $shortcode !== '' && isset($index[$shortcode])) {
|
||||
$bekannt++;
|
||||
continue;
|
||||
}
|
||||
if ($shortcode === '') {
|
||||
$log("Keine Beitrags-URL: {$url} — übersprungen.", 'WARN');
|
||||
$uebersprungen++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = $fetch('https://www.instagram.com/api/v1/oembed/?url=' . rawurlencode("https://www.instagram.com/p/{$shortcode}/"));
|
||||
$data = $body !== false ? json_decode($body, true) : null;
|
||||
|
||||
if (!is_array($data) && $manualImg === '') {
|
||||
$log("oEmbed liefert nichts für {$shortcode} — übersprungen. Ist der Beitrag öffentlich? Notfalls mit --bild=<pfad> nachreichen.", 'WARN');
|
||||
$uebersprungen++;
|
||||
continue;
|
||||
}
|
||||
$data ??= [];
|
||||
|
||||
// Fremde Beiträge abweisen: ein versehentlich eingeworfener Link soll nicht still im
|
||||
// Vereinsfeed landen.
|
||||
$autor = (string) ($data['author_name'] ?? '');
|
||||
if ($autor !== '' && strcasecmp($autor, $username) !== 0) {
|
||||
$log("Beitrag {$shortcode} stammt von @{$autor}, nicht von @{$username} — abgelehnt.", 'WARN');
|
||||
$uebersprungen++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bestand = isset($index[$shortcode]) ? $posts[$index[$shortcode]] : null;
|
||||
$bild = "{$imgDir}/{$shortcode}.jpg";
|
||||
|
||||
// Bild nur holen, wenn es fehlt (oder von Hand geliefert wird) — ein zweiter Lauf mit
|
||||
// demselben Link kostet damit nichts.
|
||||
if ($manualImg !== '') {
|
||||
if (!is_file($manualImg)) {
|
||||
$fail("Bilddatei nicht gefunden: {$manualImg}");
|
||||
}
|
||||
if (!$bildSchreiben((string) file_get_contents($manualImg), $bild)) {
|
||||
$fail("Bild unlesbar: {$manualImg}");
|
||||
}
|
||||
} elseif (!is_file($bild)) {
|
||||
$quelle = (string) ($data['thumbnail_url'] ?? '');
|
||||
if ($quelle === '') {
|
||||
$log("Kein Bild in der oEmbed-Antwort für {$shortcode} — übersprungen.", 'WARN');
|
||||
$uebersprungen++;
|
||||
continue;
|
||||
}
|
||||
$rohdaten = $fetch($quelle);
|
||||
if ($rohdaten === false || !$bildSchreiben($rohdaten, $bild)) {
|
||||
$log("Bild-Download fehlgeschlagen: {$shortcode} — übersprungen.", 'WARN');
|
||||
$uebersprungen++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Caption: aus oEmbed, sonst die bestehende behalten (Notfall-Pfad mit --bild).
|
||||
$caption = (string) ($data['title'] ?? ($bestand['caption'] ?? ''));
|
||||
$alt = trim(strtok($caption, "\n") ?: '');
|
||||
$alt = $alt !== '' ? mb_substr($alt, 0, 120) : 'Instagram-Beitrag des TSV 08 Kulmbach';
|
||||
|
||||
$takenAt = !empty($data['media_id'])
|
||||
? $takenAtFromMediaId((string) $data['media_id'])
|
||||
: (int) ($bestand['taken_at'] ?? time());
|
||||
|
||||
$eintrag = [
|
||||
'shortcode' => $shortcode,
|
||||
'caption' => $caption,
|
||||
'alt' => $alt,
|
||||
'taken_at' => $takenAt,
|
||||
// oEmbed sagt nicht, ob es ein Video ist. Steuert nur das ▶ auf der Kachel und wird
|
||||
// bei Bedarf im Chat nachgetragen; ein bestehender Wert bleibt erhalten.
|
||||
'is_video' => (bool) ($bestand['is_video'] ?? false),
|
||||
'image' => "img/instagram/{$shortcode}.jpg",
|
||||
'url' => "https://www.instagram.com/p/{$shortcode}/",
|
||||
];
|
||||
|
||||
if ($bestand !== null) {
|
||||
$posts[$index[$shortcode]] = $eintrag;
|
||||
$aktualisiert++;
|
||||
$log("Aktualisiert: {$shortcode}");
|
||||
} else {
|
||||
$posts[] = $eintrag;
|
||||
$index[$shortcode] = array_key_last($posts);
|
||||
$neu++;
|
||||
$log("Aufgenommen: {$shortcode} — " . mb_substr($alt, 0, 60));
|
||||
}
|
||||
}
|
||||
|
||||
if ($neu === 0 && $aktualisiert === 0) {
|
||||
if ($vomProfil && $uebersprungen === 0) {
|
||||
// Kein Fehler: das Profil hatte schlicht nichts Neues.
|
||||
$log("Keine neuen Beiträge — alle {$bekannt} vom Profil sind schon im Feed.");
|
||||
echo "Nichts Neues. Der Feed ist auf dem aktuellen Stand (" . count($posts) . " Beiträge).\n";
|
||||
exit(0);
|
||||
}
|
||||
$log("Nichts aufgenommen ({$uebersprungen} übersprungen).", 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$speichern();
|
||||
$log("Fertig: {$neu} neu, {$aktualisiert} aktualisiert, {$bekannt} schon bekannt, {$uebersprungen} übersprungen — "
|
||||
. count($posts) . ' Beiträge im Feed.');
|
||||
|
||||
echo "\nBeiträge in data/instagram.json: " . count($posts) . "\n";
|
||||
foreach (array_slice($posts, 0, 5) as $post) {
|
||||
printf(" %-14s %s %s\n", $post['shortcode'], date('d.m.Y', $post['taken_at']), mb_substr($post['alt'], 0, 50));
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Instagram-Sync (Juicer-Ersatz): holt die letzten Beiträge des öffentlichen
|
||||
* Profils, lädt die Bilder lokal nach public/assets/img/instagram/ und schreibt
|
||||
* data/instagram.json atomar. Bei JEDEM Fehler bleibt der letzte gute Cache
|
||||
* unangetastet — die Website degradiert nur, sie bricht nie.
|
||||
*
|
||||
* Aufruf: php bin/instagram-sync.php [-v]
|
||||
* Cron: 17 6,18 * * * cd /pfad/zur/site && /usr/bin/php bin/instagram-sync.php >> storage/logs/instagram.log 2>&1
|
||||
*
|
||||
* Strategie-Kette (erster Erfolg gewinnt):
|
||||
* 1. feed/user-API (JSON, items[] — antwortet auch dann, wenn web_profile_info 400 wirft)
|
||||
* 2. web_profile_info-API (JSON, edges[])
|
||||
* 3. Profil-HTML nach eingebettetem JSON parsen
|
||||
*/
|
||||
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
|
||||
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
|
||||
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
|
||||
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
$verbose = in_array('-v', $argv ?? [], true);
|
||||
$username = (string) config('instagram.username', 'tsv08kulmbach');
|
||||
$maxPosts = (int) config('instagram.max_posts', 9);
|
||||
$imgDir = PUBLIC_PATH . '/assets/img/instagram';
|
||||
$cacheFile = DATA_PATH . '/instagram.json';
|
||||
|
||||
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
|
||||
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
|
||||
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
|
||||
log_write('instagram', $level, $msg);
|
||||
if ($verbose) {
|
||||
echo '[' . date('c') . "] {$level} {$msg}\n";
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
|
||||
exit(1);
|
||||
};
|
||||
|
||||
// --- Lock gegen parallele Läufe ---
|
||||
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
|
||||
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// --- HTTP-Helfer mit Browser-typischen Headern ---
|
||||
$fetch = static function (string $url, array $headers = []): string|false {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
CURLOPT_HTTPHEADER => array_merge(['Accept-Language: de-DE,de;q=0.9'], $headers),
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
return ($body !== false && $status === 200) ? $body : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts aus der web_profile_info-JSON-Struktur ziehen.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
$extractPosts = static function (array $data) use ($maxPosts): array {
|
||||
$edges = $data['data']['user']['edge_owner_to_timeline_media']['edges']
|
||||
?? $data['graphql']['user']['edge_owner_to_timeline_media']['edges']
|
||||
?? [];
|
||||
$posts = [];
|
||||
foreach (array_slice($edges, 0, $maxPosts) as $edge) {
|
||||
$node = $edge['node'] ?? [];
|
||||
if (empty($node['shortcode']) || empty($node['display_url'])) {
|
||||
continue;
|
||||
}
|
||||
$caption = trim((string) ($node['edge_media_to_caption']['edges'][0]['node']['text'] ?? ''));
|
||||
$posts[] = [
|
||||
'shortcode' => (string) $node['shortcode'],
|
||||
'caption' => $caption,
|
||||
'taken_at' => (int) ($node['taken_at_timestamp'] ?? 0),
|
||||
'is_video' => (bool) ($node['is_video'] ?? false),
|
||||
'display_url' => (string) $node['display_url'],
|
||||
];
|
||||
}
|
||||
return $posts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts aus der feed/user-Struktur ziehen (items[] mit image_versions2 statt edges[]).
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
$extractFeedItems = static function (array $data) use ($maxPosts): array {
|
||||
$posts = [];
|
||||
foreach (array_slice($data['items'] ?? [], 0, $maxPosts) as $item) {
|
||||
// Größten Bild-Kandidaten nehmen — wird unten ohnehin auf 640x800 gecroppt.
|
||||
$url = '';
|
||||
$bestWidth = 0;
|
||||
foreach ($item['image_versions2']['candidates'] ?? [] as $candidate) {
|
||||
$width = (int) ($candidate['width'] ?? 0);
|
||||
if ($width > $bestWidth && !empty($candidate['url'])) {
|
||||
$bestWidth = $width;
|
||||
$url = (string) $candidate['url'];
|
||||
}
|
||||
}
|
||||
if (empty($item['code']) || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$posts[] = [
|
||||
'shortcode' => (string) $item['code'],
|
||||
'caption' => trim((string) ($item['caption']['text'] ?? '')),
|
||||
'taken_at' => (int) ($item['taken_at'] ?? 0),
|
||||
// media_type: 1 = Bild, 2 = Video/Reel, 8 = Karussell.
|
||||
'is_video' => (int) ($item['media_type'] ?? 1) === 2 || !empty($item['video_versions']),
|
||||
'display_url' => $url,
|
||||
];
|
||||
}
|
||||
return $posts;
|
||||
};
|
||||
|
||||
$apiHeaders = [
|
||||
'x-ig-app-id: 936619743392459',
|
||||
'Accept: application/json',
|
||||
'Referer: https://www.instagram.com/' . $username . '/',
|
||||
];
|
||||
|
||||
// --- Strategie 1: feed/user-API ---
|
||||
$posts = [];
|
||||
$body = $fetch("https://www.instagram.com/api/v1/feed/user/{$username}/username/?count=12", $apiHeaders);
|
||||
if ($body !== false) {
|
||||
$data = json_decode($body, true);
|
||||
if (is_array($data)) {
|
||||
$posts = $extractFeedItems($data);
|
||||
$log('Strategie 1 (feed/user): ' . count($posts) . ' Posts');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategie 2: web_profile_info-API ---
|
||||
if ($posts === []) {
|
||||
$body = $fetch("https://www.instagram.com/api/v1/users/web_profile_info/?username={$username}", $apiHeaders);
|
||||
if ($body !== false) {
|
||||
$data = json_decode($body, true);
|
||||
if (is_array($data)) {
|
||||
$posts = $extractPosts($data);
|
||||
$log('Strategie 2 (web_profile_info): ' . count($posts) . ' Posts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategie 3: Profil-HTML nach eingebettetem JSON parsen ---
|
||||
if ($posts === []) {
|
||||
$html = $fetch("https://www.instagram.com/{$username}/");
|
||||
if ($html !== false) {
|
||||
// Eingebettete JSON-Blöcke (script type application/json) nach Timeline-Daten durchsuchen.
|
||||
if (preg_match_all('/<script type="application\/json"[^>]*>(.*?)<\/script>/s', $html, $m)) {
|
||||
foreach ($m[1] as $jsonBlob) {
|
||||
if (!str_contains($jsonBlob, 'edge_owner_to_timeline_media')) {
|
||||
continue;
|
||||
}
|
||||
$data = json_decode($jsonBlob, true);
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
// Struktur variiert — rekursiv nach edge_owner_to_timeline_media suchen.
|
||||
$stack = [$data];
|
||||
while ($stack !== [] && $posts === []) {
|
||||
$item = array_pop($stack);
|
||||
if (isset($item['edge_owner_to_timeline_media'])) {
|
||||
$posts = $extractPosts(['data' => ['user' => $item]]);
|
||||
break;
|
||||
}
|
||||
foreach ($item as $value) {
|
||||
if (is_array($value)) {
|
||||
$stack[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($posts !== []) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$log('Strategie 3 (HTML-Parsing): ' . count($posts) . ' Posts');
|
||||
}
|
||||
}
|
||||
|
||||
if ($posts === []) {
|
||||
$fail('Keine Posts ermittelbar (Instagram blockt vermutlich die Server-IP oder hat die Struktur geändert)');
|
||||
}
|
||||
|
||||
// --- Bilder lokal laden, auf Instagram-Portrait 4:5 (640x800) bringen ---
|
||||
if (!is_dir($imgDir)) {
|
||||
mkdir($imgDir, 0755, true);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($posts as $post) {
|
||||
$file = "{$imgDir}/{$post['shortcode']}.jpg";
|
||||
if (!is_file($file)) {
|
||||
$img = $fetch($post['display_url']);
|
||||
if ($img === false) {
|
||||
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$src = imagecreatefromstring($img);
|
||||
if ($src === false) {
|
||||
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
// Mittiger Crop auf 4:5 (Instagram-Portrait), dann auf 640x800 skalieren.
|
||||
if ($w / $h > 4 / 5) {
|
||||
$cropH = $h;
|
||||
$cropW = (int) round($h * 4 / 5);
|
||||
} else {
|
||||
$cropW = $w;
|
||||
$cropH = (int) round($w * 5 / 4);
|
||||
}
|
||||
$out = imagecreatetruecolor(640, 800);
|
||||
imagecopyresampled($out, $src, 0, 0, (int) (($w - $cropW) / 2), (int) (($h - $cropH) / 2), 640, 800, $cropW, $cropH);
|
||||
imagejpeg($out, $file, 80);
|
||||
}
|
||||
|
||||
// Alt-Text aus der Caption: erste Zeile, gekürzt.
|
||||
$alt = trim(strtok($post['caption'], "\n") ?: '');
|
||||
$alt = $alt !== '' ? mb_substr($alt, 0, 120) : 'Instagram-Beitrag des TSV 08 Kulmbach';
|
||||
|
||||
$result[] = [
|
||||
'shortcode' => $post['shortcode'],
|
||||
'caption' => $post['caption'],
|
||||
'alt' => $alt,
|
||||
'taken_at' => $post['taken_at'],
|
||||
'is_video' => $post['is_video'],
|
||||
'image' => "img/instagram/{$post['shortcode']}.jpg",
|
||||
'url' => "https://www.instagram.com/p/{$post['shortcode']}/",
|
||||
];
|
||||
}
|
||||
|
||||
if ($result === []) {
|
||||
$fail('Posts gefunden, aber kein einziges Bild ladbar');
|
||||
}
|
||||
|
||||
// --- Nicht mehr aktuelle Bilder aufräumen ---
|
||||
$keep = array_map(static fn (array $p): string => "{$p['shortcode']}.jpg", $result);
|
||||
foreach (glob("{$imgDir}/*.jpg") ?: [] as $existing) {
|
||||
if (!in_array(basename($existing), $keep, true)) {
|
||||
unlink($existing);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cache atomar schreiben (tmp + rename) ---
|
||||
$payload = json_encode([
|
||||
'fetched_at' => time(),
|
||||
'username' => $username,
|
||||
'posts' => $result,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$tmp = $cacheFile . '.tmp';
|
||||
file_put_contents($tmp, $payload);
|
||||
rename($tmp, $cacheFile);
|
||||
|
||||
$log('OK: ' . count($result) . ' Posts gecached, Bilder lokal in assets/img/instagram/');
|
||||
@@ -34,6 +34,7 @@ if (PHP_SAPI !== 'cli') {
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
require_once __DIR__ . '/chrome-lib.php';
|
||||
|
||||
$verbose = in_array('-v', $argv, true);
|
||||
|
||||
@@ -99,23 +100,9 @@ $srcDir = STORAGE_PATH . '/logo-quellen';
|
||||
$destDir = PUBLIC_PATH . '/assets/downloads/logo';
|
||||
$tmpDir = sys_get_temp_dir() . '/tsv08-logo-' . getmypid();
|
||||
|
||||
// --- Chrome finden ------------------------------------------------------------
|
||||
// Reihenfolge: Umgebungsvariable CHROME, dann die üblichen Installationsorte.
|
||||
$chrome = getenv('CHROME') ?: '';
|
||||
if ($chrome === '') {
|
||||
foreach ([
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
] as $candidate) {
|
||||
if (is_executable($candidate)) {
|
||||
$chrome = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($chrome === '' || !is_executable($chrome)) {
|
||||
// --- Chrome finden ---
|
||||
$chrome = chrome_binary();
|
||||
if ($chrome === null) {
|
||||
$fail('Kein Chrome gefunden. Pfad per Umgebungsvariable setzen: CHROME="/Pfad/zu/Chrome" php bin/logo-build.php');
|
||||
}
|
||||
|
||||
@@ -135,21 +122,8 @@ register_shutdown_function(static function () use ($tmpDir): void {
|
||||
@rmdir($tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Chrome einmal aufrufen.
|
||||
*
|
||||
* KEIN --user-data-dir: naheliegend wäre ein eigener Profilordner, damit ein offenes
|
||||
* Chrome-Fenster den Lauf nicht stört. Chrome 152 bleibt damit aber beim ERSTEN Aufruf
|
||||
* endlos hängen (gemessen: kein Abbruch nach zwei Minuten, kein Bild, keine Meldung).
|
||||
* Ohne den Schalter läuft jeder Aufruf in wenigen Sekunden durch, auch bei geöffnetem
|
||||
* Chrome. Wer hier etwas ändert, misst die Laufzeit nach.
|
||||
*/
|
||||
$runChrome = static function (array $args) use ($chrome): void {
|
||||
$cmd = escapeshellarg($chrome)
|
||||
. ' --headless --disable-gpu --hide-scrollbars --force-color-profile=srgb'
|
||||
. ' --no-first-run --no-default-browser-check'
|
||||
. ' ' . implode(' ', $args) . ' 2>/dev/null';
|
||||
exec($cmd);
|
||||
chrome_run($chrome, $args);
|
||||
};
|
||||
|
||||
/** Maße der SVG in mm aus dem Wurzelelement lesen (für die PDF-Seitengröße). */
|
||||
|
||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
* - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table
|
||||
* - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG)
|
||||
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
|
||||
* setzt ihn (wie bin/instagram-sync.php).
|
||||
* setzt ihn (wie bin/instagram-add.php).
|
||||
*/
|
||||
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
|
||||
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
|
||||
|
||||
@@ -159,8 +159,10 @@ if ($phpPost <= $phpUpload) {
|
||||
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
|
||||
}
|
||||
|
||||
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
|
||||
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
|
||||
// --- Datenstand des Cron-Skripts (fehlt nur beim Erstlauf) ---
|
||||
// Nur noch matchcenter: data/instagram.json ist seit dem 17.09.2026 chat-gepflegt
|
||||
// (bin/instagram-add.php) und nicht „alt“, nur weil eine Woche nichts passiert ist.
|
||||
foreach (['matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
|
||||
$file = DATA_PATH . '/' . $name . '.json';
|
||||
if (!is_file($file)) {
|
||||
$warnings[] = "data/{$name}.json fehlt — einmal \"php {$script}\" von Hand anstoßen.";
|
||||
@@ -169,6 +171,29 @@ foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchce
|
||||
}
|
||||
}
|
||||
|
||||
// --- Instagram: Einträge und Bilder müssen zusammenpassen ---
|
||||
// Genau daran ist es am 17.09.2026 auseinandergelaufen: die Bilder waren versioniert,
|
||||
// data/instagram.json nicht. Ein Eintrag ohne Bild ist auf der Startseite eine kaputte
|
||||
// Kachel, ein Bild ohne Eintrag eine Leiche, die niemand mehr findet.
|
||||
$igPosts = json_load('instagram')['posts'] ?? [];
|
||||
$igFehlt = [];
|
||||
foreach ($igPosts as $igPost) {
|
||||
if (!empty($igPost['image']) && !is_file(PUBLIC_PATH . '/assets/' . $igPost['image'])) {
|
||||
$igFehlt[] = $igPost['image'];
|
||||
}
|
||||
}
|
||||
if ($igFehlt !== []) {
|
||||
$errors[] = 'Instagram-Beiträge ohne Bild: ' . implode(', ', $igFehlt)
|
||||
. '. Bild nachholen mit: php bin/instagram-add.php https://www.instagram.com/p/<shortcode>/';
|
||||
}
|
||||
$igCodes = array_column($igPosts, 'shortcode');
|
||||
foreach (glob(PUBLIC_PATH . '/assets/img/instagram/*.jpg') ?: [] as $igDatei) {
|
||||
if (!in_array(basename($igDatei, '.jpg'), $igCodes, true)) {
|
||||
$warnings[] = 'Instagram-Bild ohne Eintrag im Feed: ' . basename($igDatei)
|
||||
. ' — entweder aufnehmen oder löschen.';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
|
||||
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
|
||||
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
|
||||
|
||||
@@ -43,11 +43,13 @@ $log = static function (string $msg, string $level = 'INFO'): void {
|
||||
* darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren.
|
||||
*
|
||||
* Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf
|
||||
* Ordner, die die Sync-Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg,
|
||||
* die der BFV nicht mehr führt, bin/instagram-sync.php ältere Posts — am 31.07.2026
|
||||
* nachgewiesen: ein Lauf löschte 18 Wappen und 3 Instagram-Bilder. Bei den Wappen
|
||||
* dauert das eine Saison, bei Instagram Wochen. Danach zeigte eine archivierte
|
||||
* Ausgabe Lücken, und „gedruckt = fixiert" wäre nur die halbe Wahrheit.
|
||||
* Ordner, die andere Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg,
|
||||
* die der BFV nicht mehr führt — am 31.07.2026 nachgewiesen: ein Lauf löschte 18 Wappen
|
||||
* und 3 Instagram-Bilder. Danach zeigte eine archivierte Ausgabe Lücken, und
|
||||
* „gedruckt = fixiert" wäre nur die halbe Wahrheit.
|
||||
* Bei Instagram ist die Gefahr seit dem 17.09.2026 kleiner (der aufräumende
|
||||
* bin/instagram-sync.php ist weg, Beiträge kommen einzeln über bin/instagram-add.php),
|
||||
* aber ein Bild kann weiterhin per --entfernen verschwinden. Das Einfrieren bleibt.
|
||||
*
|
||||
* img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind:
|
||||
* ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich
|
||||
|
||||
Reference in New Issue
Block a user