Skripte nach bin/ und lib/ umstrukturiert + WSL-Log-Analyse Alle .bat-Skripte liegen jetzt in bin/, alle .py in lib/, .env(.example) in bin/. setenv.bat setzt IWZ_* (inkl. IWZ_ENV) und PYTHONPATH; die Launcher rufen setenv, aktivieren die .venv falls vorhanden und starten das zugehoerige lib/-Skript. Die Python-Skripte lesen die .env ueber IWZ_ENV (Fallback bin/.env). Neu: Nutzungsanalyse per GoAccess (analyze.py/.bat) - laedt die IONOS-Access-Logs per SFTP (paramiko) und erzeugt einen HTML-Report. GoAccess laeuft ueber WSL (GOACCESS_MODE=wsl, Pfad-Uebersetzung nach /mnt/c). install_py.bat prueft WSL+GoAccess und zeigt sonst den Installationsweg. Doku in doc/analyse.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
227 lines
8.0 KiB
Python
227 lines
8.0 KiB
Python
#!/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())
|