Files
tsv08kulmbach-website/bin/instagram-add.php

399 lines
16 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);
/**
* 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);
// 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=<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));
}