> 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> */ $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> */ $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('/