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

8
.gitignore vendored
View File

@@ -26,7 +26,12 @@
# der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine # der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine
# archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht # archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht
# dasselbe wie maschinenverwaltet und für immer referenziert. # 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 /data/matchcenter.json
# Dateiablage /dateien — Laufzeit-Kram der Files Gallery. # Dateiablage /dateien — Laufzeit-Kram der Files Gallery.
@@ -51,3 +56,4 @@
# OS # OS
.DS_Store .DS_Store
docs/pflegebereich-plan.md

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 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 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/` 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. 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 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 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 | | 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 | | Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente |
| Partner/Sponsoren | `data/partners.json` | — | | 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`) | | 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 | | 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 | | 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, 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. `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 **Erledigt (17.09.2026):** die Frage, ob der Instagram-Scraper von der Server-IP durchkommt, hat sich
Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er anders beantwortet als gedacht — er kommt von **keiner** IP mehr durch, siehe unten. Der Job ist aus
dort blockt, hilft auch GitHub Actions nicht (noch strenger gesperrt) — dann bleibt ein Lauf von einem der Whitelist entfernt, es sind nur noch zwei.
privaten Anschluss oder der „Aktuelles"-Block wird redaktionell gepflegt.
## 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 **`php bin/instagram-add.php --profil`** (nur CLI) ist der Normalfall: liest die Beitragsliste vom
`data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt Profil und nimmt alles auf, was noch fehlt. Je Beitrag holt es über Instagrams oEmbed-Endpunkt Bild,
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente Caption und Datum, bringt das Bild auf 640×800 (mittiger Crop auf 4:5) und schreibt
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain. `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) ## 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 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 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, 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 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. 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 **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. Stelle, die sich nicht zurücknehmen lässt.
**Meldungen werden nicht gekappt.** `config('news.max_items')` begrenzt in `news_build_feed()` nur **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 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 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 Archiv-Abschnitt** dafür (30.07.2026 gebaut und am selben Tag verworfen): bei wenigen Artikeln pro

View File

@@ -3,9 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* "Aktuelles": Instagram-Grid aus dem lokalen Cache (data/instagram.json, * "Aktuelles": Instagram-Grid aus data/instagram.json, aufgenommen mit
* geschrieben von bin/instagram-sync.php). Besucher laden ausschließlich * bin/instagram-add.php. Besucher laden ausschließlich lokal gehostete Bilder.
* lokal gehostete Bilder. Ohne Cache: CTA-Karte als Degradation. * Ohne Einträge: CTA-Karte als Degradation.
*/ */
$club = json_load('club'); $club = json_load('club');
$feed = json_load('instagram'); $feed = json_load('instagram');

63
bin/chrome-lib.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Headless Chrome für die CLI-Werkzeuge (nur CLI, bewusst NICHT in app/helpers.php).
*
* Zwei Skripte brauchen einen echten Browser, beide laufen ausschließlich lokal auf einem
* Arbeitsrechner und nie auf dem Server:
* - bin/logo-build.php rastert SVG (GD kann kein SVG) und druckt Vektor-PDF
* - bin/instagram-add.php liest mit --profil die Beitragsliste vom Instagram-Profil
*
* Auf dem All-Inkl-Webspace gibt es kein Chrome. Das ist kein Mangel, sondern die Grenze:
* was diesen Helfer braucht, ist Chat-Arbeit und kein Cron-Job.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Pfad zu Chrome/Chromium, oder null. Reihenfolge: Umgebungsvariable CHROME, dann die
* üblichen Installationsorte.
*/
function chrome_binary(): ?string
{
$env = getenv('CHROME');
if (is_string($env) && $env !== '' && is_executable($env)) {
return $env;
}
foreach ([
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
] as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
/**
* Chrome einmal aufrufen und stdout zurückgeben.
*
* KEIN --user-data-dir: naheliegend wäre ein eigener Profilordner, damit ein offenes
* Chrome-Fenster den Lauf nicht stört. Chrome 152 bleibt damit aber beim ERSTEN Aufruf
* endlos hängen (gemessen: kein Abbruch nach zwei Minuten, kein Ergebnis, keine Meldung).
* Ohne den Schalter läuft jeder Aufruf in Sekunden durch, auch bei geöffnetem Chrome.
* Wer hier etwas ändert, misst die Laufzeit nach.
*
* @param string[] $args zusätzliche Schalter, bereits shell-escaped
*/
function chrome_run(string $chrome, array $args): string
{
$cmd = escapeshellarg($chrome)
. ' --headless --disable-gpu --hide-scrollbars --force-color-profile=srgb'
. ' --no-first-run --no-default-browser-check'
. ' ' . implode(' ', $args) . ' 2>/dev/null';
return (string) shell_exec($cmd);
}

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));
}

View File

@@ -1,274 +0,0 @@
<?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. 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<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;
};
/**
* Posts aus der feed/user-Struktur ziehen (items[] mit image_versions2 statt edges[]).
* @return array<int, array<string, mixed>>
*/
$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('/<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 3 (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 Instagram-Portrait 4:5 (640x800) 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", 'WARN');
continue;
}
$src = imagecreatefromstring($img);
if ($src === false) {
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$w = imagesx($src);
$h = imagesy($src);
// Mittiger Crop auf 4:5 (Instagram-Portrait), dann auf 640x800 skalieren.
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);
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/');

View File

@@ -34,6 +34,7 @@ if (PHP_SAPI !== 'cli') {
} }
require dirname(__DIR__) . '/app/bootstrap.php'; require dirname(__DIR__) . '/app/bootstrap.php';
require_once __DIR__ . '/chrome-lib.php';
$verbose = in_array('-v', $argv, true); $verbose = in_array('-v', $argv, true);
@@ -99,23 +100,9 @@ $srcDir = STORAGE_PATH . '/logo-quellen';
$destDir = PUBLIC_PATH . '/assets/downloads/logo'; $destDir = PUBLIC_PATH . '/assets/downloads/logo';
$tmpDir = sys_get_temp_dir() . '/tsv08-logo-' . getmypid(); $tmpDir = sys_get_temp_dir() . '/tsv08-logo-' . getmypid();
// --- Chrome finden ------------------------------------------------------------ // --- Chrome finden ---
// Reihenfolge: Umgebungsvariable CHROME, dann die üblichen Installationsorte. $chrome = chrome_binary();
$chrome = getenv('CHROME') ?: ''; if ($chrome === null) {
if ($chrome === '') {
foreach ([
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
] as $candidate) {
if (is_executable($candidate)) {
$chrome = $candidate;
break;
}
}
}
if ($chrome === '' || !is_executable($chrome)) {
$fail('Kein Chrome gefunden. Pfad per Umgebungsvariable setzen: CHROME="/Pfad/zu/Chrome" php bin/logo-build.php'); $fail('Kein Chrome gefunden. Pfad per Umgebungsvariable setzen: CHROME="/Pfad/zu/Chrome" php bin/logo-build.php');
} }
@@ -135,21 +122,8 @@ register_shutdown_function(static function () use ($tmpDir): void {
@rmdir($tmpDir); @rmdir($tmpDir);
}); });
/**
* Chrome einmal aufrufen.
*
* KEIN --user-data-dir: naheliegend wäre ein eigener Profilordner, damit ein offenes
* Chrome-Fenster den Lauf nicht stört. Chrome 152 bleibt damit aber beim ERSTEN Aufruf
* endlos hängen (gemessen: kein Abbruch nach zwei Minuten, kein Bild, keine Meldung).
* Ohne den Schalter läuft jeder Aufruf in wenigen Sekunden durch, auch bei geöffnetem
* Chrome. Wer hier etwas ändert, misst die Laufzeit nach.
*/
$runChrome = static function (array $args) use ($chrome): void { $runChrome = static function (array $args) use ($chrome): void {
$cmd = escapeshellarg($chrome) chrome_run($chrome, $args);
. ' --headless --disable-gpu --hide-scrollbars --force-color-profile=srgb'
. ' --no-first-run --no-default-browser-check'
. ' ' . implode(' ', $args) . ' 2>/dev/null';
exec($cmd);
}; };
/** Maße der SVG in mm aus dem Wurzelelement lesen (für die PDF-Seitengröße). */ /** Maße der SVG in mm aus dem Wurzelelement lesen (für die PDF-Seitengröße). */

View File

@@ -19,7 +19,7 @@ declare(strict_types=1);
* - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table * - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table
* - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG) * - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG)
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer * Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
* setzt ihn (wie bin/instagram-sync.php). * setzt ihn (wie bin/instagram-add.php).
*/ */
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP // 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). // setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).

View File

@@ -159,8 +159,10 @@ if ($phpPost <= $phpUpload) {
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung."; $warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
} }
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) --- // --- Datenstand des Cron-Skripts (fehlt nur beim Erstlauf) ---
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) { // Nur noch matchcenter: data/instagram.json ist seit dem 17.09.2026 chat-gepflegt
// (bin/instagram-add.php) und nicht „alt“, nur weil eine Woche nichts passiert ist.
foreach (['matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
$file = DATA_PATH . '/' . $name . '.json'; $file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) { if (!is_file($file)) {
$warnings[] = "data/{$name}.json fehlt — einmal \"php {$script}\" von Hand anstoßen."; $warnings[] = "data/{$name}.json fehlt — einmal \"php {$script}\" von Hand anstoßen.";
@@ -169,6 +171,29 @@ foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchce
} }
} }
// --- Instagram: Einträge und Bilder müssen zusammenpassen ---
// Genau daran ist es am 17.09.2026 auseinandergelaufen: die Bilder waren versioniert,
// data/instagram.json nicht. Ein Eintrag ohne Bild ist auf der Startseite eine kaputte
// Kachel, ein Bild ohne Eintrag eine Leiche, die niemand mehr findet.
$igPosts = json_load('instagram')['posts'] ?? [];
$igFehlt = [];
foreach ($igPosts as $igPost) {
if (!empty($igPost['image']) && !is_file(PUBLIC_PATH . '/assets/' . $igPost['image'])) {
$igFehlt[] = $igPost['image'];
}
}
if ($igFehlt !== []) {
$errors[] = 'Instagram-Beiträge ohne Bild: ' . implode(', ', $igFehlt)
. '. Bild nachholen mit: php bin/instagram-add.php https://www.instagram.com/p/<shortcode>/';
}
$igCodes = array_column($igPosts, 'shortcode');
foreach (glob(PUBLIC_PATH . '/assets/img/instagram/*.jpg') ?: [] as $igDatei) {
if (!in_array(basename($igDatei, '.jpg'), $igCodes, true)) {
$warnings[] = 'Instagram-Bild ohne Eintrag im Feed: ' . basename($igDatei)
. ' — entweder aufnehmen oder löschen.';
}
}
// --- Termin-Quellen: events.json vs. veranstaltungen.json --- // --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne // Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json. // eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.

View File

@@ -43,11 +43,13 @@ $log = static function (string $msg, string $level = 'INFO'): void {
* darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren. * darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren.
* *
* Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf * Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf
* Ordner, die die Sync-Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg, * Ordner, die andere Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg,
* die der BFV nicht mehr führt, bin/instagram-sync.php ältere Posts — am 31.07.2026 * die der BFV nicht mehr führt — am 31.07.2026 nachgewiesen: ein Lauf löschte 18 Wappen
* nachgewiesen: ein Lauf löschte 18 Wappen und 3 Instagram-Bilder. Bei den Wappen * und 3 Instagram-Bilder. Danach zeigte eine archivierte Ausgabe Lücken, und
* dauert das eine Saison, bei Instagram Wochen. Danach zeigte eine archivierte * „gedruckt = fixiert" wäre nur die halbe Wahrheit.
* Ausgabe Lücken, und „gedruckt = fixiert" wäre nur die halbe Wahrheit. * Bei Instagram ist die Gefahr seit dem 17.09.2026 kleiner (der aufräumende
* bin/instagram-sync.php ist weg, Beiträge kommen einzeln über bin/instagram-add.php),
* aber ein Bild kann weiterhin per --entfernen verschwinden. Das Einfrieren bleibt.
* *
* img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind: * img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind:
* ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich * ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich

View File

@@ -17,7 +17,6 @@
* *
* URLs (Schlüssel = cron.key unten): * URLs (Schlüssel = cron.key unten):
* https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key> * https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key>
* https://www.tsv08kulmbach.de/cron.php?job=instagram&key=<cron.key>
* https://www.tsv08kulmbach.de/cron.php?job=logrotate&key=<cron.key> * https://www.tsv08kulmbach.de/cron.php?job=logrotate&key=<cron.key>
* *
* Antwort ist immer leer: 204 = gelaufen oder wegen Mindestabstand übersprungen, * Antwort ist immer leer: 204 = gelaufen oder wegen Mindestabstand übersprungen,
@@ -36,11 +35,7 @@
* Wer den Verkehr zum BFV klein halten will, nimmt stattdessen zwei „täglich"-Jobs * Wer den Verkehr zum BFV klein halten will, nimmt stattdessen zwei „täglich"-Jobs
* (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen. * (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen.
* *
* 2. Instagram — ZWEI Jobs „täglich", 06:17 und 18:17 * 2. Logpflege — „täglich", 03:23 (`?job=logrotate`)
* Zwei Einträge, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht stündlich:
* Instagram sperrt auffällige Zugriffsmuster deutlich schneller als der BFV.
*
* 3. Logpflege — „täglich", 03:23 (`?job=logrotate`)
* Rotiert erst über 2 MB und löscht Einträge älter als 90 Tage, ein täglicher Lauf * Rotiert erst über 2 MB und löscht Einträge älter als 90 Tage, ein täglicher Lauf
* ist deshalb meistens ein No-Op. Wöchentlich genügt auch. * ist deshalb meistens ein No-Op. Wöchentlich genügt auch.
* *
@@ -118,19 +113,22 @@ return [
// Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";' // Erzeugen: php -r 'echo bin2hex(random_bytes(24)), "\n";'
'key' => 'CHANGE_ME', 'key' => 'CHANGE_ME',
// Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei // Mindestabstand zwischen zwei Läufen desselben Jobs in Sekunden. Schützt bei
// geleaktem Link davor, dass BFV oder Instagram unsere Server-IP sperren. // geleaktem Link davor, dass der BFV unsere Server-IP sperrt.
// Muss kleiner sein als der engste gewünschte Takt (½-stündlich = 1800 s). // Muss kleiner sein als der engste gewünschte Takt (½-stündlich = 1800 s).
'min_interval' => 240, 'min_interval' => 240,
], ],
// Instagram-Scraper (bin/instagram-sync.php). // Instagram-Beiträge (bin/instagram-add.php). KEIN Cron mehr: der Scraper ist am
// 17.09.2026 entfallen, Beiträge werden einzeln über ihre Post-URL aufgenommen.
// 'username' dient dabei als Prüfung, dass kein fremder Beitrag hereinrutscht,
// 'max_posts' begrenzt nur die Kacheln auf der Startseite.
'instagram' => [ 'instagram' => [
'username' => 'tsv08kulmbach', 'username' => 'tsv08kulmbach',
'max_posts' => 9, 'max_posts' => 9,
], ],
// Zentrale News-Seite (app/pages/news.php) — mischt data/news.json, // Zentrale News-Seite (app/pages/news.php) — mischt data/news.json,
// Instagram-Cache und Matchcenter-Ergebnisse; hier nur die Obergrenze für // Instagram-Beiträge und Matchcenter-Ergebnisse; hier nur die Obergrenze für
// die Gesamtliste (News allein könnte sonst unbegrenzt wachsen). // die Gesamtliste (News allein könnte sonst unbegrenzt wachsen).
'news' => [ 'news' => [
'max_items' => 20, 'max_items' => 20,

150
data/instagram.json Normal file
View File

@@ -0,0 +1,150 @@
{
"fetched_at": 1789636733,
"username": "tsv08kulmbach",
"posts": [
{
"shortcode": "DdROjQqjI1_",
"caption": "Ergebnisse 🔴⚫️",
"alt": "Ergebnisse 🔴⚫️",
"taken_at": 1789391962,
"is_video": false,
"image": "img/instagram/DdROjQqjI1_.jpg",
"url": "https://www.instagram.com/p/DdROjQqjI1_/"
},
{
"shortcode": "DdHvGEDDLIh",
"caption": "🔴⚫️-Programm am Wochenende",
"alt": "🔴⚫️-Programm am Wochenende",
"taken_at": 1789073480,
"is_video": false,
"image": "img/instagram/DdHvGEDDLIh.jpg",
"url": "https://www.instagram.com/p/DdHvGEDDLIh/"
},
{
"shortcode": "DdB5NiwDLiq",
"caption": "Rückblick zum Sportfest im Katzbachtal\n\nDrei Tage voller Sport, Spannung und Teamgeist – unser Sportfest mit Jugendturnieren, Jugendspielen sowie Herren- und Frauen-Punktspielen. ⚽️\n\nVielen Dank an alle Mannschaften, Trainer:innen, Helfer:innen und Fans, die dieses Wochenende zu etwas Besonderem gemacht haben! 🙌 \nOhne euch wäre das nicht möglich gewesen!\n\n🔴⚫️",
"alt": "Rückblick zum Sportfest im Katzbachtal",
"taken_at": 1788877457,
"is_video": false,
"image": "img/instagram/DdB5NiwDLiq.jpg",
"url": "https://www.instagram.com/p/DdB5NiwDLiq/"
},
{
"shortcode": "Dc-41FTDC7u",
"caption": "Heimsiege für unsere Erste und unsere Frauen! 🥳\nLeider keine Punkte für unsere Zweite!\n\n🔴⚫️",
"alt": "Heimsiege für unsere Erste und unsere Frauen! 🥳",
"taken_at": 1788776594,
"is_video": false,
"image": "img/instagram/Dc-41FTDC7u.jpg",
"url": "https://www.instagram.com/p/Dc-41FTDC7u/"
},
{
"shortcode": "Dc0lDcXDEG-",
"caption": "Vorschau aufs Sportfest-Wochenende 🔴⚫️\n\nWir freuen uns auf euer kommen und eure Unterstützung!",
"alt": "Vorschau aufs Sportfest-Wochenende 🔴⚫️",
"taken_at": 1788430681,
"is_video": false,
"image": "img/instagram/Dc0lDcXDEG-.jpg",
"url": "https://www.instagram.com/p/Dc0lDcXDEG-/"
},
{
"shortcode": "DcswzHHMHUs",
"caption": "Verdienter Heimdreier für unsere Erste am vergangenen Samstag 💪🏻\n\nÜberzeugende 1. Halbzeit und eine etwas wilde 2. Halbzeit 🤹🏻‍♂️",
"alt": "Verdienter Heimdreier für unsere Erste am vergangenen Samstag 💪🏻",
"taken_at": 1788168403,
"is_video": false,
"image": "img/instagram/DcswzHHMHUs.jpg",
"url": "https://www.instagram.com/p/DcswzHHMHUs/"
},
{
"shortcode": "DcrER7ODIlF",
"caption": "Sportfest im Katzbachtal!\n\nMittlerweile ist es ein echtes Highlight in unserem Vereinskalender: Das Sportfest im Katzbachtal!⚽🎉\n\n🗓️ 04.09. – 06.09.\n\nVon Freitag bis Sonntagabend erwartet euch ein abwechslungsreiches Programm für Groß und Klein – mit Fußball pur, Unterhaltung und geselligem Beisammensein.\nNatürlich ist auch für das leibliche Wohl bestens gesorgt 🌭🍕🍹🍺\n\nWir freuen uns auf euren Besuch! ❤️🖤",
"alt": "Sportfest im Katzbachtal!",
"taken_at": 1788111508,
"is_video": false,
"image": "img/instagram/DcrER7ODIlF.jpg",
"url": "https://www.instagram.com/p/DcrER7ODIlF/"
},
{
"shortcode": "DcjmUaMMJnD",
"caption": "🔴⚫️\n\nErste Herren\n6. Spieltag\n🗓️ Samstag, 29.8.\n🏟️ Heimspiel gegen Marktschorgast\n🕑 Anstoß: 14 Uhr\n\nZweite Herren \n🗓️Mittwoch, 2.9.\n🏟️ In Melkendorf gegen Thurnau 2\n🕑 Anstoß: 18 Uhr\n\nWir freuen uns auf eure Unterstützung!",
"alt": "🔴⚫️",
"taken_at": 1787860919,
"is_video": false,
"image": "img/instagram/DcjmUaMMJnD.jpg",
"url": "https://www.instagram.com/p/DcjmUaMMJnD/"
},
{
"shortcode": "DcavewWMsVH",
"caption": "Auswärtssieg für unsere Erste! 🙌",
"alt": "Auswärtssieg für unsere Erste! 🙌",
"taken_at": 1787563732,
"is_video": false,
"image": "img/instagram/DcavewWMsVH.jpg",
"url": "https://www.instagram.com/p/DcavewWMsVH/"
},
{
"shortcode": "DcQa-pwshfy",
"caption": "Für unsere Erste gehts auswärts weiter! 💪🏻\n\n🔴⚫️",
"alt": "Für unsere Erste gehts auswärts weiter! 💪🏻",
"taken_at": 1787217439,
"is_video": false,
"image": "img/instagram/DcQa-pwshfy.jpg",
"url": "https://www.instagram.com/p/DcQa-pwshfy/"
},
{
"shortcode": "DcPBlNMjKbP",
"caption": "🔴⚫️ Schnuppertraining im Katzbachtal! \n\n📅 29.08.\n⏰ 10:00–11:00 Uhr\n📍 Katzbachtal\n\nEine Stunde Fußball pur – für alle von der G- bis zur C-Jugend!",
"alt": "🔴⚫️ Schnuppertraining im Katzbachtal!",
"taken_at": 1787170569,
"is_video": false,
"image": "img/instagram/DcPBlNMjKbP.jpg",
"url": "https://www.instagram.com/p/DcPBlNMjKbP/"
},
{
"shortcode": "DcJDDkEMo0d",
"caption": "Leider keine Punkte am vergangenen Spieltag…😤\n\nAbhaken und voller Fokus auf das kommende Auswärtsspiel!",
"alt": "Leider keine Punkte am vergangenen Spieltag…😤",
"taken_at": 1786970016,
"is_video": false,
"image": "img/instagram/DcJDDkEMo0d.jpg",
"url": "https://www.instagram.com/p/DcJDDkEMo0d/"
},
{
"shortcode": "Db-XCrOsd4q",
"caption": "Heimspiel! - bei gewohnten Temperaturen 🔥",
"alt": "Heimspiel! - bei gewohnten Temperaturen 🔥",
"taken_at": 1786611395,
"is_video": false,
"image": "img/instagram/Db-XCrOsd4q.jpg",
"url": "https://www.instagram.com/p/Db-XCrOsd4q/"
},
{
"shortcode": "Db2ya5TDMLC",
"caption": "Unsere Ergebnisse vom Wochenende 🔴⚫️",
"alt": "Unsere Ergebnisse vom Wochenende 🔴⚫️",
"taken_at": 1786357314,
"is_video": false,
"image": "img/instagram/Db2ya5TDMLC.jpg",
"url": "https://www.instagram.com/p/Db2ya5TDMLC/"
},
{
"shortcode": "Dbs_SENMqBr",
"caption": "Weiter geht’s! 🔴⚫️",
"alt": "Weiter geht’s! 🔴⚫️",
"taken_at": 1786028513,
"is_video": false,
"image": "img/instagram/Dbs_SENMqBr.jpg",
"url": "https://www.instagram.com/p/Dbs_SENMqBr/"
},
{
"shortcode": "Dbk0vKCs-p2",
"caption": "Gebrauchter Samstagnachmittag…\nMund abputzen und weiter machen!\n\n🔴⚫️",
"alt": "Gebrauchter Samstagnachmittag…",
"taken_at": 1785754549,
"is_video": false,
"image": "img/instagram/Dbk0vKCs-p2.jpg",
"url": "https://www.instagram.com/p/Dbk0vKCs-p2/"
}
]
}

View File

@@ -197,21 +197,26 @@ Startseite kommen. Weißes Blatt oder 500er? Dann [SSH]
### C3 [SSH] Datenstände erzeugen ### C3 [SSH] Datenstände erzeugen
`data/matchcenter.json` und `data/instagram.json` sind bewusst nicht im Repo, die `data/matchcenter.json` ist bewusst nicht im Repo, die holt der Sync. Vor dem
holen die Syncs. Vor dem ersten Lauf fehlen Tabellen, Spiele und der ersten Lauf fehlen Tabellen und Spiele (die Seite bricht deswegen nicht, sie zeigt
Instagram-Block (die Seite bricht deswegen nicht, sie zeigt Ersatzinhalte). Ersatzinhalte).
```bash ```bash
cd /www/htdocs/w01ca75d/tsv08kulmbach-website cd /www/htdocs/w01ca75d/tsv08kulmbach-website
php bin/matchcenter-sync.php # Spiele, Ergebnisse, Tabellen, Wappen php bin/matchcenter-sync.php # Spiele, Ergebnisse, Tabellen, Wappen
php bin/instagram-sync.php # Posts und Bilder
``` ```
**Der Instagram-Lauf ist der Wackelkandidat des ganzen Deploys.** Instagram sperrt **Instagram braucht hier nichts mehr.** Seit dem 17.09.2026 gibt es keinen Scraper
Rechenzentrums-IPs; bisher lief der Scraper nur über einen privaten Anschluss. und keinen Cron dafür: `data/instagram.json` liegt IM REPO und kommt mit dem Pull
Schlägt er hier fehl, ist das kein Fehler im Deploy: dann bleibt der Weg, ihn von mit, die Bilder ebenso. Neue Beiträge nimmt Claude im Chat mit
zu Hause laufen zu lassen und `data/instagram.json` samt Bildern hochzuladen, oder `php bin/instagram-add.php <post-url>` auf. Hintergrund in CLAUDE.md, Abschnitt
der Block wird redaktionell gepflegt. Die Website funktioniert in jedem Fall. „Instagram-Beiträge".
**Einmalig beim ersten Pull nach dem 17.09.2026:** auf dem Server liegt noch eine
alte, unversionierte `data/instagram.json` vom letzten Sync-Lauf. Git bricht den
Pull sonst ab („untracked working tree file would be overwritten"). Sie ist
reiner Skript-Output und enthält keine Pflegearbeit, also einfach löschen:
`rm data/instagram.json && git pull`.
### C4 [SSH] `preflight` bis null Fehler ### C4 [SSH] `preflight` bis null Fehler
@@ -270,11 +275,12 @@ nicht `app_secret`). Steht er leer, antwortet der Endpoint mit 403 und **nichts*
| # | Pfad | Zeitpunkt | | # | Pfad | Zeitpunkt |
|---|---|---| |---|---|---|
| 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 | | 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 |
| 2 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 06:17 | | 2 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 |
| 3 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 18:17 |
| 4 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 |
Vier Dinge dahinter sind Absicht: Die beiden früheren Instagram-Jobs (06:17 und 18:17) sind am 17.09.2026 entfallen und
**gehören im KAS gelöscht** — der Endpoint kennt den Job nicht mehr und antwortet mit 403.
Drei Dinge dahinter sind Absicht:
- **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage - **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage
nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah. nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah.
@@ -282,8 +288,6 @@ Vier Dinge dahinter sind Absicht:
spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein
halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass
Samstagsergebnisse erst abends stehen. Samstagsergebnisse erst abends stehen.
- **Instagram zwei Einträge**, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht
stündlich: Instagram sperrt auffällige Zugriffsmuster schneller als der BFV.
- **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich - **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich
per Lock, sonst fällt einer aus). per Lock, sonst fällt einer aus).
- **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder - **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -17,7 +17,7 @@ declare(strict_types=1);
* *
* Warum nicht GitHub Actions mit FTP-Upload: dort müsste ein Dritter Schreibzugang * Warum nicht GitHub Actions mit FTP-Upload: dort müsste ein Dritter Schreibzugang
* zum ganzen Webspace kennen. Hier kennt der Aufrufer einen Schlüssel, der * zum ganzen Webspace kennen. Hier kennt der Aufrufer einen Schlüssel, der
* ausschließlich "starte einen der drei Jobs" kann — deutlich kleinerer Radius. * ausschließlich "starte einen der beiden Jobs" kann — deutlich kleinerer Radius.
* *
* Aufruf: https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key> * Aufruf: https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=<cron.key>
* Schlüssel alternativ als Header X-Cron-Key (bevorzugt, landet nicht im Log * Schlüssel alternativ als Header X-Cron-Key (bevorzugt, landet nicht im Log
@@ -27,7 +27,7 @@ declare(strict_types=1);
* - Job-Whitelist, keine freien Skriptnamen aus der URL * - Job-Whitelist, keine freien Skriptnamen aus der URL
* - Schlüssel aus config.php, Vergleich mit hash_equals; ohne echten Schlüssel ist zu * - Schlüssel aus config.php, Vergleich mit hash_equals; ohne echten Schlüssel ist zu
* - Mindestabstand zwischen zwei Läufen (cron.min_interval) — ein geleakter Link kann * - Mindestabstand zwischen zwei Läufen (cron.min_interval) — ein geleakter Link kann
* damit nicht dauerfeuern und uns die Server-IP bei BFV/Instagram sperren lassen * damit nicht dauerfeuern und uns die Server-IP beim BFV sperren lassen
* - flock in den Skripten selbst: parallele Aufrufe überspringen sich * - flock in den Skripten selbst: parallele Aufrufe überspringen sich
* - Antwort ist immer leer. 403 nur bei falschem Schlüssel/Job, damit ein falsch * - Antwort ist immer leer. 403 nur bei falschem Schlüssel/Job, damit ein falsch
* eingerichteter Cron-Dienst überhaupt auffällt (sonst syncte wochenlang nichts, * eingerichteter Cron-Dienst überhaupt auffällt (sonst syncte wochenlang nichts,
@@ -51,9 +51,11 @@ header('Cache-Control: no-store');
// Code). Der Job passt in denselben Rahmen wie die zwei Syncs: kein Parameter aus der // Code). Der Job passt in denselben Rahmen wie die zwei Syncs: kein Parameter aus der
// URL, keine Netzverbindung nach außen, er schreibt ausschließlich in storage/logs. // URL, keine Netzverbindung nach außen, er schreibt ausschließlich in storage/logs.
// Eine VIERTE Ausnahme braucht wieder denselben Aufwand an Begründung. // Eine VIERTE Ausnahme braucht wieder denselben Aufwand an Begründung.
// 'instagram' stand hier bis zum 17.09.2026 und ist entfallen: Instagram verlangt seit
// Anfang September Login für den Profilabruf, der Scraper ist gelöscht. Beiträge kommen
// jetzt einzeln über bin/instagram-add.php herein (Chat-Pflege, kein Cron).
$jobs = [ $jobs = [
'matchcenter' => 'bin/matchcenter-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php',
'instagram' => 'bin/instagram-sync.php',
'logrotate' => 'bin/log-rotate.php', 'logrotate' => 'bin/log-rotate.php',
]; ];