File size: 7,381 Bytes
284e818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71c08ae
284e818
 
 
 
 
 
 
ed70eb7
284e818
4ccb3e1
284e818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed70eb7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284e818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b02f256
 
eea5f65
e6a157d
 
284e818
 
 
 
 
 
 
 
 
 
71c08ae
 
 
 
 
ed70eb7
284e818
 
ed70eb7
284e818
71c08ae
ed70eb7
284e818
 
65fb614
 
 
 
284e818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ccb3e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284e818
 
 
 
4ccb3e1
ed70eb7
 
 
 
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""Build an interactive fuel-price map of France as a self-contained HTML file.

Rather than emitting one marker object per station per fuel (which balloons the
file to tens of MB), the station data is embedded once as compact JSON and the
map is rendered client-side with Leaflet + marker clustering. A fuel selector
recolours and filters the points live: green = cheaper, red = pricier, scaled to
each fuel's national price distribution.
"""
from __future__ import annotations

import json
import math
import os
import shutil
from datetime import datetime
from zoneinfo import ZoneInfo

from car_data import fetch_cars
from fuel_data import FUELS, fetch_stations

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
STATIC_DIR = os.path.join(BASE_DIR, "static")
OUTPUT_HTML = os.path.join(OUTPUT_DIR, "index.html")  # served at "/" (PWA start_url)
CACHE_JSON = os.path.join(OUTPUT_DIR, "stations.json")
HISTORY_JSON = os.path.join(OUTPUT_DIR, "price_history.json")  # national medians over time
PARIS = ZoneInfo("Europe/Paris")
SITE_URL = "https://samdnx-prix-carburant.hf.space/"  # canonical URL (sitemap / SEO)

FUEL_LABELS = list(FUELS)
TEMPLATE_PATH = os.path.join(BASE_DIR, "map_template.html")


def percentile(sorted_vals: list[float], pct: float) -> float:
    """Linear-interpolation percentile of an already-sorted list."""
    if not sorted_vals:
        return 0.0
    k = (len(sorted_vals) - 1) * pct / 100.0
    lo, hi = math.floor(k), math.ceil(k)
    if lo == hi:
        return sorted_vals[int(k)]
    return sorted_vals[lo] * (hi - k) + sorted_vals[hi] * (k - lo)


def fuel_stats(stations: list[dict]) -> dict[str, dict]:
    """Per-fuel count and 5th/50th/95th percentiles used for colour scaling."""
    stats = {}
    for label in FUEL_LABELS:
        values = sorted(
            s["prices"][label] for s in stations if s["prices"][label] is not None
        )
        if not values:
            continue
        stats[label] = {
            "count": len(values),
            "p5": round(percentile(values, 5), 3),
            "median": round(percentile(values, 50), 3),
            "p95": round(percentile(values, 95), 3),
        }
    return stats


def compute_trends(stats: dict[str, dict]) -> dict[str, dict]:
    """National median per fuel, with the change vs the previous build.

    A small history of medians is kept in output/price_history.json so the map
    can show whether each fuel is trending up or down. On hosts with ephemeral
    storage the history resets on a cold restart (deltas then start at 0).
    """
    today = datetime.now(PARIS).strftime("%Y-%m-%d")
    medians = {f: stats[f]["median"] for f in stats}
    history: list[dict] = []
    if os.path.exists(HISTORY_JSON):
        try:
            with open(HISTORY_JSON, encoding="utf-8") as fh:
                history = json.load(fh)
        except Exception:
            history = []

    prev = history[-1]["medians"] if history else {}
    trends = {f: {"median": m, "delta": round(m - prev[f], 3) if f in prev else 0.0}
              for f, m in medians.items()}

    if history and history[-1].get("date") == today:
        history[-1]["medians"] = medians          # same day: keep one entry
    else:
        history.append({"date": today, "medians": medians})
    history = history[-30:]
    try:
        with open(HISTORY_JSON, "w", encoding="utf-8") as fh:
            json.dump(history, fh)
    except Exception:
        pass
    return trends


def compact_records(stations: list[dict]) -> list[list]:
    """Encode each station as a small positional array to shrink the payload."""
    records = []
    for s in stations:
        addr_parts = [s["address"], f"{s['cp']} {s['city']}".strip()]
        addr = ", ".join(p for p in addr_parts if p)
        prices = [s["prices"][label] for label in FUEL_LABELS]
        records.append(
            [
                round(s["lat"], 5),
                round(s["lon"], 5),
                s["name"],
                s["brand"],
                addr,
                (s["update"] or "")[:10],
                prices,
                s["cp"],
                s["city"],
                1 if s["highway"] else 0,
                1 if s.get("open24") else 0,
                s.get("services") or [],
            ]
        )
    return records


def build_html(stations: list[dict]) -> str:
    """Render the HTML document from the template and embedded data."""
    with open(TEMPLATE_PATH, encoding="utf-8") as fh:
        template = fh.read()

    try:
        cars = fetch_cars()
    except Exception as exc:  # car list is optional; never block the map build
        print(f"  (car list unavailable: {exc})")
        cars = []
    stats = fuel_stats(stations)
    payload = {
        "fuels": FUEL_LABELS,
        "stats": stats,
        "stations": compact_records(stations),
        "cars": cars,
        "trends": compute_trends(stats),
    }
    data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
    months_fr = ["janv.", "févr.", "mars", "avr.", "mai", "juin",
                 "juil.", "août", "sept.", "oct.", "nov.", "déc."]
    now = datetime.now(PARIS)
    stamp = f"{now.day} {months_fr[now.month - 1]} {now.year}, {now:%H:%M}"

    return (
        template.replace("__DATA__", data_json)
        .replace("__COUNT__", f"{len(stations):,}".replace(",", " "))
        .replace("__TIMESTAMP__", stamp)
    )


def copy_static() -> None:
    """Copy the PWA assets (manifest, service worker, icons) into output/."""
    if not os.path.isdir(STATIC_DIR):
        return
    for name in os.listdir(STATIC_DIR):
        src = os.path.join(STATIC_DIR, name)
        if os.path.isfile(src):
            shutil.copy2(src, os.path.join(OUTPUT_DIR, name))


def write_sitemap(lastmod: str) -> None:
    """Write sitemap.xml with the build date as <lastmod> (freshness signal for crawlers)."""
    xml = (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
        '  <url>\n'
        f'    <loc>{SITE_URL}</loc>\n'
        f'    <lastmod>{lastmod}</lastmod>\n'
        '    <changefreq>daily</changefreq>\n'
        '    <priority>1.0</priority>\n'
        '  </url>\n'
        '</urlset>\n'
    )
    with open(os.path.join(OUTPUT_DIR, "sitemap.xml"), "w", encoding="utf-8") as fh:
        fh.write(xml)


def generate(output_html: str = OUTPUT_HTML) -> str:
    """Fetch live data, build the map, write the HTML file, return its path."""
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    copy_static()
    write_sitemap(datetime.now(PARIS).strftime("%Y-%m-%d"))
    try:  # social-share card (needs Pillow + a system font); never block the build
        import generate_og
        generate_og.save(os.path.join(OUTPUT_DIR, "og-card.png"))
    except Exception as exc:
        print(f"  (social card unavailable: {exc})")
    stations = fetch_stations(cache_path=CACHE_JSON)
    html_doc = build_html(stations)
    with open(output_html, "w", encoding="utf-8") as fh:
        fh.write(html_doc)
    size_mb = os.path.getsize(output_html) / 1e6
    print(
        f"[{datetime.now(PARIS):%Y-%m-%d %H:%M:%S}] "
        f"Map generated: {output_html} ({len(stations)} stations, {size_mb:.1f} MB)"
    )
    return output_html


if __name__ == "__main__":
    generate()