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
8
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
79
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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -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/');
|
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
BIN
public/assets/img/instagram/Db2ya5TDMLC.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
public/assets/img/instagram/Dbk0vKCs-p2.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/assets/img/instagram/Dbs_SENMqBr.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/assets/img/instagram/Dc-41FTDC7u.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/assets/img/instagram/DdB5NiwDLiq.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
public/assets/img/instagram/DdHvGEDDLIh.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/assets/img/instagram/DdROjQqjI1_.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||