[ …] * php bin/instagram-add.php --liste=links.txt eine URL je Zeile * php bin/instagram-add.php --entfernen= Eintrag und Bild löschen * php bin/instagram-add.php --bild= 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=` 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 | [...] | --liste= | --entfernen=\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); // Optional wie im Matchcenter-Sync, wenn der lokale DNS-Dienst ausfällt. $dohUrl = getenv('TSV_HTTP_DOH_URL') ?: ''; if ($dohUrl !== '') { curl_setopt($ch, CURLOPT_DOH_URL, $dohUrl); } 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= 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)); }