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:
2026-09-17 11:23:12 +02:00
parent 75fce4f680
commit fef80bf669
25 changed files with 753 additions and 357 deletions

393
bin/instagram-add.php Normal file
View 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));
}