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

View File

@@ -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 <post-url>`** (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=<datei>`,
`--entfernen=<shortcode>` und `--bild=<pfad>` (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=<post-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=<pfad>`. 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