File size: 4,791 Bytes
284e818
 
 
 
 
 
 
 
 
590c919
284e818
 
 
71c08ae
284e818
 
 
71c08ae
284e818
 
 
 
 
 
 
 
590c919
 
 
 
 
 
 
284e818
71c08ae
 
 
 
 
284e818
 
71c08ae
284e818
 
71c08ae
284e818
71c08ae
284e818
71c08ae
284e818
 
 
 
 
 
 
 
 
 
57ffa0b
 
284e818
 
 
 
 
 
 
 
590c919
 
 
284e818
 
590c919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71c08ae
 
 
 
 
 
 
 
284e818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71c08ae
284e818
 
 
71c08ae
284e818
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Production entrypoint: build the map, serve it, refresh it twice a day.

Runs as the single process inside a container (e.g. a Hugging Face Docker
Space). It builds the map immediately on startup, serves the output/ directory
over HTTP on $PORT (default 7860), and rebuilds at 07:00 and 19:00 in a
background thread so the served prices stay fresh.
"""
from __future__ import annotations

import gzip
import http.server
import os
import socketserver
import sys
import threading
import time
import traceback
from datetime import datetime

import schedule

from build_map import OUTPUT_DIR, generate

PORT = int(os.environ.get("PORT", "7860"))
REFRESH_TIMES = ["07:00", "19:00"]  # twice a day, container local time

# Content types worth gzipping (text-ish). Images/PNG are already compressed.
COMPRESSIBLE = {
    "text/html", "text/css", "text/javascript", "text/plain",
    "application/javascript", "application/json",
    "application/manifest+json", "application/xml", "image/svg+xml",
}


def log(msg: str) -> None:
    """Timestamped line, flushed immediately so it shows up in container logs."""
    print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}", flush=True)


def safe_generate() -> None:
    """Rebuild the map, logging failures instead of crashing the loop."""
    log("Refreshing fuel data and rebuilding the map…")
    try:
        generate()
        log("Rebuild complete.")
    except Exception:
        log("Map generation FAILED:")
        traceback.print_exc()
        sys.stdout.flush()


class Handler(http.server.SimpleHTTPRequestHandler):
    """Serve output/ with correct MIME types and no-cache on the live files."""

    extensions_map = {
        **http.server.SimpleHTTPRequestHandler.extensions_map,
        ".webmanifest": "application/manifest+json",
        ".js": "text/javascript",
        ".json": "application/json",
        ".xml": "application/xml",
        ".txt": "text/plain",
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=OUTPUT_DIR, **kwargs)

    def end_headers(self):
        path = self.path.split("?")[0]
        if path in ("/", "/index.html", "/sw.js"):
            self.send_header("Cache-Control", "no-cache")  # always revalidate
        elif path.endswith((".png", ".ico", ".webmanifest", ".svg")):
            self.send_header("Cache-Control", "public, max-age=86400")  # rarely change
        super().end_headers()

    def do_GET(self):
        """Serve files with gzip for text assets (big win for the embedded data)."""
        path = self.translate_path(self.path)
        if os.path.isdir(path):
            path = os.path.join(path, "index.html")
        if not os.path.isfile(path):
            return super().do_GET()  # let the base class produce a 404
        try:
            with open(path, "rb") as fh:
                body = fh.read()
        except OSError:
            return super().do_GET()

        ctype = self.guess_type(path)
        base_ct = ctype.split(";")[0].strip()
        if base_ct in COMPRESSIBLE and "charset" not in ctype:
            ctype = base_ct + "; charset=utf-8"

        encoding = None
        if ("gzip" in self.headers.get("Accept-Encoding", "")
                and base_ct in COMPRESSIBLE and len(body) > 1024):
            body = gzip.compress(body, 6)
            encoding = "gzip"

        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        if encoding:
            self.send_header("Content-Encoding", encoding)
            self.send_header("Vary", "Accept-Encoding")
        self.end_headers()  # adds the Cache-Control policy above
        self.wfile.write(body)

    def log_message(self, fmt, *args):  # verbose, timestamped request logging
        log(f"{self.address_string()} {fmt % args}")

    def handle(self):
        try:
            super().handle()
        except (BrokenPipeError, ConnectionResetError):
            pass  # client disconnected mid-response; nothing to do


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


def scheduler_loop() -> None:
    for when in REFRESH_TIMES:
        schedule.every().day.at(when).do(safe_generate)
    while True:
        schedule.run_pending()
        time.sleep(30)


def main() -> None:
    log(f"Starting petrol-map on port {PORT} (TZ={os.environ.get('TZ', 'system')})")
    safe_generate()  # ensure the map exists before we start serving
    threading.Thread(target=scheduler_loop, daemon=True).start()
    with Server(("0.0.0.0", PORT), Handler) as httpd:
        log(f"Serving on http://0.0.0.0:{PORT}  (auto-refresh at {', '.join(REFRESH_TIMES)})")
        httpd.serve_forever()


if __name__ == "__main__":
    main()