From fef80bf6696f64b1c65bc4e5523e1cc537a4bcc9 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 17 Sep 2026 11:23:12 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS --- .gitignore | 8 +- CLAUDE.md | 79 +++- app/components/instagram-feed.php | 6 +- bin/chrome-lib.php | 63 +++ bin/instagram-add.php | 393 ++++++++++++++++++ bin/instagram-sync.php | 274 ------------ bin/logo-build.php | 36 +- bin/matchcenter-sync.php | 2 +- bin/preflight.php | 29 +- bin/stadionzeitung-publish.php | 12 +- config/config.example.php | 16 +- data/instagram.json | 150 +++++++ docs/deploy.md | 34 +- .../tsv08-kulmbach-logo-einfarbig-schwarz.pdf | Bin 3819 -> 3819 bytes .../tsv08-kulmbach-logo-einfarbig-weiss.pdf | Bin 3818 -> 3818 bytes .../logo/tsv08-kulmbach-logo-farbig.pdf | Bin 4333 -> 4333 bytes .../logo/tsv08-kulmbach-logo-paket.zip | Bin 1065917 -> 1065910 bytes public/assets/img/instagram/Db2ya5TDMLC.jpg | Bin 0 -> 115291 bytes public/assets/img/instagram/Dbk0vKCs-p2.jpg | Bin 0 -> 70913 bytes public/assets/img/instagram/Dbs_SENMqBr.jpg | Bin 0 -> 58814 bytes public/assets/img/instagram/Dc-41FTDC7u.jpg | Bin 0 -> 131719 bytes public/assets/img/instagram/DdB5NiwDLiq.jpg | Bin 0 -> 147803 bytes public/assets/img/instagram/DdHvGEDDLIh.jpg | Bin 0 -> 69638 bytes public/assets/img/instagram/DdROjQqjI1_.jpg | Bin 0 -> 73816 bytes public/cron.php | 8 +- 25 files changed, 753 insertions(+), 357 deletions(-) create mode 100644 bin/chrome-lib.php create mode 100644 bin/instagram-add.php delete mode 100644 bin/instagram-sync.php create mode 100644 data/instagram.json create mode 100644 public/assets/img/instagram/Db2ya5TDMLC.jpg create mode 100644 public/assets/img/instagram/Dbk0vKCs-p2.jpg create mode 100644 public/assets/img/instagram/Dbs_SENMqBr.jpg create mode 100644 public/assets/img/instagram/Dc-41FTDC7u.jpg create mode 100644 public/assets/img/instagram/DdB5NiwDLiq.jpg create mode 100644 public/assets/img/instagram/DdHvGEDDLIh.jpg create mode 100644 public/assets/img/instagram/DdROjQqjI1_.jpg diff --git a/.gitignore b/.gitignore index 9d61fc5..0950063 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,12 @@ # der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine # archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht # dasselbe wie maschinenverwaltet und für immer referenziert. -/data/instagram.json +# data/instagram.json ist seit dem 17.09.2026 VERSIONIERT: sie ist nicht mehr +# maschinenverwaltet, sondern chat-gepflegt (bin/instagram-add.php nimmt einzelne +# Beiträge auf). Ihr Inhalt ist Pflegearbeit und nicht wiederherstellbar — und +# solange sie ignoriert war, lief sie gegen die versionierten Bilder auseinander: +# am 17.09.2026 lagen lokal 10 Bilder zu 9 Einträgen, drei Beiträge fehlten im Feed +# und zwei Einträge hatten kein Bild mehr. /data/matchcenter.json # Dateiablage /dateien — Laufzeit-Kram der Files Gallery. @@ -51,3 +56,4 @@ # OS .DS_Store +docs/pflegebereich-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index ab2a534..8d7dd2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern. 1. **Keine externen Einbindungen.** Keine CDNs, keine externen Fonts/Scripts/iframes/Embeds, keine Tracking-/Analytics-Dienste, kein reCAPTCHA. Alles wird self-hosted. Einzige erlaubte ausgehende Verbindungen: All-Inkl-SMTP (Mailversand, serverseitig) und die CLI/Cron-Skripte unter `bin/` - (Instagram-Scraper, Matchcenter-Sync, Files-Gallery-Update — nie im Request-Pfad). + (Matchcenter-Sync, Instagram-Aufnahme, Files-Gallery-Update — nie im Request-Pfad). Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (`default-src 'self'`) erzwingt das — nicht aufweichen. 2. **`.context/` und `.context_db/` sind reine Referenz** (alte, unsichere CMS-Seite). Von dort werden nur Assets (Bilder/Fonts/Videos), Texte und Styling-Werte extrahiert. **Niemals Code übernehmen, niemals @@ -52,7 +52,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern. | Ansprechpartner/Personen (geteilte Quelle) | `data/ansprechpartner.json` (per Abteilung auf Team-/Jugend-/Verein-Seiten gezogen) | Namen/Funktionen/Kontakte in Templates duplizieren | | Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente | | Partner/Sponsoren | `data/partners.json` | — | -| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** | +| Instagram-Beiträge | `data/instagram.json` + `public/assets/img/instagram/` | Aufgenommen ausschließlich mit **`php bin/instagram-add.php `** (holt Bild, Text und Datum) — Einträge nie von Hand tippen, Bilder nie von Hand skalieren. **Kein Cron**, chat-gepflegt wie `events.json`. Eintrag und Bild müssen immer zusammen existieren, `bin/preflight.php` prüft beide Richtungen | | Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) | | Stadionzeitung (Ausgaben-Archiv) | `data/stadionzeitung.json` + `public/assets/img/stadionzeitung/` | **maschinenverwaltet von `bin/stadionzeitung-add.php` + `bin/stadionzeitung-publish.php` (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind `vorwort`, `interview`, `aufruf`, `veranstaltung` (Slug-Verweis auf `veranstaltungen.json`) und `cover` (`teaser` einer Sonderausgabe).** Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt | | Sponsoren-Anzeigen (Stadionzeitung) | `data/anzeigen.json` + `public/assets/img/anzeigen/` | Dauerhafter Bestand, **nicht** pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. `sponsor`, `alt` (optional), `aktiv`, `bis` (optional, Enddatum gegen das Ausgabedatum), `platzierung` (optional, fester Platz auf einer Live-Seite statt im Streu-Pool) und die **Reihenfolge** (= Reihenfolge im Heft) im Chat gepflegt; `base`/`widths` schreibt ausschließlich `bin/anzeige-add.php`. Anzeigen nie in den Ausgaben-Ordner legen | @@ -432,17 +432,69 @@ Besucher keine Aktualisierung, und Fire-and-Forget ist auf Shared Hosting unzuve gewünschten Takt sperrt. Der damals hier notierte Ausstieg („wenn der Tarif je Cronjobs bekommt, `cron.key` leeren") ist am 31.07.2026 genau so eingetreten und vollzogen. -**Offen:** ob der Instagram-Scraper von der Server-IP überhaupt durchkommt. Instagram sperrt -Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er -dort blockt, hilft auch GitHub Actions nicht (noch strenger gesperrt) — dann bleibt ein Lauf von einem -privaten Anschluss oder der „Aktuelles"-Block wird redaktionell gepflegt. +**Erledigt (17.09.2026):** die Frage, ob der Instagram-Scraper von der Server-IP durchkommt, hat sich +anders beantwortet als gedacht — er kommt von **keiner** IP mehr durch, siehe unten. Der Job ist aus +der Whitelist entfernt, es sind nur noch zwei. -## Instagram-Feed (Juicer-Ersatz) +## Instagram-Beiträge (Juicer-Ersatz, seit 17.09.2026 ohne Scraper) -`bin/instagram-sync.php` (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt -`data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt -der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente -eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain. +**`php bin/instagram-add.php --profil`** (nur CLI) ist der Normalfall: liest die Beitragsliste vom +Profil und nimmt alles auf, was noch fehlt. Je Beitrag holt es über Instagrams oEmbed-Endpunkt Bild, +Caption und Datum, bringt das Bild auf 640×800 (mittiger Crop auf 4:5) und schreibt +`data/instagram.json` atomar. Daneben einzelne Post-URLs als Argument, `--liste=`, +`--entfernen=` und `--bild=` (Notfall). Besucher laden Instagram-Inhalte weiterhin +ausschließlich von unserer Domain; ohne Einträge rendert die Komponente eine „Folge uns"-CTA-Karte. + +**Die Beitragsliste kommt aus headless Chrome, und das ist der Kern des Ganzen.** Im ausgelieferten +Profil-HTML stehen die Beitrags-Codes NICHT — sie entstehen erst durch das JavaScript der Seite. +Ein echter Browser bekommt sie ausgeloggt weiterhin (nachgewiesen 17.09.2026: zwölf Codes, darunter +vier, die dem Feed fehlten). Das Skript liest sie per `--dump-dom` aus dem gerenderten DOM. +Den GraphQL-Aufruf, den der Browser dafür macht, **bewusst nicht nachbauen**: er ist mit +Sitzungs-Token abgesichert, und die Rekonstruktion wäre genau die Bastelei, die beim nächsten +Meta-Update wieder bricht. `bin/chrome-lib.php` hält `chrome_binary()` und `chrome_run()` für die +zwei Skripte, die einen Browser brauchen (hier und `bin/logo-build.php`) — **kein `--user-data-dir`**, +siehe Kommentar dort. + +**Kein Cron, und der Grund ist hart:** auf dem All-Inkl-Webspace gibt es kein Chrome. `--profil` +läuft nur lokal auf einem Arbeitsrechner, die Aktualisierung braucht also einen Anstoß im Chat +(„hol die neuen Instagram-Beiträge"). Dafür sind es zwei Wörter statt gesammelter Links, und die +Seite hängt an nichts mehr, das unbemerkt von außen kaputtgehen kann. + +**Warum der Scraper weg ist.** `bin/instagram-sync.php` holte die letzten Beiträge des öffentlichen +Profils und lieferte seit dem **03.09.2026** bei jedem Lauf „Keine Posts ermittelbar". Am 17.09.2026 +von einem **privaten** Anschluss gegengeprüft, alle drei Strategien: `feed/user` und +`web_profile_info` antworten mit **401 `require_login`** (`igweb_rollout`), das Profil-HTML enthält +keine Beitragsdaten mehr, `?__a=1` ist tot, und auch `/embed/` liefert nur noch eine App-Hülle, die +ihren Inhalt erst im Browser nachlädt. **Es lag also nicht an der Server-IP** — ein Lauf von woanders +hilft nicht, und der Scraper ist gelöscht, damit ihn niemand wiederbelebt. +Verworfene Alternativen: die offizielle **Graph API** (setzt Professional-Konto, Meta-App und Token +voraus, abgelehnt von Felix) und **kommerzielle Scraping-Dienste** (Dritter, Kosten, bricht beim +nächsten Meta-Update). + +**Was noch geht, und warum es trägt:** `https://www.instagram.com/api/v1/oembed/?url=` +antwortet ohne Token und liefert `thumbnail_url` (640×800, also genau unser Format), `title` (die +komplette Caption) und `media_id`. **Das Datum steckt in der media_id**: `(id >> 23) + 1314220021721` +ergibt Millisekunden seit der Instagram-Epoche. Gegen alle neun echten `taken_at`-Werte gerechnet: +sechs exakt, drei mit 2 bis 8 Minuten Abweichung (Aufnahme- gegen Veröffentlichungszeit), sichtbar +ist ohnehin nur der Tag. **Der Shortcode ist der Primärschlüssel** und damit die komplette +Dedupe-Logik: er ist der Dateiname des Bildes und der Schlüssel des Eintrags, ein zweimal +geschickter Link aktualisiert statt zu verdoppeln. + +**Die Datenstruktur bleibt exakt wie vorher** (sieben Felder je Beitrag) — deshalb war an +`app/components/instagram-feed.php`, `news_build_feed()`, der Momente-Seite der Stadionzeitung und +`bin/stadionzeitung-publish.php` **nichts** zu ändern. + +**`data/instagram.json` ist seit dem 17.09.2026 versioniert**, vorher gitignored. Sie ist nicht mehr +maschinenverwaltet, ihr Inhalt ist Pflegearbeit. Solange sie ignoriert war, lief sie gegen die +versionierten Bilder auseinander: am 17.09.2026 lagen lokal **10 Bilder zu 9 Einträgen** — drei +Beiträge vom Server fehlten im lokalen Feed, zwei Einträge hatten kein Bild mehr. `bin/preflight.php` +prüft das jetzt in beide Richtungen (Eintrag ohne Bild = Fehler, Bild ohne Eintrag = Warnung). + +**Ehrliche Grenze:** der genutzte Endpunkt ist Instagrams eigener, nicht der offiziell dokumentierte +(`graph.facebook.com/instagram_oembed` antwortet zwar auch ohne Token, liefert aber **kein** Bild, +nur den Einbettungscode, den Regel 1 verbietet). Fällt er weg, bleibt alles Eingetragene liegen und +nichts bricht rückwirkend; für neue Beiträge greift dann `--bild=`. Das ist der Unterschied zum +Scraper, dessen Ausfall den ganzen Block eingefroren hat. ## Matchcenter (BFV-Widget-Ersatz) @@ -610,7 +662,8 @@ Konflikte mitten in eingefrorenen Datensätzen. alles, worauf er zeigt (`img/crests/`, `img/instagram/`, `img/news/`), als `fix-*` in den Ausgaben-Ordner und schreibt die Pfade um. **Grund:** vorher fror nur die Daten ein, die Pfade zeigten weiter in Ordner, die die Sync-Skripte verwalten — `matchcenter-sync` räumt Wappen weg, -die der BFV nicht mehr führt (eine Saison), `instagram-sync` ältere Posts (Wochen). Am 31.07.2026 +die der BFV nicht mehr führt (eine Saison), und der damalige `instagram-sync` ältere Posts (Wochen; +dieses Skript gibt es seit dem 17.09.2026 nicht mehr, das Einfrieren bleibt trotzdem richtig). Am 31.07.2026 nachgewiesen: ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also von selbst zerfallen. `img/news` ist mit dabei, weil ein Artikelbild im Chat ersetzt werden kann. **Varianten-Sets** (`{base, widths}`) werden über alle Breiten kopiert und nur umgeschrieben, wenn @@ -1074,7 +1127,7 @@ zieht seine News aus `news_build_feed()` und friert sie beim Veröffentlichen ei Stelle, die sich nicht zurücknehmen lässt. **Meldungen werden nicht gekappt.** `config('news.max_items')` begrenzt in `news_build_feed()` nur -Instagram und Ergebnisse — die wachsen von allein (Sync 2×/Tag, jedes Wochenende neue Spiele), und +Instagram und Ergebnisse — die wachsen von allein (jedes Wochenende neue Spiele), und davor schützt die Grenze. Redaktionelle Meldungen stehen immer alle im Feed, sonst wäre ein älterer Artikel irgendwann von der Website aus nicht mehr verlinkt. **Es gibt bewusst keinen zweiten Archiv-Abschnitt** dafür (30.07.2026 gebaut und am selben Tag verworfen): bei wenigen Artikeln pro diff --git a/app/components/instagram-feed.php b/app/components/instagram-feed.php index aa39142..0e6738c 100644 --- a/app/components/instagram-feed.php +++ b/app/components/instagram-feed.php @@ -3,9 +3,9 @@ declare(strict_types=1); /** - * "Aktuelles": Instagram-Grid aus dem lokalen Cache (data/instagram.json, - * geschrieben von bin/instagram-sync.php). Besucher laden ausschließlich - * lokal gehostete Bilder. Ohne Cache: CTA-Karte als Degradation. + * "Aktuelles": Instagram-Grid aus data/instagram.json, aufgenommen mit + * bin/instagram-add.php. Besucher laden ausschließlich lokal gehostete Bilder. + * Ohne Einträge: CTA-Karte als Degradation. */ $club = json_load('club'); $feed = json_load('instagram'); diff --git a/bin/chrome-lib.php b/bin/chrome-lib.php new file mode 100644 index 0000000..75b26b0 --- /dev/null +++ b/bin/chrome-lib.php @@ -0,0 +1,63 @@ +/dev/null'; + + return (string) shell_exec($cmd); +} diff --git a/bin/instagram-add.php b/bin/instagram-add.php new file mode 100644 index 0000000..be15509 --- /dev/null +++ b/bin/instagram-add.php @@ -0,0 +1,393 @@ + [ …] + * 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); + 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)); +} diff --git a/bin/instagram-sync.php b/bin/instagram-sync.php deleted file mode 100644 index 32715d4..0000000 --- a/bin/instagram-sync.php +++ /dev/null @@ -1,274 +0,0 @@ -> 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('/