Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74614672b | ||
|
|
eeffa578cb | ||
|
|
2379f4ac9f |
@@ -1,16 +0,0 @@
|
|||||||
# Kopieren nach .env und mit echten Werten befuellen.
|
|
||||||
# .env ist in .gitignore eingetragen und wird NICHT eingecheckt.
|
|
||||||
|
|
||||||
# Storage Zone -> Access-Tab im Bunny.net Dashboard
|
|
||||||
BUNNY_STORAGE_ZONE=iwz-ferienwohnungen
|
|
||||||
BUNNY_STORAGE_HOST=storage.bunnycdn.com
|
|
||||||
BUNNY_STORAGE_PASSWORD=
|
|
||||||
|
|
||||||
# Pull Zone -> CDN -> Security-Tab im Bunny.net Dashboard (Token Authentication)
|
|
||||||
BUNNY_PULL_ZONE_URL=https://iwz-ferienwohnungen.b-cdn.net
|
|
||||||
BUNNY_AUTH_KEY=
|
|
||||||
|
|
||||||
# Account-API-Key fuer den CDN-Cache-Purge nach dem Deploy.
|
|
||||||
# Dashboard -> oben rechts auf den Namen -> Account -> API -> "API Key".
|
|
||||||
# ACHTUNG: nicht der BUNNY_AUTH_KEY oben, sondern der Account-weite API-Key!
|
|
||||||
BUNNY_API_KEY=
|
|
||||||
@@ -4,3 +4,10 @@ resources/
|
|||||||
.env
|
.env
|
||||||
.deploy_manifest.json
|
.deploy_manifest.json
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# WordPress-Export der alten Seite (nur temporär, enthält DB-Zugangsdaten im Klartext)
|
||||||
|
app816341166/
|
||||||
|
|
||||||
|
# Nutzungsanalyse (analyze.py): heruntergeladene Rohlogs + erzeugte Reports
|
||||||
|
logs/
|
||||||
|
results/
|
||||||
|
|||||||
@@ -14,14 +14,35 @@ Drei Ferienwohnungen in Kaufbeuren-Hirschzell / Allgäu.
|
|||||||
- **Buchungskalender**: webplanner.de (externer Dienst, per Link eingebunden)
|
- **Buchungskalender**: webplanner.de (externer Dienst, per Link eingebunden)
|
||||||
- **Git**: eigene Gitea-Instanz (kein GitHub/GitLab, kein CI/CD-Workflow)
|
- **Git**: eigene Gitea-Instanz (kein GitHub/GitLab, kein CI/CD-Workflow)
|
||||||
- **Deploy**: manuell per `deploy.bat` oder `deploy.py` → `hugo --gc --minify` + Upload nach Bunny.net Storage Zone (per Bunny-API/FTP, analog zum Ansatz in `.github/workflows/deploy.yml`-Vorlage, aber lokal statt als GitHub Action)
|
- **Deploy**: manuell per `deploy.bat` oder `deploy.py` → `hugo --gc --minify` + Upload nach Bunny.net Storage Zone (per Bunny-API/FTP, analog zum Ansatz in `.github/workflows/deploy.yml`-Vorlage, aber lokal statt als GitHub Action)
|
||||||
|
- **Nutzungsanalyse**: Server-Log-Auswertung mit GoAccess (kein JS-Tracking, keine Cookies, kein Consent-Banner). Skript `analyze.bat` / `analyze.py` lädt die Access-Logs per SFTP und erzeugt einen HTML-Report. Siehe `doc/analyse.md`.
|
||||||
|
|
||||||
|
## Hosting-Roadmap (Stand aktuell)
|
||||||
|
|
||||||
|
- **Jetzt**: Deploy läuft auf **Bunny.net** – für das erste Ausrollen der Instanz zur Kontrolle und Absprache. Die vorhandene Bunny-Konfiguration (Storage Zone, Pull Zone, `.env`) ist in Ordnung und bleibt vorerst so. `deploy.bat` / `deploy.py` deployen weiterhin nach Bunny.
|
||||||
|
- **Später**: Die Instanz wird auf **IONOS-Webspace** ausgerollt (Deploy-Ziel wird dann umgestellt).
|
||||||
|
- **Nutzungsanalyse jetzt schon erprobbar**: Die Log-Analyse (`analyze.py`) wird bereits mit den **IONOS-Access-Logs** getestet (SFTP-Zugang zum IONOS `/logs`-Ordner), unabhängig davon, dass das Deploy-Ziel noch Bunny ist. Nach dem IONOS-Umzug bleibt der Analyse-Workflow gleich.
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
iwz/
|
iwz/
|
||||||
├── hugo.toml # Konfiguration: baseURL, Menü, Kontaktdaten (Params)
|
├── hugo.toml # Konfiguration: baseURL, Menü, Kontaktdaten (Params)
|
||||||
├── deploy.bat # Windows: hugo build + wrangler deploy
|
├── requirements.txt # Python-Abhängigkeiten (paramiko für analyze)
|
||||||
├── deploy.py # Python-Alternative zum deploy.bat
|
├── bin/ # Alle .bat-Skripte (Einstiegspunkte)
|
||||||
|
│ ├── setenv.bat # setzt IWZ, IWZ_BIN, IWZ_LIB, IWZ_ENV ... + PYTHONPATH
|
||||||
|
│ ├── install_py.bat # legt .venv an + pip install -r requirements.txt
|
||||||
|
│ ├── get_cmd.bat # öffnet Shell mit gesetzter Umgebung
|
||||||
|
│ ├── deploy.bat # → lib/deploy.py (Vollupload nach Bunny)
|
||||||
|
│ ├── deploy-lastdiff.bat# → lib/deploy_lastdiff.py (nur Änderungen)
|
||||||
|
│ ├── analyze.bat # → lib/analyze.py (IONOS-Logs + GoAccess)
|
||||||
|
│ ├── provide_previewlink.bat # → lib/preview_link.py
|
||||||
|
│ ├── .env # Secrets (NICHT im Git; von setenv als IWZ_ENV gesetzt)
|
||||||
|
│ └── .env.example # Vorlage zum Kopieren nach bin/.env
|
||||||
|
├── lib/ # Alle .py-Skripte (von den .bat aufgerufen)
|
||||||
|
│ ├── deploy.py # Hugo-Build + FTP-Upload nach Bunny Storage Zone
|
||||||
|
│ ├── deploy_lastdiff.py # nur geänderte Dateien (Hash-Manifest)
|
||||||
|
│ ├── analyze.py # SFTP-Download der Logs + GoAccess-Report
|
||||||
|
│ └── preview_link.py # signierter Bunny-Vorschau-Link
|
||||||
├── content/
|
├── content/
|
||||||
│ ├── _index.md # Startseite
|
│ ├── _index.md # Startseite
|
||||||
│ ├── preise.md # Preistabelle + Buchungsbedingungen
|
│ ├── preise.md # Preistabelle + Buchungsbedingungen
|
||||||
@@ -94,8 +115,15 @@ hugo --gc --minify
|
|||||||
# danach: Upload von ./public zur Bunny.net Storage Zone
|
# danach: Upload von ./public zur Bunny.net Storage Zone
|
||||||
# (Storage Zone Name + API-Key/Storage-Passwort als Secrets/env, nicht im Git)
|
# (Storage Zone Name + API-Key/Storage-Passwort als Secrets/env, nicht im Git)
|
||||||
|
|
||||||
# Bei jeder Änderung:
|
# Bei jeder Änderung (Skripte liegen jetzt in bin/, Python in lib/):
|
||||||
deploy.bat
|
bin\deploy.bat # Vollupload
|
||||||
# oder:
|
# oder nur geänderte Dateien:
|
||||||
python deploy.py
|
bin\deploy-lastdiff.bat
|
||||||
|
# oder direkt:
|
||||||
|
python lib\deploy.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die `.bat`-Skripte in `bin/` rufen `setenv.bat` auf (setzt `IWZ_*` und
|
||||||
|
`PYTHONPATH`), wechseln ins Projekt-Root und starten das zugehörige Skript in
|
||||||
|
`lib/`. Die Secrets liegen in `bin/.env` (von `setenv.bat` als `IWZ_ENV`
|
||||||
|
gesetzt; die Python-Skripte lesen diesen Pfad, sonst Fallback auf `bin/.env`).
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Kopieren nach .env und mit echten Werten befuellen.
|
||||||
|
# .env ist in .gitignore eingetragen und wird NICHT eingecheckt.
|
||||||
|
|
||||||
|
# Storage Zone -> Access-Tab im Bunny.net Dashboard
|
||||||
|
BUNNY_STORAGE_ZONE=iwz-ferienwohnungen
|
||||||
|
BUNNY_STORAGE_HOST=storage.bunnycdn.com
|
||||||
|
BUNNY_STORAGE_PASSWORD=
|
||||||
|
|
||||||
|
# Pull Zone -> CDN -> Security-Tab im Bunny.net Dashboard (Token Authentication)
|
||||||
|
BUNNY_PULL_ZONE_URL=https://iwz-ferienwohnungen.b-cdn.net
|
||||||
|
BUNNY_AUTH_KEY=
|
||||||
|
|
||||||
|
# Account-API-Key fuer den CDN-Cache-Purge nach dem Deploy.
|
||||||
|
# Dashboard -> oben rechts auf den Namen -> Account -> API -> "API Key".
|
||||||
|
# ACHTUNG: nicht der BUNNY_AUTH_KEY oben, sondern der Account-weite API-Key!
|
||||||
|
BUNNY_API_KEY=
|
||||||
|
|
||||||
|
# --- Nutzungsanalyse (analyze.py) ------------------------------------------
|
||||||
|
# SFTP-Zugang zu IONOS (gleiche Zugangsdaten wie im IONOS-Kundenbereich / SFTP).
|
||||||
|
IONOS_SFTP_HOST=
|
||||||
|
IONOS_SFTP_PORT=22
|
||||||
|
IONOS_SFTP_USER=
|
||||||
|
IONOS_SFTP_PASSWORD=
|
||||||
|
# Ordner mit den Access-Logs auf dem Server (im Screenshot: /logs).
|
||||||
|
IONOS_LOGS_DIR=/logs
|
||||||
|
|
||||||
|
# GoAccess-Aufruf:
|
||||||
|
# GOACCESS_MODE=wsl -> GoAccess laeuft in der WSL (empfohlen, Standard).
|
||||||
|
# Installation: bin\install_py.bat prueft WSL+GoAccess
|
||||||
|
# und zeigt sonst die Installationsbefehle.
|
||||||
|
# GOACCESS_MODE=exe -> nativer Windows-Build; dann GOACCESS_EXE auf die
|
||||||
|
# goaccess.exe setzen (Download: https://goaccess.io/download).
|
||||||
|
GOACCESS_MODE=wsl
|
||||||
|
GOACCESS_EXE=goaccess
|
||||||
|
|
||||||
|
# Apache-Logformat. IONOS nutzt i.d.R. das Combined Log Format.
|
||||||
|
# Falls der Report leer bleibt -> siehe doc/analyse.md (Format anpassen).
|
||||||
|
LOG_FORMAT=COMBINED
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM SPS_SKEL - Python Virtual Environment aktivieren
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat"
|
||||||
|
|
||||||
|
if not exist "%IWZ%\.venv" (
|
||||||
|
echo FEHLER: Virtual environment nicht gefunden.
|
||||||
|
echo Bitte zuerst bin\install_py.bat ausfuehren.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
echo Virtuelle Umgebung aktiviert.
|
||||||
|
echo Python-Version:
|
||||||
|
python --version
|
||||||
|
echo.
|
||||||
|
echo Installierte Pakete:
|
||||||
|
pip list
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ Ferienwohnungen - Nutzungsanalyse (IONOS-Logs + GoAccess)
|
||||||
|
REM ================================================================
|
||||||
|
REM Aufruf:
|
||||||
|
REM bin\analyze.bat Logs laden + Report bauen + oeffnen
|
||||||
|
REM bin\analyze.bat --no-download nur neu auswerten (schon geladene Logs)
|
||||||
|
REM ================================================================
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat" >nul
|
||||||
|
|
||||||
|
REM Virtuelle Umgebung nutzen, falls vorhanden (paramiko liegt dort)
|
||||||
|
if exist "%IWZ%\.venv\Scripts\activate.bat" (
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PY=py"
|
||||||
|
py --version >nul 2>&1 || set "PY=python"
|
||||||
|
|
||||||
|
cd /d "%IWZ%"
|
||||||
|
"%PY%" "%IWZ_LIB%\analyze.py" %*
|
||||||
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%RC%"=="0" pause
|
||||||
|
endlocal & exit /b %RC%
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ Ferienwohnungen - Deploy (nur geaenderte Dateien)
|
||||||
|
REM ================================================================
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat" >nul
|
||||||
|
|
||||||
|
REM Virtuelle Umgebung nutzen, falls vorhanden (paramiko etc. liegen dort)
|
||||||
|
if exist "%IWZ%\.venv\Scripts\activate.bat" (
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PY=py"
|
||||||
|
py --version >nul 2>&1 || set "PY=python"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === IWZ Ferienwohnungen - Deploy (nur Aenderungen) ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d "%IWZ%"
|
||||||
|
"%PY%" "%IWZ_LIB%\deploy_lastdiff.py" %*
|
||||||
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%RC%"=="0" pause
|
||||||
|
endlocal & exit /b %RC%
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ Ferienwohnungen - Deploy (Vollupload nach Bunny.net)
|
||||||
|
REM ================================================================
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Umgebung setzen (IWZ, IWZ_LIB, IWZ_ENV, PYTHONPATH ...)
|
||||||
|
call "%~dp0setenv.bat" >nul
|
||||||
|
|
||||||
|
REM Virtuelle Umgebung nutzen, falls vorhanden (paramiko etc. liegen dort)
|
||||||
|
if exist "%IWZ%\.venv\Scripts\activate.bat" (
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Python-Interpreter bestimmen (py-Launcher bevorzugt, sonst python)
|
||||||
|
set "PY=py"
|
||||||
|
py --version >nul 2>&1 || set "PY=python"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === IWZ Ferienwohnungen - Deploy ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Aus dem Projekt-Root heraus bauen (hugo + public liegen dort)
|
||||||
|
cd /d "%IWZ%"
|
||||||
|
"%PY%" "%IWZ_LIB%\deploy.py" %*
|
||||||
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%RC%"=="0" pause
|
||||||
|
endlocal & exit /b %RC%
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ Ferienwohnungen - Shell mit gesetzten Umgebungsvariablen oeffnen
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat"
|
||||||
|
start cmd /k "echo IWZ Umgebung aktiv. && echo IWZ=%IWZ%"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ - Python Virtual Environment einrichten
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat"
|
||||||
|
|
||||||
|
if not exist "%IWZ%\.venv" (
|
||||||
|
echo Initialisiere Python virtual environment...
|
||||||
|
py -m venv "%IWZ%\.venv" --upgrade-deps
|
||||||
|
echo Erfolgreich.
|
||||||
|
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
echo Installiere erforderliche Python Packages...
|
||||||
|
pip install -r "%IWZ%\requirements.txt" -q
|
||||||
|
echo Erfolgreich.
|
||||||
|
deactivate
|
||||||
|
) else (
|
||||||
|
echo Erforderliche Python Packages bereits installiert!
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo Pruefe Nutzungsanalyse-Voraussetzungen (WSL + GoAccess)
|
||||||
|
echo ================================================================
|
||||||
|
|
||||||
|
REM 1) Ist WSL verfuegbar?
|
||||||
|
where wsl >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [FEHLT] WSL ist nicht installiert.
|
||||||
|
echo So einrichten ^(einmalig, PowerShell als Administrator^):
|
||||||
|
echo wsl --install
|
||||||
|
echo Danach Windows neu starten und die Ubuntu-Ersteinrichtung
|
||||||
|
echo ^(Benutzername/Passwort^) durchlaufen. Anschliessend GoAccess
|
||||||
|
echo wie unten installieren.
|
||||||
|
goto :wsl_done
|
||||||
|
)
|
||||||
|
echo [OK] WSL gefunden.
|
||||||
|
|
||||||
|
REM 2) Ist GoAccess in der WSL installiert?
|
||||||
|
wsl -e bash -lc "command -v goaccess >/dev/null 2>&1"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [FEHLT] GoAccess ist in der WSL nicht installiert.
|
||||||
|
echo So installieren:
|
||||||
|
echo wsl -e sudo apt update
|
||||||
|
echo wsl -e sudo apt install -y goaccess
|
||||||
|
echo ^(fragt einmalig nach dem WSL-sudo-Passwort^)
|
||||||
|
) else (
|
||||||
|
for /f "delims=" %%V in ('wsl -e bash -lc "goaccess --version ^| head -1"') do set "GA_VER=%%V"
|
||||||
|
echo [OK] GoAccess in der WSL: %GA_VER%
|
||||||
|
)
|
||||||
|
|
||||||
|
:wsl_done
|
||||||
|
echo ================================================================
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM Ferienwohnungen Wolframm - Vorschau-Link erzeugen
|
||||||
|
REM ================================================================
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
call "%~dp0setenv.bat" >nul
|
||||||
|
|
||||||
|
REM Virtuelle Umgebung nutzen, falls vorhanden
|
||||||
|
if exist "%IWZ%\.venv\Scripts\activate.bat" (
|
||||||
|
call "%IWZ%\.venv\Scripts\activate.bat"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PY=py"
|
||||||
|
py --version >nul 2>&1 || set "PY=python"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === Ferienwohnungen Wolframm - Vorschau-Link erzeugen ===
|
||||||
|
|
||||||
|
cd /d "%IWZ%"
|
||||||
|
"%PY%" "%IWZ_LIB%\preview_link.py" %*
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Link oben markieren und mit Strg+C kopieren.
|
||||||
|
echo.
|
||||||
|
endlocal
|
||||||
|
cmd /k
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
REM ================================================================
|
||||||
|
REM IWZ Ferienwohnungen - Umgebungsvariablen Setup
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
if defined IWZ_BIN (
|
||||||
|
echo IWZ Umgebung bereits gesetzt.
|
||||||
|
goto :show
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Setting up environment variables for IWZ ...
|
||||||
|
|
||||||
|
REM Basis-Projektpfad (Ordner ueber bin\)
|
||||||
|
set "IWZ=%~dp0.."
|
||||||
|
if "%IWZ:~-6%"=="bin\.." set "IWZ=%IWZ:~0,-6%"
|
||||||
|
if "%IWZ:~-1%"=="\" set "IWZ=%IWZ:~0,-1%"
|
||||||
|
|
||||||
|
REM Pfade fuer verschiedene Komponenten
|
||||||
|
set "IWZ_BIN=%IWZ%\bin"
|
||||||
|
set "IWZ_LIB=%IWZ%\lib"
|
||||||
|
set "IWZ_DOC=%IWZ%\doc"
|
||||||
|
set "IWZ_CONTENT=%IWZ%\content"
|
||||||
|
set "IWZ_LAYOUTS=%IWZ%\layouts"
|
||||||
|
set "IWZ_STATIC=%IWZ%\static"
|
||||||
|
set "IWZ_PUBLIC=%IWZ%\public"
|
||||||
|
set "IWZ_LOGS=%IWZ%\logs"
|
||||||
|
set "IWZ_RESULTS=%IWZ%\results"
|
||||||
|
|
||||||
|
REM Pfad zur .env (liegt jetzt in bin\). Die Python-Skripte lesen IWZ_ENV.
|
||||||
|
set "IWZ_ENV=%IWZ_BIN%\.env"
|
||||||
|
|
||||||
|
REM Python-Pfad erweitern (nur wenn noch nicht vorhanden)
|
||||||
|
echo %PYTHONPATH% | find /i "%IWZ_LIB%" >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
set "PYTHONPATH=%IWZ_LIB%;%PYTHONPATH%"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Ordner erstellen falls sie nicht existieren
|
||||||
|
if not exist "%IWZ_BIN%" mkdir "%IWZ_BIN%"
|
||||||
|
if not exist "%IWZ_LIB%" mkdir "%IWZ_LIB%"
|
||||||
|
if not exist "%IWZ_LOGS%" mkdir "%IWZ_LOGS%"
|
||||||
|
if not exist "%IWZ_RESULTS%" mkdir "%IWZ_RESULTS%"
|
||||||
|
|
||||||
|
REM Optional dauerhaft in der Registry setzen: bin\setenv.bat --persist
|
||||||
|
if /i "%1"=="--persist" (
|
||||||
|
echo Setze Umgebungsvariablen dauerhaft in der Registry ^(setx^)...
|
||||||
|
setx IWZ "%IWZ%" >nul
|
||||||
|
setx IWZ_BIN "%IWZ_BIN%" >nul
|
||||||
|
setx IWZ_LIB "%IWZ_LIB%" >nul
|
||||||
|
setx IWZ_ENV "%IWZ_ENV%" >nul
|
||||||
|
echo Dauerhaft gesetzt. Gilt fuer alle neuen Prozesse.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Umgebungsvariablen anzeigen
|
||||||
|
:show
|
||||||
|
echo.
|
||||||
|
echo ================================================================
|
||||||
|
echo IWZ ENVIRONMENT SETUP COMPLETE
|
||||||
|
echo ================================================================
|
||||||
|
echo IWZ = %IWZ%
|
||||||
|
echo IWZ_BIN = %IWZ_BIN%
|
||||||
|
echo IWZ_LIB = %IWZ_LIB%
|
||||||
|
echo IWZ_DOC = %IWZ_DOC%
|
||||||
|
echo IWZ_CONTENT = %IWZ_CONTENT%
|
||||||
|
echo IWZ_LAYOUTS = %IWZ_LAYOUTS%
|
||||||
|
echo IWZ_STATIC = %IWZ_STATIC%
|
||||||
|
echo IWZ_PUBLIC = %IWZ_PUBLIC%
|
||||||
|
echo IWZ_LOGS = %IWZ_LOGS%
|
||||||
|
echo IWZ_RESULTS = %IWZ_RESULTS%
|
||||||
|
echo IWZ_ENV = %IWZ_ENV%
|
||||||
|
echo PYTHONPATH = %PYTHONPATH%
|
||||||
|
echo ================================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Optionally keep window open
|
||||||
|
if "%1"=="--keep-open" pause
|
||||||
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 691 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: "Blog & Veranstaltungen"
|
||||||
|
description: "Aktuelle Veranstaltungen, Tipps und Neuigkeiten aus Kaufbeuren und dem Allgäu"
|
||||||
|
---
|
||||||
|
|
||||||
|
Hier finden Sie aktuelle Veranstaltungstipps und Neuigkeiten aus Kaufbeuren und
|
||||||
|
der Region – ideal, um Ihren Aufenthalt bei uns zu planen.
|
||||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
title: "Veranstaltungshighlights in Kaufbeuren – Herbst 2026"
|
||||||
|
date: 2026-08-28
|
||||||
|
# Einblenddatum: 2 Monate vor dem ersten Event (11.09.2026) -> ab hier sichtbar
|
||||||
|
publishDate: 2026-07-11
|
||||||
|
# Ablaufdatum: 1 Monat nach dem ersten Event -> danach ausgeblendet
|
||||||
|
expiryDate: 2026-10-11
|
||||||
|
description: "Von Filmsoiree über Genusstage bis zum Jazzkonzert – die schönsten Veranstaltungen rund um Kaufbeuren im Herbst 2026."
|
||||||
|
bild: "filmsoiree-kino.jpg"
|
||||||
|
bild_alt: "Kinosaal mit roten Sesseln"
|
||||||
|
bild_quelle: 'Foto: <a href="https://unsplash.com/@krields" target="_blank" rel="noopener">Krists Luhaers</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>'
|
||||||
|
---
|
||||||
|
|
||||||
|
Der Herbst hält in Kaufbeuren und im Ostallgäu ein buntes Programm bereit. Ob
|
||||||
|
Kultur, Kulinarik oder ein stimmungsvoller Bummel durch die Altstadt – hier ist
|
||||||
|
für jeden etwas dabei. Während Ihres Aufenthalts bei uns in Kaufbeuren-Hirschzell
|
||||||
|
erreichen Sie alle Veranstaltungsorte bequem in wenigen Minuten.
|
||||||
|
|
||||||
|
## Filmsoiree: „Zwischen zwei Heimaten"
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="filmsoiree-kino.jpg"
|
||||||
|
alt="Kinosaal mit roten Sesseln"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@krields" target="_blank" rel="noopener">Krists Luhaers</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**11. September 2026 · Gablonzer Haus (Großer Saal), Kaufbeuren-Neugablonz**
|
||||||
|
|
||||||
|
Anlässlich von „80 Jahre Neugablonz – 80 Jahre Vertreibung" zeigt das
|
||||||
|
Isergebirgs-Museum in Zusammenarbeit mit dem Kulturreferat eine besondere
|
||||||
|
Filmsoiree. Der Eintritt ist frei (Zählkarte vorab erforderlich).
|
||||||
|
|
||||||
|
## Geführte Pilgerwanderung: Etappe Mindelheim nach Kaufbeuren
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="pilgerwanderung-natur.jpg"
|
||||||
|
alt="Wanderweg durch grüne Voralpenlandschaft"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@fab_fotografie" target="_blank" rel="noopener">Fabian Kühne</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**19. September 2026 · Start in Mindelheim, Ziel Crescentiakloster Kaufbeuren**
|
||||||
|
|
||||||
|
Eine begleitete Pilgerwanderung auf der rund 28 Kilometer langen 4. Etappe des
|
||||||
|
Crescentia-Pilgerwegs mit Impulsen und geistlicher Begleitung durch
|
||||||
|
Pilgerwegbegleiter.
|
||||||
|
|
||||||
|
## Allgäuer Genusstage 2026
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="genusstage-kulinarik.jpg"
|
||||||
|
alt="Regionale Spezialitäten auf einem Tisch"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@brookelark" target="_blank" rel="noopener">Brooke Lark</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**19. September – 04. Oktober 2026 · Kaufbeuren, Ostallgäu & Region**
|
||||||
|
|
||||||
|
Kulinarische Aktionswochen im Landkreis Ostallgäu und der Stadt Kaufbeuren.
|
||||||
|
Regionale Erzeuger, Gastronomen und Direktvermarkter präsentieren Spezialitäten
|
||||||
|
aus der Heimat.
|
||||||
|
|
||||||
|
## Jubiläumskonzert: 60 Jahre Kulturring Kaufbeuren
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="konzert-orchester.jpg"
|
||||||
|
alt="Orchester bei einem Konzert"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@manuelsardo" target="_blank" rel="noopener">Manuel Nägeli</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**26. September 2026 · Stadtsaal Kaufbeuren**
|
||||||
|
|
||||||
|
Der Kulturring Kaufbeuren feiert sein 60-jähriges Bestehen mit einer sinfonischen
|
||||||
|
Märchenstunde, gespielt vom renommierten ODEON Jugendsymphonieorchester München.
|
||||||
|
|
||||||
|
## Theater & Performance: „Poseidon und die Plastiksee"
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="theater-buehne.jpg"
|
||||||
|
alt="Theaterbühne mit Vorhang und Beleuchtung"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@barnaba" target="_blank" rel="noopener">Barna Bartis</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**07. Oktober 2026 · Stadttheater Kaufbeuren**
|
||||||
|
|
||||||
|
Ein Gastspiel der Saison des Kulturrings Kaufbeuren e.V., das sich auf
|
||||||
|
künstlerische Weise mit Umwelt- und Meeres-Themen im historischen Rahmen des
|
||||||
|
Stadttheaters auseinandersetzt.
|
||||||
|
|
||||||
|
## Jazzkonzert: Yakou Tribe – „Out of Sight"
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="jazz-konzert.jpg"
|
||||||
|
alt="Musiker bei einem Jazzkonzert"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@mariusmasalar" target="_blank" rel="noopener">Marius Masalar</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**30. Oktober 2026 · Stadttheater Kaufbeuren**
|
||||||
|
|
||||||
|
Das renommierte Berliner Jazz-Ensemble Yakou Tribe gastiert im Stadttheater
|
||||||
|
Kaufbeuren und präsentiert modernen, mitreißenden Instrumental-Jazz.
|
||||||
|
|
||||||
|
## Altstadtfunkeln (Candle Light Shopping & Kaufbeuren Leuchtet)
|
||||||
|
|
||||||
|
{{< bild
|
||||||
|
src="altstadtfunkeln-nacht.jpg"
|
||||||
|
alt="Illuminierte Altstadtgasse bei Nacht"
|
||||||
|
quelle=`Foto: <a href="https://unsplash.com/@agk42" target="_blank" rel="noopener">Alex Knight</a> auf <a href="https://unsplash.com" target="_blank" rel="noopener">Unsplash</a>` >}}
|
||||||
|
|
||||||
|
**13. und 14. November 2026 · Altstadt Kaufbeuren**
|
||||||
|
|
||||||
|
Die Zusammenführung von „Candle Light Shopping" und „Kaufbeuren leuchtet".
|
||||||
|
Besucher erwarten stimmungsvolle Videomappings an historischen Fassaden,
|
||||||
|
illuminierte Gassen und verlängerte Einkaufszeiten im lokalen Einzelhandel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Die verwendeten Fotos sind Symbolbilder (Unsplash-Lizenz, kostenlos auch für
|
||||||
|
kommerzielle Nutzung). Alle Veranstaltungsangaben ohne Gewähr – bitte informieren
|
||||||
|
Sie sich vor Ihrem Besuch bei den jeweiligen Veranstaltern über eventuelle
|
||||||
|
Änderungen.*
|
||||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -1,32 +1,38 @@
|
|||||||
---
|
---
|
||||||
title: "Unsere Ferienwohnungen"
|
title: "Unsere Ferienwohnungen"
|
||||||
description: "Drei komfortable Ferienwohnungen in Kaufbeuren-Hirschzell"
|
description: "Drei komfortable Ferienwohnungen in Kaufbeuren-Hirschzell"
|
||||||
allgemein:
|
gemeinsame_ausstattung:
|
||||||
- titel: "Sicherheit"
|
- titel: "Kochen und Essen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Rauchmelder"
|
- "Backofen"
|
||||||
- "Verbandskasten"
|
- "Dunstabzugshaube"
|
||||||
- titel: "Eigenschaften"
|
- "Elektroherd"
|
||||||
|
- "Esstisch/Essecke"
|
||||||
|
- "Geschirr"
|
||||||
|
- "Geschirrhandtücher"
|
||||||
|
- "Kühlschrank"
|
||||||
|
- "Kaffeemaschine Filter"
|
||||||
|
- "Mikrowelle"
|
||||||
|
- "Mixer"
|
||||||
|
- "Toaster"
|
||||||
|
- "Wasserkocher"
|
||||||
|
- titel: "Haushaltshilfen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Seniorengerecht"
|
- "Bügelbrett"
|
||||||
- "Nichtraucher"
|
- "Bügeleisen"
|
||||||
- "Allergikergeeignet"
|
- "Putzmittel"
|
||||||
- "bedingt barrierefrei"
|
- "Staubsauger"
|
||||||
- "Haustiere nicht erlaubt"
|
- "Wäscheständer"
|
||||||
- titel: "Für die Kleinen"
|
- "Wäschetrockner"
|
||||||
|
- "Wäschewaschen möglich"
|
||||||
|
- titel: "Wohnen und Leben"
|
||||||
punkte:
|
punkte:
|
||||||
- "Kinderhochstuhl"
|
- "Flachbildfernseher"
|
||||||
- "Wickelkommode"
|
- "Fliegenschutzgitter"
|
||||||
- "Wickelauflage"
|
- "Heizung/Zentralheizung"
|
||||||
- "Babybett (Reisebett)"
|
- "Internet WLAN"
|
||||||
- "Kindertritt"
|
- "Kabel-TV"
|
||||||
- "Baby-Badewanne"
|
- "Radio"
|
||||||
umfeld:
|
|
||||||
- titel: "Im Grünen"
|
|
||||||
punkte:
|
|
||||||
- "Terrasse"
|
|
||||||
- "Gartenmöbel"
|
|
||||||
- "Sonnenschirm"
|
|
||||||
- titel: "Entfernungen"
|
- titel: "Entfernungen"
|
||||||
punkte:
|
punkte:
|
||||||
- "PKW-Stellplatz: 10 m"
|
- "PKW-Stellplatz: 10 m"
|
||||||
@@ -41,35 +47,43 @@ umfeld:
|
|||||||
- "Apotheke: 3,0 km"
|
- "Apotheke: 3,0 km"
|
||||||
- "Autobahn: 20 km"
|
- "Autobahn: 20 km"
|
||||||
- "Flughafen: 45 km"
|
- "Flughafen: 45 km"
|
||||||
- titel: "Spiel, Sport, Spaß"
|
- titel: "Eigenschaften"
|
||||||
punkte:
|
punkte:
|
||||||
- "Tischtennis"
|
- "Allergikergeeignet"
|
||||||
- "Spielplatz"
|
- "bedingt barrierefrei"
|
||||||
gemeinsam:
|
- "Haustiere nicht erlaubt"
|
||||||
- titel: "Wohnen und Leben"
|
- "Nichtraucher"
|
||||||
|
- "Seniorengerecht"
|
||||||
|
- titel: "Für die Kleinen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Heizung/Zentralheizung"
|
- "Baby-Badewanne"
|
||||||
- "Fliegenschutzgitter"
|
- "Babybett (Reisebett)"
|
||||||
- "Flachbildfernseher"
|
- "Kinderhochstuhl"
|
||||||
- "Kabel-TV"
|
- "Kindertritt"
|
||||||
- "Radio"
|
|
||||||
- "Internet WLAN"
|
|
||||||
- titel: "Bad und Sanitär"
|
- titel: "Bad und Sanitär"
|
||||||
punkte:
|
punkte:
|
||||||
- "Dusche"
|
- "Dusche"
|
||||||
- "Waschbecken"
|
|
||||||
- "Spiegel"
|
|
||||||
- "Haartrockner"
|
- "Haartrockner"
|
||||||
|
- "Spiegel"
|
||||||
|
- "Waschbecken"
|
||||||
- "WC"
|
- "WC"
|
||||||
- titel: "Haushaltshilfen"
|
- titel: "Spiel, Sport, Spaß"
|
||||||
punkte:
|
punkte:
|
||||||
- "Bügelbrett"
|
- "Spielplatz"
|
||||||
- "Bügeleisen"
|
- "Tischtennis"
|
||||||
- "Staubsauger"
|
- titel: "Im Grünen"
|
||||||
- "Wäschewaschen möglich"
|
punkte:
|
||||||
- "Wäscheständer"
|
- "Gartenmöbel"
|
||||||
- "Wäschetrockner"
|
- "Sonnenschirm"
|
||||||
- "Putzmittel"
|
- "Terrasse"
|
||||||
|
- titel: "Sicherheit"
|
||||||
|
punkte:
|
||||||
|
- "Rauchmelder"
|
||||||
|
- "Verbandskasten"
|
||||||
|
- titel: "Fuhrpark"
|
||||||
|
punkte:
|
||||||
|
- "PKW-Stellplatz"
|
||||||
---
|
---
|
||||||
|
|
||||||
Alle drei Wohnungen sind vollständig ausgestattet, allergikerfreundlich und familientauglich.
|
Alle drei Wohnungen sind vollständig ausgestattet, allergikerfreundlich und familientauglich.
|
||||||
|
|||||||
@@ -16,20 +16,11 @@ ausstattung:
|
|||||||
- "1 Einzelbett (90×200 cm)"
|
- "1 Einzelbett (90×200 cm)"
|
||||||
- titel: "Kochen und Essen"
|
- titel: "Kochen und Essen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Elektroherd"
|
|
||||||
- "Backofen"
|
|
||||||
- "Dunstabzugshaube"
|
|
||||||
- "Geschirrspüler"
|
- "Geschirrspüler"
|
||||||
- "Kühlschrank"
|
|
||||||
- "Gefriertruhe"
|
- "Gefriertruhe"
|
||||||
- "Kaffeemaschine (Filter)"
|
- titel: "Für die Kleinen"
|
||||||
- "Wasserkocher"
|
punkte:
|
||||||
- "Toaster"
|
- "Wickelkommode"
|
||||||
- "Mixer"
|
|
||||||
- "Mikrowelle"
|
|
||||||
- "Esstisch/Essecke"
|
|
||||||
- "Geschirr"
|
|
||||||
- "Geschirrhandtücher"
|
|
||||||
bilder:
|
bilder:
|
||||||
- bild: "/images/fewo1/ferienwohnung-1-wolframm-kaufbeuren-Wohnzimmer.jpg"
|
- bild: "/images/fewo1/ferienwohnung-1-wolframm-kaufbeuren-Wohnzimmer.jpg"
|
||||||
titel: "Ferienwohnung Kaufbeuren Wohnzimmer FeWo 1"
|
titel: "Ferienwohnung Kaufbeuren Wohnzimmer FeWo 1"
|
||||||
|
|||||||
@@ -14,19 +14,10 @@ ausstattung:
|
|||||||
- "1 Zustellbett (80×190 cm)"
|
- "1 Zustellbett (80×190 cm)"
|
||||||
- titel: "Kochen und Essen"
|
- titel: "Kochen und Essen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Elektroherd"
|
|
||||||
- "Backofen"
|
|
||||||
- "Dunstabzugshaube"
|
|
||||||
- "Mikrowelle"
|
|
||||||
- "Kühlschrank"
|
|
||||||
- "Gefrierfach"
|
- "Gefrierfach"
|
||||||
- "Kaffeemaschine (Filter)"
|
- titel: "Für die Kleinen"
|
||||||
- "Wasserkocher"
|
punkte:
|
||||||
- "Toaster"
|
- "Wickelunterlage"
|
||||||
- "Mixer"
|
|
||||||
- "Esstisch/Essecke"
|
|
||||||
- "Geschirr"
|
|
||||||
- "Geschirrhandtücher"
|
|
||||||
bilder:
|
bilder:
|
||||||
- bild: "/images/fewo2/Wohn-esszimmer-Front-fewo-2.jpg"
|
- bild: "/images/fewo2/Wohn-esszimmer-Front-fewo-2.jpg"
|
||||||
titel: "Ferienwohnung Kaufbeuren Wohn-Esszimmer FeWo 2"
|
titel: "Ferienwohnung Kaufbeuren Wohn-Esszimmer FeWo 2"
|
||||||
|
|||||||
@@ -15,22 +15,10 @@ ausstattung:
|
|||||||
- "1 Zustellbett (80×190 cm)"
|
- "1 Zustellbett (80×190 cm)"
|
||||||
- titel: "Kochen und Essen"
|
- titel: "Kochen und Essen"
|
||||||
punkte:
|
punkte:
|
||||||
- "Elektroherd"
|
|
||||||
- "Backofen"
|
|
||||||
- "Dunstabzugshaube"
|
|
||||||
- "Kühlschrank"
|
|
||||||
- "Gefrierfach"
|
- "Gefrierfach"
|
||||||
- "Kaffeemaschine (Filter)"
|
- titel: "Für die Kleinen"
|
||||||
- "Wasserkocher"
|
|
||||||
- "Toaster"
|
|
||||||
- "Mixer"
|
|
||||||
- "Mikrowelle"
|
|
||||||
- "Esstisch/Essecke"
|
|
||||||
- "Geschirr"
|
|
||||||
- "Geschirrhandtücher"
|
|
||||||
- titel: "Besonderheit"
|
|
||||||
punkte:
|
punkte:
|
||||||
- "Kinderfreundlich"
|
- "Wickelkommode"
|
||||||
bilder:
|
bilder:
|
||||||
- bild: "/images/fewo3/ferienwohnung-allgaeu-kaufbeuren-wohnung-3-Kueche-Sitzecke-fewo.jpg"
|
- bild: "/images/fewo3/ferienwohnung-allgaeu-kaufbeuren-wohnung-3-Kueche-Sitzecke-fewo.jpg"
|
||||||
titel: "Ferienwohnung Kaufbeuren FeWo 3 – Sitzecke in der Küche"
|
titel: "Ferienwohnung Kaufbeuren FeWo 3 – Sitzecke in der Küche"
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo.
|
|
||||||
echo === IWZ Ferienwohnungen - Deploy (nur Änderungen) ===
|
|
||||||
echo.
|
|
||||||
python deploy_lastdiff.py
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
pause
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo.
|
|
||||||
echo === IWZ Ferienwohnungen - Deploy ===
|
|
||||||
echo.
|
|
||||||
python deploy.py
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
pause
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Nutzungsanalyse der Website (Server-Logs + GoAccess)
|
||||||
|
|
||||||
|
Die neue Hugo-Seite verzichtet bewusst auf JavaScript-Tracking (kein Google
|
||||||
|
Analytics, keine Cookies, kein Consent-Banner nötig). Stattdessen werten wir
|
||||||
|
die **Apache-Access-Logs** aus, die IONOS ohnehin auf dem Server schreibt.
|
||||||
|
|
||||||
|
Ablauf des Skripts `analyze.py`:
|
||||||
|
|
||||||
|
1. Verbindet sich per **SFTP** mit IONOS und lädt alle `access.log*`-Dateien
|
||||||
|
aus dem `/logs`-Ordner nach `./logs/` herunter.
|
||||||
|
2. Entpackt die rotierten `.gz`-Logs und fügt alles zu einer Datei zusammen.
|
||||||
|
3. Lässt **GoAccess** darüberlaufen und erzeugt einen HTML-Report unter
|
||||||
|
`./results/report_JJJJ-MM-TT.html`, der automatisch im Browser geöffnet wird.
|
||||||
|
|
||||||
|
`logs/` und `results/` stehen in `.gitignore` und werden nicht eingecheckt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einmalige Einrichtung
|
||||||
|
|
||||||
|
### 1. Python-Abhängigkeit (paramiko)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GoAccess (über WSL – empfohlen)
|
||||||
|
|
||||||
|
GoAccess wird offiziell nur für Linux/macOS gebaut und ist in keinem
|
||||||
|
Windows-Paketmanager verfügbar. Unter Windows ist der zuverlässigste Weg die
|
||||||
|
**WSL** (Windows Subsystem for Linux). `bin\install_py.bat` prüft automatisch,
|
||||||
|
ob WSL und GoAccess vorhanden sind, und zeigt sonst die passenden Befehle.
|
||||||
|
|
||||||
|
**WSL installieren** (falls noch nicht vorhanden, PowerShell als Administrator):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --install
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach Windows neu starten und die Ubuntu-Ersteinrichtung (Benutzer/Passwort)
|
||||||
|
durchlaufen.
|
||||||
|
|
||||||
|
**GoAccess in der WSL installieren:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -e sudo apt update
|
||||||
|
wsl -e sudo apt install -y goaccess
|
||||||
|
```
|
||||||
|
|
||||||
|
In der `.env` steht dafür (Standard):
|
||||||
|
|
||||||
|
```text
|
||||||
|
GOACCESS_MODE=wsl
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript ruft GoAccess dann per `wsl goaccess ...` auf und übersetzt die
|
||||||
|
Windows-Pfade automatisch nach `/mnt/c/...`.
|
||||||
|
|
||||||
|
> **Alternative – nativer Windows-Build:** Falls du kein WSL nutzen willst,
|
||||||
|
> lade den Cygwin-Build von <https://goaccess.io/download>, entpacke ihn (z. B.
|
||||||
|
> nach `C:\tools\goaccess\`) und setze in der `.env`:
|
||||||
|
>
|
||||||
|
> ```text
|
||||||
|
> GOACCESS_MODE=exe
|
||||||
|
> GOACCESS_EXE=C:\tools\goaccess\goaccess.exe
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 3. IONOS-SFTP-Zugang in `.env` eintragen
|
||||||
|
|
||||||
|
Die Zugangsdaten stehen im IONOS-Kundenbereich unter **Hosting → SFTP/SSH**.
|
||||||
|
Es sind dieselben, mit denen der `/logs`-Ordner erreichbar ist.
|
||||||
|
|
||||||
|
```text
|
||||||
|
IONOS_SFTP_HOST=<z.B. home123456789.1and1-data.host oder access-5000000000.webspace-host.com>
|
||||||
|
IONOS_SFTP_PORT=22
|
||||||
|
IONOS_SFTP_USER=<SFTP-Benutzer>
|
||||||
|
IONOS_SFTP_PASSWORD=<SFTP-Passwort>
|
||||||
|
IONOS_LOGS_DIR=/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
> Die `.env` steht in `.gitignore` und wird **nicht** eingecheckt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Logs neu laden, Report bauen und im Browser öffnen:
|
||||||
|
analyze.bat
|
||||||
|
# oder:
|
||||||
|
python analyze.py
|
||||||
|
|
||||||
|
# Nur neu auswerten (ohne erneuten Download der schon geladenen Logs):
|
||||||
|
python analyze.py --no-download
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim wiederholten Lauf werden bereits geladene, rotierte Logs übersprungen
|
||||||
|
(gleiche Dateigröße) – nur das aktuell wachsende Log wird neu geholt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was im Report steht
|
||||||
|
|
||||||
|
GoAccess zeigt u. a.:
|
||||||
|
|
||||||
|
- **Eindeutige Besucher** und Seitenaufrufe pro Tag
|
||||||
|
- **Meistbesuchte Seiten** (welche Ferienwohnung interessiert am meisten?)
|
||||||
|
- **Referrer** (woher kommen die Besucher – Google, direkte Eingabe, Links)
|
||||||
|
- **Länder / Sprachen**, Browser, Betriebssysteme, Geräte
|
||||||
|
- **HTTP-Statuscodes** (404-Fehler = tote Links finden)
|
||||||
|
- **Traffic-Volumen**
|
||||||
|
|
||||||
|
Aufrufe von Suchmaschinen-Bots werden herausgefiltert (`--ignore-crawlers`),
|
||||||
|
IP-Adressen im Report anonymisiert (`--anonymize-ip`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DSGVO-Hinweis
|
||||||
|
|
||||||
|
Diese Auswertung passiert **nur lokal auf deinem Rechner** aus den ohnehin
|
||||||
|
anfallenden Server-Logs. Es wird nichts an Dritte übertragen und kein
|
||||||
|
Tracking-Code beim Besucher ausgeführt. Server-Logs mit IP-Adressen dürfen zu
|
||||||
|
Sicherheits-/Betriebszwecken vorübergehend gespeichert werden; im GoAccess-
|
||||||
|
Report sind die IPs zusätzlich anonymisiert. Ein Cookie-Consent-Banner ist für
|
||||||
|
diese Art der Auswertung **nicht** erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
**Report bleibt leer / „No data".**
|
||||||
|
Dann passt das Log-Format nicht. IONOS nutzt meist das *Combined Log Format*
|
||||||
|
(`LOG_FORMAT=COMBINED`). Wirf einen Blick in die erste Zeile von
|
||||||
|
`logs/access.log.current`:
|
||||||
|
|
||||||
|
- Beginnt sie mit einer IP und enthält am Ende `"...Referrer" "...User-Agent"`
|
||||||
|
→ `COMBINED` ist korrekt.
|
||||||
|
- Fehlen Referrer/User-Agent → `LOG_FORMAT=COMMON`.
|
||||||
|
- Steht der Vhost-Name vor der IP → `LOG_FORMAT=VCOMBINED`.
|
||||||
|
|
||||||
|
Alternativ ein eigenes Format als GoAccess-`--log-format`-String in `.env`
|
||||||
|
hinterlegen (siehe <https://goaccess.io/man#custom-log>).
|
||||||
|
|
||||||
|
**`paramiko`-Authentifizierungsfehler.**
|
||||||
|
SFTP-Host/-User/-Passwort im IONOS-Kundenbereich gegenprüfen. Der SFTP-Benutzer
|
||||||
|
ist nicht zwingend identisch mit dem Login des Kundenbereichs.
|
||||||
@@ -28,7 +28,11 @@ defaultContentLanguage = "de"
|
|||||||
name = "Ausflugsziele"
|
name = "Ausflugsziele"
|
||||||
url = "/ausflugsziele/"
|
url = "/ausflugsziele/"
|
||||||
weight = 4
|
weight = 4
|
||||||
|
[[menu.main]]
|
||||||
|
name = "Blog"
|
||||||
|
url = "/blog/"
|
||||||
|
weight = 5
|
||||||
[[menu.main]]
|
[[menu.main]]
|
||||||
name = "Kontakt"
|
name = "Kontakt"
|
||||||
url = "/kontakt/"
|
url = "/kontakt/"
|
||||||
weight = 5
|
weight = 6
|
||||||
|
|||||||
@@ -30,57 +30,19 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if or .Params.allgemein .Params.umfeld .Params.gemeinsam }}
|
{{ with .Params.gemeinsame_ausstattung }}
|
||||||
<div class="umfeld" id="umfeld">
|
<div class="umfeld" id="umfeld">
|
||||||
<p class="umfeld-intro">Diese Ausstattung und Umgebungsinfos gelten für alle drei Ferienwohnungen gleichermaßen.</p>
|
<p class="umfeld-intro">Diese Ausstattung gilt für alle drei Ferienwohnungen gleichermaßen.</p>
|
||||||
|
<div class="spec-grid">
|
||||||
{{ with .Params.allgemein }}
|
{{ range . }}
|
||||||
<section class="umfeld-gruppe">
|
<div class="spec-block">
|
||||||
<h2>Allgemein</h2>
|
<h3>{{ .titel }}</h3>
|
||||||
<div class="spec-grid">
|
<ul>
|
||||||
{{ range . }}
|
{{ range .punkte }}<li>{{ . }}</li>{{ end }}
|
||||||
<div class="spec-block">
|
</ul>
|
||||||
<h3>{{ .titel }}</h3>
|
|
||||||
<ul>
|
|
||||||
{{ range .punkte }}<li>{{ . }}</li>{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{{ end }}
|
||||||
{{ end }}
|
</div>
|
||||||
|
|
||||||
{{ with .Params.umfeld }}
|
|
||||||
<section class="umfeld-gruppe">
|
|
||||||
<h2>Umfeld</h2>
|
|
||||||
<div class="spec-grid">
|
|
||||||
{{ range . }}
|
|
||||||
<div class="spec-block">
|
|
||||||
<h3>{{ .titel }}</h3>
|
|
||||||
<ul>
|
|
||||||
{{ range .punkte }}<li>{{ . }}</li>{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ with .Params.gemeinsam }}
|
|
||||||
<section class="umfeld-gruppe">
|
|
||||||
<h2>Gemeinsame Ausstattung</h2>
|
|
||||||
<div class="spec-grid">
|
|
||||||
{{ range . }}
|
|
||||||
<div class="spec-block">
|
|
||||||
<h3>{{ .titel }}</h3>
|
|
||||||
<ul>
|
|
||||||
{{ range .punkte }}<li>{{ . }}</li>{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
<article class="page">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ .Content }}
|
||||||
|
|
||||||
|
<div class="fewo-grid blog-grid">
|
||||||
|
{{ range .Pages.ByPublishDate.Reverse }}
|
||||||
|
<div class="fewo-card">
|
||||||
|
{{ $post := . }}
|
||||||
|
{{ with .Params.bild }}
|
||||||
|
{{ $src := . }}
|
||||||
|
{{ with $post.Resources.GetMatch $src }}{{ $src = .RelPermalink }}{{ end }}
|
||||||
|
<a href="{{ $post.RelPermalink }}" class="fewo-card-imglink">
|
||||||
|
<img src="{{ $src }}" alt="{{ with $post.Params.bild_alt }}{{ . }}{{ else }}{{ $post.Title }}{{ end }}" class="fewo-card-img" loading="lazy">
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
<div class="fewo-card-body">
|
||||||
|
<p class="blog-date">{{ .PublishDate.Format "02.01.2006" }}</p>
|
||||||
|
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
|
||||||
|
{{ with .Params.description }}<p>{{ . }}</p>{{ end }}
|
||||||
|
<a href="{{ .RelPermalink }}" class="btn btn-primary">Weiterlesen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if not .Pages }}
|
||||||
|
<p>Aktuell sind keine Beiträge veröffentlicht. Schauen Sie bald wieder vorbei!</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
<article class="page blog-post">
|
||||||
|
<div class="container container--narrow">
|
||||||
|
<p class="blog-date">{{ .PublishDate.Format "02.01.2006" }}</p>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ with .Params.bild }}
|
||||||
|
{{ $src := . }}
|
||||||
|
{{ with $.Resources.GetMatch $src }}{{ $src = .RelPermalink }}{{ end }}
|
||||||
|
<figure class="blog-hero">
|
||||||
|
<img src="{{ $src }}" alt="{{ with $.Params.bild_alt }}{{ . }}{{ else }}{{ $.Title }}{{ end }}" class="blog-hero-img">
|
||||||
|
{{ with $.Params.bild_quelle }}<figcaption class="bild-quelle">Bildquelle: {{ . | safeHTML }}</figcaption>{{ end }}
|
||||||
|
</figure>
|
||||||
|
{{ end }}
|
||||||
|
{{ .Content }}
|
||||||
|
<p class="blog-back"><a href="/blog/" class="btn btn-outline">← Zurück zur Übersicht</a></p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
@@ -41,9 +41,26 @@
|
|||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ $gemeinsam := (.Site.GetPage "/ferienwohnungen").Params.gemeinsame_ausstattung }}
|
||||||
{{ with .Params.ausstattung }}
|
{{ with .Params.ausstattung }}
|
||||||
<section class="ausstattung">
|
<section class="ausstattung">
|
||||||
<h2>Ausstattung</h2>
|
<h2>Ausstattung spezifisch</h2>
|
||||||
|
<div class="spec-grid">
|
||||||
|
{{ range . }}
|
||||||
|
<div class="spec-block">
|
||||||
|
<h3>{{ .titel }}</h3>
|
||||||
|
<ul>
|
||||||
|
{{ range .punkte }}<li>{{ . }}</li>{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with $gemeinsam }}
|
||||||
|
<section class="ausstattung">
|
||||||
|
<h2>Ausstattung allgemein</h2>
|
||||||
<div class="spec-grid">
|
<div class="spec-grid">
|
||||||
{{ range . }}
|
{{ range . }}
|
||||||
<div class="spec-block">
|
<div class="spec-block">
|
||||||
@@ -54,7 +71,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<p class="ausstattung-hinweis">Allgemeine Ausstattung (Sicherheit, Eigenschaften, für die Kleinen), Umfeld (Garten, Freizeit, Entfernungen) und weitere gemeinsame Ausstattung finden Sie unter <a href="/ferienwohnungen/#umfeld">Allgemein, Umfeld & gemeinsame Ausstattung</a> aller Ferienwohnungen.</p>
|
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="belegungsplan-band">
|
||||||
|
<div class="container">
|
||||||
|
<button type="button" class="btn belegungsplan-band-btn" id="belegungsplan-btn" aria-expanded="false" aria-controls="belegungsplan-frame">
|
||||||
|
Belegungsplan – hier freie Kapazitäten einsehen
|
||||||
|
</button>
|
||||||
|
<div class="belegungsplan-frame" id="belegungsplan-frame" data-src="https://www.webplanner.de/tools/?76703072363837353064343069" hidden></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{/* Highlights-Block vorerst ausgeblendet – siehe Wunsch, dass "Unsere Wohnungen" zuerst steht.
|
{{/* Highlights-Block vorerst ausgeblendet – siehe Wunsch, dass "Unsere Wohnungen" zuerst steht.
|
||||||
<section class="highlights">
|
<section class="highlights">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -70,7 +79,12 @@
|
|||||||
{{ with $page.Params.personen }}<span>bis {{ . }} Personen</span>{{ end }}
|
{{ with $page.Params.personen }}<span>bis {{ . }} Personen</span>{{ end }}
|
||||||
{{ with $page.Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }}
|
{{ with $page.Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }}
|
||||||
</p>
|
</p>
|
||||||
<a href="{{ $page.RelPermalink }}" class="btn btn-primary">Details & Buchung</a>
|
<div class="fewo-card-actions">
|
||||||
|
<a href="{{ $page.RelPermalink }}" class="btn btn-outline">Details</a>
|
||||||
|
{{ with $page.Params.belegungsplan }}
|
||||||
|
<a href="{{ . }}" class="btn btn-primary" target="_blank" rel="noopener">Buchung</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{- $src := .Get "src" -}}
|
||||||
|
{{- $alt := .Get "alt" -}}
|
||||||
|
{{- $quelle := .Get "quelle" -}}
|
||||||
|
{{- /* Bild als Page-Resource aus dem Bundle auflösen; sonst src unverändert nutzen */ -}}
|
||||||
|
{{- with .Page.Resources.GetMatch $src }}{{ $src = .RelPermalink }}{{ end -}}
|
||||||
|
<figure class="blog-figure">
|
||||||
|
<img src="{{ $src }}" alt="{{ $alt }}" loading="lazy">
|
||||||
|
{{- with $quelle }}
|
||||||
|
<figcaption class="bild-quelle">Bildquelle: {{ . | safeHTML }}</figcaption>
|
||||||
|
{{- end }}
|
||||||
|
</figure>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IWZ Ferienwohnungen - Nutzungsanalyse
|
||||||
|
|
||||||
|
Laedt die Apache-Access-Logs per SFTP von IONOS herunter, entpackt die
|
||||||
|
rotierten .gz-Dateien und erzeugt mit GoAccess einen HTML-Report.
|
||||||
|
|
||||||
|
Kein JavaScript-Tracking, keine Cookies beim Besucher -> DSGVO-freundlich.
|
||||||
|
Die Auswertung passiert rein aus den Server-Logs.
|
||||||
|
|
||||||
|
Voraussetzungen:
|
||||||
|
- pip install -r requirements.txt (paramiko)
|
||||||
|
- GoAccess Windows-Build (Pfad in .env: GOACCESS_EXE)
|
||||||
|
- .env mit IONOS_SFTP_* befuellt (siehe .env.example)
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python analyze.py # Logs laden + Report bauen + oeffnen
|
||||||
|
python analyze.py --no-download # nur neu auswerten (schon geladene Logs)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
except ImportError:
|
||||||
|
print("FEHLER: paramiko fehlt. Bitte 'pip install -r requirements.txt' ausfuehren.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
|
||||||
|
LOGS_DIR = PROJECT_ROOT / "logs" # heruntergeladene Rohlogs (in .gitignore)
|
||||||
|
RESULTS_DIR = PROJECT_ROOT / "results" # erzeugte Reports (in .gitignore)
|
||||||
|
SFTP_PORT = 22
|
||||||
|
|
||||||
|
|
||||||
|
def env_file():
|
||||||
|
"""Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env."""
|
||||||
|
from_var = os.environ.get("IWZ_ENV")
|
||||||
|
if from_var:
|
||||||
|
return Path(from_var)
|
||||||
|
return PROJECT_ROOT / "bin" / ".env"
|
||||||
|
|
||||||
|
# Nur diese Dateien aus dem IONOS /logs-Ordner interessieren uns.
|
||||||
|
# Alles andere (sftp.log, traffic.db, forensic/, info.*) wird ignoriert.
|
||||||
|
ACCESS_LOG_PREFIX = "access.log"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
env = {}
|
||||||
|
if not path.exists():
|
||||||
|
return env
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip()
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def download_access_logs(host, port, user, password, remote_dir):
|
||||||
|
"""Laedt alle access.log*-Dateien aus dem IONOS /logs-Ordner nach LOGS_DIR."""
|
||||||
|
print(f"\n-> Verbinde per SFTP mit {host}:{port} ...")
|
||||||
|
transport = paramiko.Transport((host, port))
|
||||||
|
try:
|
||||||
|
transport.connect(username=user, password=password)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.chdir(remote_dir)
|
||||||
|
|
||||||
|
LOGS_DIR.mkdir(exist_ok=True)
|
||||||
|
count = 0
|
||||||
|
for attr in sftp.listdir_attr("."):
|
||||||
|
name = attr.filename
|
||||||
|
if not name.startswith(ACCESS_LOG_PREFIX):
|
||||||
|
continue
|
||||||
|
if stat.S_ISDIR(attr.st_mode):
|
||||||
|
continue
|
||||||
|
local_path = LOGS_DIR / name
|
||||||
|
# Schon vorhanden und gleich gross? -> ueberspringen (rotierte Logs
|
||||||
|
# aendern sich nicht mehr; nur .current / aktueller Tag waechst).
|
||||||
|
if (local_path.exists()
|
||||||
|
and local_path.stat().st_size == attr.st_size
|
||||||
|
and not name.endswith(".current")):
|
||||||
|
continue
|
||||||
|
print(f" lade {name} ({attr.st_size} Bytes)")
|
||||||
|
sftp.get(name, str(local_path))
|
||||||
|
count += 1
|
||||||
|
sftp.close()
|
||||||
|
print(f" {count} Datei(en) geladen/aktualisiert.")
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_log_lines():
|
||||||
|
"""Fuehrt alle access.log*-Dateien (auch .gz) zu einem kombinierten Log zusammen.
|
||||||
|
|
||||||
|
Gibt den Pfad zur kombinierten Klartext-Logdatei zurueck.
|
||||||
|
"""
|
||||||
|
combined = LOGS_DIR / "_combined.log"
|
||||||
|
sources = sorted(
|
||||||
|
p for p in LOGS_DIR.iterdir()
|
||||||
|
if p.is_file()
|
||||||
|
and p.name.startswith(ACCESS_LOG_PREFIX)
|
||||||
|
and p.name != combined.name
|
||||||
|
)
|
||||||
|
if not sources:
|
||||||
|
print("\nFEHLER: keine access.log*-Dateien in ./logs/ gefunden.")
|
||||||
|
print("Erst 'python analyze.py' (ohne --no-download) ausfuehren.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n-> Fuehre {len(sources)} Logdatei(en) zusammen ...")
|
||||||
|
with open(combined, "wb") as out:
|
||||||
|
for src in sources:
|
||||||
|
if src.suffix == ".gz":
|
||||||
|
with gzip.open(src, "rb") as f:
|
||||||
|
shutil.copyfileobj(f, out)
|
||||||
|
else:
|
||||||
|
with open(src, "rb") as f:
|
||||||
|
shutil.copyfileobj(f, out)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def to_wsl_path(win_path):
|
||||||
|
"""Uebersetzt einen Windows-Pfad (C:\\...) in einen WSL-Pfad (/mnt/c/...)."""
|
||||||
|
p = Path(win_path).resolve()
|
||||||
|
drive = p.drive.rstrip(":").lower() # "C:" -> "c"
|
||||||
|
rest = p.as_posix()[len(p.drive):] # Rest ohne Laufwerk, mit /
|
||||||
|
return f"/mnt/{drive}{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
def goaccess_args(log_format):
|
||||||
|
"""Gemeinsame GoAccess-Optionen (ohne Ein-/Ausgabedateien)."""
|
||||||
|
return [
|
||||||
|
"--log-format=" + log_format,
|
||||||
|
"--anonymize-ip", # DSGVO: IPs im Report anonymisieren
|
||||||
|
"--ignore-crawlers", # Bots aus den Besucherzahlen filtern
|
||||||
|
"--html-report-title=IWZ Ferienwohnungen - Zugriffsstatistik",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_goaccess(mode, goaccess_exe, log_file, report_file, log_format):
|
||||||
|
"""Ruft GoAccess auf und erzeugt einen eigenstaendigen HTML-Report.
|
||||||
|
|
||||||
|
mode="wsl": GoAccess laeuft in der WSL (Standard), Pfade werden nach
|
||||||
|
/mnt/c/... uebersetzt.
|
||||||
|
mode="exe": nativer Windows-Build unter GOACCESS_EXE.
|
||||||
|
"""
|
||||||
|
print(f"\n-> GoAccess ({mode}) -> {report_file.name}")
|
||||||
|
|
||||||
|
if mode == "wsl":
|
||||||
|
cmd = [
|
||||||
|
"wsl", "goaccess",
|
||||||
|
to_wsl_path(log_file),
|
||||||
|
"-o", to_wsl_path(report_file),
|
||||||
|
] + goaccess_args(log_format)
|
||||||
|
not_found_hint = (
|
||||||
|
"GoAccess ist in der WSL nicht installiert (oder WSL fehlt).\n"
|
||||||
|
" In der WSL installieren: wsl -e sudo apt update ^&^& "
|
||||||
|
"sudo apt install -y goaccess\n"
|
||||||
|
"Siehe auch bin/install_py.bat (prueft WSL + GoAccess) und doc/analyse.md."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
goaccess_exe,
|
||||||
|
str(log_file),
|
||||||
|
"-o", str(report_file),
|
||||||
|
] + goaccess_args(log_format)
|
||||||
|
not_found_hint = (
|
||||||
|
f"GoAccess-EXE nicht gefunden ('{goaccess_exe}').\n"
|
||||||
|
" Windows-Build laden: https://goaccess.io/download, entpacken,\n"
|
||||||
|
" dann GOACCESS_EXE in bin/.env auf die goaccess.exe setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("\nFEHLER: GoAccess konnte nicht gestartet werden.")
|
||||||
|
print(not_found_hint)
|
||||||
|
sys.exit(1)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("\nFEHLER: GoAccess fehlgeschlagen.")
|
||||||
|
print("Pruefe die GoAccess-Installation und das Log-Format (LOG_FORMAT).")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=== IWZ Nutzungsanalyse ===")
|
||||||
|
no_download = "--no-download" in sys.argv
|
||||||
|
|
||||||
|
env = {**load_env(env_file()), **os.environ}
|
||||||
|
host = env.get("IONOS_SFTP_HOST")
|
||||||
|
port = int(env.get("IONOS_SFTP_PORT", SFTP_PORT))
|
||||||
|
user = env.get("IONOS_SFTP_USER")
|
||||||
|
password = env.get("IONOS_SFTP_PASSWORD")
|
||||||
|
remote_dir = env.get("IONOS_LOGS_DIR", "/logs")
|
||||||
|
# GoAccess-Aufruf: "wsl" (Standard) oder "exe" (nativer Windows-Build).
|
||||||
|
goaccess_mode = env.get("GOACCESS_MODE", "wsl").strip().lower()
|
||||||
|
goaccess_exe = env.get("GOACCESS_EXE", "goaccess")
|
||||||
|
# IONOS liefert i.d.R. Apache "Combined Log Format". Bei anderen
|
||||||
|
# Formaten hier anpassen (siehe doc/analyse.md).
|
||||||
|
log_format = env.get("LOG_FORMAT", "COMBINED")
|
||||||
|
|
||||||
|
if not no_download:
|
||||||
|
if not host or not user or not password:
|
||||||
|
print("\nFEHLER: IONOS_SFTP_HOST / _USER / _PASSWORD fehlen in bin/.env.")
|
||||||
|
print("Siehe bin/.env.example. Oder mit --no-download nur neu auswerten.")
|
||||||
|
sys.exit(1)
|
||||||
|
download_access_logs(host, port, user, password, remote_dir)
|
||||||
|
else:
|
||||||
|
print("\n-> Download uebersprungen (--no-download).")
|
||||||
|
|
||||||
|
combined = collect_log_lines()
|
||||||
|
|
||||||
|
RESULTS_DIR.mkdir(exist_ok=True)
|
||||||
|
report_file = RESULTS_DIR / f"report_{date.today().isoformat()}.html"
|
||||||
|
run_goaccess(goaccess_mode, goaccess_exe, combined, report_file, log_format)
|
||||||
|
|
||||||
|
print(f"\nFertig! Report: {report_file}")
|
||||||
|
webbrowser.open(report_file.as_uri())
|
||||||
@@ -13,11 +13,19 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
|
||||||
PUBLIC_DIR = PROJECT_ROOT / "public"
|
PUBLIC_DIR = PROJECT_ROOT / "public"
|
||||||
FTP_PORT = 21
|
FTP_PORT = 21
|
||||||
|
|
||||||
|
|
||||||
|
def env_file():
|
||||||
|
"""Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env."""
|
||||||
|
from_var = os.environ.get("IWZ_ENV")
|
||||||
|
if from_var:
|
||||||
|
return Path(from_var)
|
||||||
|
return PROJECT_ROOT / "bin" / ".env"
|
||||||
|
|
||||||
|
|
||||||
def load_env(path):
|
def load_env(path):
|
||||||
env = {}
|
env = {}
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -99,7 +107,7 @@ def purge_cdn_cache(pull_zone_url, api_key):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("=== IWZ Deploy ===")
|
print("=== IWZ Deploy ===")
|
||||||
|
|
||||||
env = {**load_env(PROJECT_ROOT / ".env"), **os.environ}
|
env = {**load_env(env_file()), **os.environ}
|
||||||
zone = env.get("BUNNY_STORAGE_ZONE")
|
zone = env.get("BUNNY_STORAGE_ZONE")
|
||||||
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
|
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
|
||||||
password = env.get("BUNNY_STORAGE_PASSWORD")
|
password = env.get("BUNNY_STORAGE_PASSWORD")
|
||||||
@@ -108,7 +116,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
if not zone or not password:
|
if not zone or not password:
|
||||||
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
|
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
|
||||||
print("Siehe .env.example -> .env kopieren und befuellen.")
|
print("Siehe bin/.env.example -> bin/.env kopieren und befuellen.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
hugo_build()
|
hugo_build()
|
||||||
@@ -14,12 +14,20 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
|
||||||
PUBLIC_DIR = PROJECT_ROOT / "public"
|
PUBLIC_DIR = PROJECT_ROOT / "public"
|
||||||
MANIFEST_PATH = PROJECT_ROOT / ".deploy_manifest.json"
|
MANIFEST_PATH = PROJECT_ROOT / ".deploy_manifest.json"
|
||||||
FTP_PORT = 21
|
FTP_PORT = 21
|
||||||
|
|
||||||
|
|
||||||
|
def env_file():
|
||||||
|
"""Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env."""
|
||||||
|
from_var = os.environ.get("IWZ_ENV")
|
||||||
|
if from_var:
|
||||||
|
return Path(from_var)
|
||||||
|
return PROJECT_ROOT / "bin" / ".env"
|
||||||
|
|
||||||
|
|
||||||
def load_env(path):
|
def load_env(path):
|
||||||
env = {}
|
env = {}
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -124,14 +132,14 @@ def deploy_to_bunny(host, zone, password, changed, deleted):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("=== IWZ Deploy (nur Änderungen) ===")
|
print("=== IWZ Deploy (nur Änderungen) ===")
|
||||||
|
|
||||||
env = {**load_env(PROJECT_ROOT / ".env"), **os.environ}
|
env = {**load_env(env_file()), **os.environ}
|
||||||
zone = env.get("BUNNY_STORAGE_ZONE")
|
zone = env.get("BUNNY_STORAGE_ZONE")
|
||||||
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
|
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
|
||||||
password = env.get("BUNNY_STORAGE_PASSWORD")
|
password = env.get("BUNNY_STORAGE_PASSWORD")
|
||||||
|
|
||||||
if not zone or not password:
|
if not zone or not password:
|
||||||
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
|
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
|
||||||
print("Siehe .env.example -> .env kopieren und befuellen.")
|
print("Siehe bin/.env.example -> bin/.env kopieren und befuellen.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
hugo_build()
|
hugo_build()
|
||||||
@@ -6,15 +6,24 @@ fuer die per Bunny Token Authentication geschuetzte Pull Zone.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
|
||||||
GUELTIGKEIT_SEKUNDEN = 7 * 24 * 60 * 60 # 7 Tage
|
GUELTIGKEIT_SEKUNDEN = 7 * 24 * 60 * 60 # 7 Tage
|
||||||
URL_PATH = "/"
|
URL_PATH = "/"
|
||||||
|
|
||||||
|
|
||||||
|
def env_file():
|
||||||
|
"""Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env."""
|
||||||
|
from_var = os.environ.get("IWZ_ENV")
|
||||||
|
if from_var:
|
||||||
|
return Path(from_var)
|
||||||
|
return PROJECT_ROOT / "bin" / ".env"
|
||||||
|
|
||||||
|
|
||||||
def load_env(path):
|
def load_env(path):
|
||||||
env = {}
|
env = {}
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -36,12 +45,12 @@ def build_signed_url(pull_zone_url, auth_key, url_path, expires):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
env = load_env(PROJECT_ROOT / ".env")
|
env = load_env(env_file())
|
||||||
pull_zone_url = env.get("BUNNY_PULL_ZONE_URL", "").rstrip("/")
|
pull_zone_url = env.get("BUNNY_PULL_ZONE_URL", "").rstrip("/")
|
||||||
auth_key = env.get("BUNNY_AUTH_KEY")
|
auth_key = env.get("BUNNY_AUTH_KEY")
|
||||||
|
|
||||||
if not pull_zone_url or not auth_key:
|
if not pull_zone_url or not auth_key:
|
||||||
print("\nFEHLER: BUNNY_PULL_ZONE_URL / BUNNY_AUTH_KEY fehlen in .env.")
|
print("\nFEHLER: BUNNY_PULL_ZONE_URL / BUNNY_AUTH_KEY fehlen in bin/.env.")
|
||||||
print("Siehe .env.example. Auth Key: Pull Zone -> Security -> Token Authentication.")
|
print("Siehe .env.example. Auth Key: Pull Zone -> Security -> Token Authentication.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo.
|
|
||||||
echo === Ferienwohnungen Wolframm - Vorschau-Link erzeugen ===
|
|
||||||
python preview_link.py
|
|
||||||
echo.
|
|
||||||
echo Link oben markieren und mit Strg+C kopieren.
|
|
||||||
echo.
|
|
||||||
cmd /k
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Python-Abhaengigkeiten fuer die lib/-Skripte.
|
||||||
|
#
|
||||||
|
# deploy.py / deploy_lastdiff.py / preview_link.py:
|
||||||
|
# nutzen ausschliesslich die Standardbibliothek (ftplib, urllib, hashlib,
|
||||||
|
# json, subprocess ...) -> keine externen Pakete noetig.
|
||||||
|
#
|
||||||
|
# analyze.py:
|
||||||
|
# paramiko fuer den SFTP-Download der IONOS-Access-Logs.
|
||||||
|
paramiko
|
||||||
@@ -257,9 +257,34 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
.highlight-card h3 { margin-bottom: 0.5rem; font-size: 1.05rem; }
|
.highlight-card h3 { margin-bottom: 0.5rem; font-size: 1.05rem; }
|
||||||
.highlight-card p { color: var(--text-muted); font-size: 0.9rem; }
|
.highlight-card p { color: var(--text-muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ===== Belegungsplan-Band (auffälliger Verweis auf der Startseite) ===== */
|
||||||
|
.belegungsplan-band {
|
||||||
|
background: #c0392b;
|
||||||
|
padding: 1.6rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.belegungsplan-band-btn {
|
||||||
|
background: #e67e22;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.9rem 2rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.belegungsplan-band-btn:hover {
|
||||||
|
background: #d35400;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Belegungsplan (Übersicht ein-/ausblenden) ===== */
|
/* ===== Belegungsplan (Übersicht ein-/ausblenden) ===== */
|
||||||
.belegungsplan-toggle { margin: 1rem 0 2.5rem; }
|
.belegungsplan-toggle { margin: 1rem 0 2.5rem; }
|
||||||
.belegungsplan-frame { margin-top: 1.5rem; }
|
.belegungsplan-frame {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
.belegungsplan-frame[hidden] { display: none; }
|
.belegungsplan-frame[hidden] { display: none; }
|
||||||
|
|
||||||
/* ===== FeWo Grid ===== */
|
/* ===== FeWo Grid ===== */
|
||||||
@@ -281,6 +306,8 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
.fewo-card-img { width: 100%; height: 200px; object-fit: cover; transition: opacity 0.2s; }
|
.fewo-card-img { width: 100%; height: 200px; object-fit: cover; transition: opacity 0.2s; }
|
||||||
.fewo-card-imglink:hover .fewo-card-img { opacity: 0.9; }
|
.fewo-card-imglink:hover .fewo-card-img { opacity: 0.9; }
|
||||||
.fewo-card-body { padding: 1.5rem; }
|
.fewo-card-body { padding: 1.5rem; }
|
||||||
|
.fewo-card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.fewo-card-actions .btn { margin-right: 0; }
|
||||||
.fewo-card-body h2, .fewo-card-body h3 { margin-bottom: 0.5rem; font-size: 1.2rem; }
|
.fewo-card-body h2, .fewo-card-body h3 { margin-bottom: 0.5rem; font-size: 1.2rem; }
|
||||||
.fewo-card-body h2 a, .fewo-card-body h3 a { color: var(--text); }
|
.fewo-card-body h2 a, .fewo-card-body h3 a { color: var(--text); }
|
||||||
.fewo-meta {
|
.fewo-meta {
|
||||||
@@ -311,10 +338,13 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
/* ===== Karten-Grid (wiederverwendet für Umfeld + Ausstattung) ===== */
|
/* ===== Karten-Grid (wiederverwendet für Umfeld + Ausstattung) ===== */
|
||||||
.spec-grid {
|
.spec-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 980px) {
|
||||||
|
.spec-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
.spec-grid { grid-template-columns: 1fr; }
|
.spec-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
.spec-block {
|
.spec-block {
|
||||||
@@ -337,7 +367,6 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
/* ===== Ausstattung (einzelne Ferienwohnung) ===== */
|
/* ===== Ausstattung (einzelne Ferienwohnung) ===== */
|
||||||
.ausstattung { margin-top: 2rem; }
|
.ausstattung { margin-top: 2rem; }
|
||||||
.ausstattung h2 { margin: 1.5rem 0 1rem; font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
.ausstattung h2 { margin: 1.5rem 0 1rem; font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
||||||
.ausstattung-hinweis { margin-top: 1.5rem; font-size: 0.95rem; color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ===== Ausflugsziele-Gruppen ===== */
|
/* ===== Ausflugsziele-Gruppen ===== */
|
||||||
.ausflug-gruppe { margin-top: 3rem; }
|
.ausflug-gruppe { margin-top: 3rem; }
|
||||||
@@ -548,7 +577,7 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(255,255,255,0.6);
|
background: transparent;
|
||||||
}
|
}
|
||||||
.zitat-box-inner {
|
.zitat-box-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -610,3 +639,31 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
|
|
||||||
/* ===== Main ===== */
|
/* ===== Main ===== */
|
||||||
.main { min-height: calc(100vh - var(--nav-height) - 200px); }
|
.main { min-height: calc(100vh - var(--nav-height) - 200px); }
|
||||||
|
|
||||||
|
/* ===== Blog ===== */
|
||||||
|
.blog-grid { margin-top: 2rem; }
|
||||||
|
.blog-date { color: var(--muted, #777); font-size: 0.85rem; margin-bottom: 0.4rem; }
|
||||||
|
.container--narrow { max-width: 760px; }
|
||||||
|
.blog-hero-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 420px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius, 6px);
|
||||||
|
}
|
||||||
|
.blog-post h2 { margin-top: 2.5rem; }
|
||||||
|
.blog-back { margin-top: 2.5rem; }
|
||||||
|
.blog-figure { margin: 1rem 0 1.5rem; }
|
||||||
|
.blog-figure img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 380px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius, 6px);
|
||||||
|
}
|
||||||
|
.blog-hero { margin: 0 0 2rem; }
|
||||||
|
.bild-quelle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted, #777);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.bild-quelle a { color: var(--muted, #777); text-decoration: underline; }
|
||||||
|
|||||||