Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync
- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage - CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first) - Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2) - Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub) - Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt - Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA) - bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
214
bin/instagram-sync.php
Normal file
214
bin/instagram-sync.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?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. web_profile_info-API (JSON, inkl. der letzten 12 Posts)
|
||||
* 2. Profil-HTML nach eingebettetem JSON parsen
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require 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';
|
||||
|
||||
$log = static function (string $msg) use ($verbose): void {
|
||||
$line = '[' . date('c') . "] {$msg}\n";
|
||||
file_put_contents(STORAGE_PATH . '/logs/instagram.log', $line, FILE_APPEND);
|
||||
if ($verbose) {
|
||||
echo $line;
|
||||
}
|
||||
};
|
||||
|
||||
$fail = static function (string $msg) use ($log): never {
|
||||
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
|
||||
exit(1);
|
||||
};
|
||||
|
||||
// --- Lock gegen parallele Läufe ---
|
||||
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
$fail('Läuft bereits (Lock belegt)');
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
};
|
||||
|
||||
// --- Strategie 1: web_profile_info-API ---
|
||||
$posts = [];
|
||||
$apiHeaders = [
|
||||
'x-ig-app-id: 936619743392459',
|
||||
'Accept: application/json',
|
||||
'Referer: https://www.instagram.com/' . $username . '/',
|
||||
];
|
||||
$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 1 (web_profile_info): ' . count($posts) . ' Posts');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategie 2: 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 2 (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 640px-Quadrat 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");
|
||||
continue;
|
||||
}
|
||||
$src = imagecreatefromstring($img);
|
||||
if ($src === false) {
|
||||
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
|
||||
continue;
|
||||
}
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
$side = min($w, $h);
|
||||
$out = imagecreatetruecolor(640, 640);
|
||||
imagecopyresampled($out, $src, 0, 0, (int) (($w - $side) / 2), (int) (($h - $side) / 2), 640, 640, $side, $side);
|
||||
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/');
|
||||
Reference in New Issue
Block a user