#!/usr/bin/env python3 import subprocess import json import time import threading from datetime import datetime, timedelta from pathlib import Path from flask import Flask, jsonify, render_template_string, Response DB_DATA = Path(__file__).parent / "page_counts.json" INTERVAL = 30 * 60 # 30 Minuten # Bilder werden nur alle 2 Stunden exakt gezählt (SUM über die ~10-GB- # Tabelle). Dazwischen wird eine 1-%-Stichprobe (TABLESAMPLE SYSTEM) # hochgerechnet - das kostet nur einen Bruchteil der IO. EXACT_IMAGE_INTERVAL = 2 * 60 * 60 # Sekunden IMAGE_SAMPLE_PERCENT = 1 # Sehr kleine Tabellen: Stichprobe kann leer sein; dort ist der exakte # Count ohnehin billig. SAMPLE_EXACT_FALLBACK_ROWS = 100_000 app = Flask(__name__) # r""" raw string: preserves \n etc. for the embedded JavaScript # (a normal string would eat the backslashes and break the JS) HTML = r""" JonasSearch – Pages

JonasSearch

Indexed Pages – Live Statistics
Export CSV Export JSON
–Pages
Loading…
–Images
Loading…
Loading… Interval: – First measurement: – Measurements: – Last update: – Next refresh: –
Growth / hour
–
Average over full history
Growth / day
–
Average over full history
Growth 24 h
–
vs. measurement ~24 h ago
Growth 7 days
–
vs. measurement ~7 d ago
Avg / h (24 h)
–
Mean of last 24 h changes
Best hour
–
Largest gain within one hour
Worst hour
–
Largest loss within one hour
Peak
–
Highest measurement ever
Lowest
–
Lowest measurement ever
Forecast next hour
–
Linear trend extrapolation
Indexed pages over time
Real measurement timestamps · dashed line = linear-regression trend & 24 h forecast
Change per 5 minutes
Normalized to a 5-minute interval · grey = raw · colored = smoothed (moving average over 5 measurements)
Cumulative net growth
Total pages gained/lost since the start of the selected range · linear time axis
Growth per hour (last 48 h)
Sum of changes per hour bucket · green = growth, red = loss
Growth by hour of day
Average gain per 5 min, grouped by hour of day (whole history) · shows when the crawler is most active
Records – biggest changes
Largest gains and losses between two consecutive measurements (selected range)
Activity heatmap – gain per hour of day
Rows = days · columns = hours (0–23) · green = growth, red = loss, grey = no data
""" def load_data(): if not DB_DATA.exists(): return [] try: with DB_DATA.open("r", encoding="utf-8") as f: return json.load(f) except Exception: return [] def save_data(data): tmp = DB_DATA.with_suffix(".tmp") with tmp.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) tmp.replace(DB_DATA) # Exakt: ein Scan, der COUNT(*) und die Bilder in einem Durchgang # ermittelt. Wird nur alle EXACT_IMAGE_INTERVAL Sekunden ausgeführt. EXACT_IMAGE_SQL = ( "SELECT COUNT(*), " "COALESCE(SUM(cardinality(pictures)), 0)::bigint " "FROM pages;" ) # Schätzung: COUNT(*) wie bisher, Bilder aus 1 % der Tabellenblöcke # (TABLESAMPLE SYSTEM), hochgerechnet. Die zweite Spalte ist NULL, wenn # kein Block ausgewählt wurde (sehr kleine Tabelle). ESTIMATE_IMAGE_SQL = ( "SELECT (SELECT COUNT(*) FROM pages), " "(SELECT (SUM(cardinality(pictures)) * {scale})::bigint " "FROM pages TABLESAMPLE SYSTEM ({percent}));" ).format( scale=100 // IMAGE_SAMPLE_PERCENT, percent=IMAGE_SAMPLE_PERCENT, ) def _psql_scalar(sql): """Eine einzelne psql-Zeile ("count|images") abholen.""" command = [ "sudo", "-u", "postgres", "psql", "-p", "25432", "-d", "jonassearch", "-t", "-A", "-F", "|", "-c", sql, ] result = subprocess.run( command, capture_output=True, text=True, check=True ) return result.stdout.strip() def _parse_stats_row(output): """"count|images" -> (count, images|None).""" count_raw, _sep, images_raw = (output or "").strip().partition("|") count = int(count_raw.strip()) images = int(images_raw.strip()) if images_raw.strip() else None return count, images def images_exact_due(data, now=None): """ True, wenn der letzte exakte Bild-Count fehlt oder mindestens EXACT_IMAGE_INTERVAL alt ist. Einträge aus der Zeit vor dieser Funktion (ohne images_exact) zählen nicht als exakt. """ now = now or datetime.now() for entry in reversed(data or []): if entry.get("images_exact") is not True: continue try: timestamp = datetime.fromisoformat(str(entry.get("timestamp", ""))) except (TypeError, ValueError): continue return now - timestamp >= timedelta(seconds=EXACT_IMAGE_INTERVAL) return True def get_stats(exact_images): """ Seitenzahl + Bildanzahl holen. exact_images=True: ein Scan, exakter Bild-Count. exact_images=False: Seitenzahl wie bisher, Bilder aus einer 1-%-Stichprobe hochgerechnet. Liefert die Stichprobe nichts (kleine Tabelle), wird exakt gezählt - das ist dort billig. Rückgabe: (pages, images, images_exact). images kann None sein, wenn eine große Tabelle wider Erwarten keinen Block gesampelt hat. """ try: if exact_images: count, images = _parse_stats_row(_psql_scalar(EXACT_IMAGE_SQL)) return count, images, True count, images = _parse_stats_row(_psql_scalar(ESTIMATE_IMAGE_SQL)) except subprocess.CalledProcessError as exc: # Z. B. noch keine Migration (pages.pictures fehlt): die # Seitenzahl soll trotzdem weiter erfasst werden. detail = ((exc.stderr or "").strip().splitlines() or [str(exc)])[-1] print(f"Bild-Statistik nicht verfügbar ({detail}); zähle nur Seiten.") return int(_psql_scalar("SELECT COUNT(*) FROM pages;").strip()), None, False if images is None: if count < SAMPLE_EXACT_FALLBACK_ROWS: return get_stats(True) return count, None, False return count, images, False def collect(): while True: try: data = load_data() exact = images_exact_due(data) count, images, images_exact = get_stats(exact) if images is None: # Stichprobe ohne Treffer (praktisch nur bei sehr großen # Tabellen): letzten bekannten Wert fortschreiben. for previous in reversed(data): if previous.get("images") is not None: images = previous["images"] break entry = { "timestamp": datetime.now().isoformat(), "count": count, "images": images, "images_exact": images_exact, } data.append(entry) save_data(data) print( f"[{entry['timestamp']}] " f"Pages: {count} · Images: {images} " f"({'exakt' if images_exact else 'geschätzt'})" ) except Exception as e: print(f"Fehler beim Abrufen der Statistik: {e}") time.sleep(INTERVAL) @app.route("/") def index(): return render_template_string(HTML) @app.route("/api/data") def api_data(): return jsonify({ "values": load_data() }) @app.route("/api/export") def api_export(): lines = ["timestamp;count"] for entry in load_data(): lines.append(f"{entry.get('timestamp', '')};{entry.get('count', '')}") return Response("\n".join(lines), mimetype="text/csv") if __name__ == "__main__": # Collector im Hintergrund starten thread = threading.Thread(target=collect, daemon=True) thread.start() # Webserver app.run( host="0.0.0.0", port=8081, threaded=True, load_dotenv=False # sonst liest Flask beim Start .env im Projektordner -> PermissionError )