chore: checkpoint initial — Zektyc dataset, avant humanisation

- site vitrine + fingerprint test + zmap + z-panel
- remplacement duckdns -> riricdev.tail5ea5cd.ts.net (canonical/og/alternates/docs)
- suppression du token DuckDNS (plus utilisé)
This commit is contained in:
riricdev 2026-09-06 13:03:25 +02:00
commit d7ec32a458
92 changed files with 17476 additions and 0 deletions

98
src/db.ts Normal file
View file

@ -0,0 +1,98 @@
import { Database } from "bun:sqlite"
import { join, normalize } from "node:path"
const DATA_ROOT = normalize(join(import.meta.dir, "..", "data"))
export const DB_FILE = normalize(join(DATA_ROOT, "zektyc.db"))
export interface HoneypotRow {
ip: string
count: number
first: number
last: number
banned: boolean
endpoints: Record<string, number>
}
const db = new Database(DB_FILE, { create: true })
db.run(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS honeypot (
ip TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 1,
first INTEGER NOT NULL,
last INTEGER NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
endpoints TEXT NOT NULL DEFAULT '{}'
);
`)
const upsertStmt = db.prepare(`
INSERT INTO honeypot (ip, count, first, last, banned, endpoints)
VALUES (?1, 1, ?2, ?2, 0, ?3)
ON CONFLICT(ip) DO UPDATE SET
count = count + 1,
last = excluded.last,
endpoints = excluded.endpoints
`)
const bumpStmt = db.prepare(`
UPDATE honeypot
SET count = count + 1, last = ?2, endpoints = ?3
WHERE ip = ?1
`)
const getStmt = db.prepare(`SELECT * FROM honeypot WHERE ip = ?1`)
const banStmt = db.prepare(`
INSERT INTO honeypot (ip, count, first, last, banned, endpoints)
VALUES (?1, 0, ?2, ?2, ?3, '{}')
ON CONFLICT(ip) DO UPDATE SET banned = ?3
`)
const getBannedStmt = db.prepare(`SELECT ip FROM honeypot WHERE banned = 1`)
const clearStmt = db.prepare(`DELETE FROM honeypot`)
const listStmt = db.prepare(`SELECT * FROM honeypot ORDER BY last DESC`)
export function recordHoneypotHit(ip: string, path: string) {
if (!ip || ip === "local") return
const now = Date.now()
const row = getStmt.get(ip) as { endpoints: string } | null
if (row) {
let endpoints: Record<string, number> = {}
try {
endpoints = JSON.parse(row.endpoints) as Record<string, number>
} catch {}
endpoints[path] = (endpoints[path] ?? 0) + 1
bumpStmt.run(ip, now, JSON.stringify(endpoints))
} else {
upsertStmt.run(ip, now, JSON.stringify({ [path]: 1 }))
}
}
export function getHoneypotIps(): HoneypotRow[] {
const rows = listStmt.all() as Array<
Omit<HoneypotRow, "banned" | "endpoints"> & { banned: number; endpoints: string }
>
return rows.map((r) => ({
ip: r.ip,
count: r.count,
first: r.first,
last: r.last,
banned: r.banned === 1,
endpoints: JSON.parse(r.endpoints) as Record<string, number>,
}))
}
export function setHoneypotBanned(ip: string, banned: boolean) {
const now = Date.now()
banStmt.run(ip, now, banned ? 1 : 0)
return true
}
export function getBannedHoneypotIps(): string[] {
const rows = getBannedStmt.all() as Array<{ ip: string }>
return rows.map((r) => r.ip)
}
export function clearHoneypot() {
clearStmt.run()
}
export { db }

107
src/docs.ts Normal file
View file

@ -0,0 +1,107 @@
import { marked } from "marked"
import { readFileSync } from "node:fs"
import { join, normalize } from "node:path"
const SITE_URL = "https://riricdev.tail5ea5cd.ts.net"
// Layout de la page de documentation (navigable, sections).
function layout(title: string, description: string, body: string, sidebar: string): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="${description}" />
<meta name="robots" content="index, follow" />
<title>${title} · Zektyc</title>
<link rel="canonical" href="${SITE_URL}/docs/maxspeed" />
<meta name="theme-color" content="#070a0f" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Zektyc" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${SITE_URL}/docs/maxspeed" />
<meta property="og:image" content="${SITE_URL}/og-image.png" />
<meta property="og:locale" content="fr_FR" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="me" href="https://piaille.fr/@zektyc" />
<link rel="stylesheet" href="/css/style.css?v=7" />
<style>
.docs-wrap { display: flex; gap: 32px; align-items: flex-start; max-width: 1180px; margin: 0 auto; padding: 40px 24px; }
.docs-side { flex: 0 0 240px; position: sticky; top: 24px; font-size: 14px; }
.docs-side h3 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.6; }
.docs-side ul { list-style: none; margin: 0; padding: 0; }
.docs-side li { margin: 4px 0; }
.docs-side a { color: inherit; text-decoration: none; opacity: 0.85; }
.docs-side a:hover { opacity: 1; text-decoration: underline; }
.docs-side a.active { opacity: 1; font-weight: 600; }
.docs-body { flex: 1 1 auto; min-width: 0; line-height: 1.7; }
.docs-body h1 { font-size: 1.9em; border-bottom: 1px solid #222; padding-bottom: 12px; }
.docs-body h2 { font-size: 1.35em; margin-top: 2.2em; border-bottom: 1px solid #1e1e1e; padding-bottom: 6px; }
.docs-body h3 { font-size: 1.1em; margin-top: 1.6em; }
.docs-body pre { background: #0d1117; border: 1px solid #222; border-radius: 8px; padding: 14px; overflow: auto; font-size: 13px; }
.docs-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.docs-body :not(pre) > code { background: #161b22; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
.docs-body table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 14px; }
.docs-body th, .docs-body td { border: 1px solid #24262b; padding: 8px 12px; text-align: left; }
.docs-body th { background: #0d1117; }
.docs-body blockquote { border-left: 3px solid #333; margin: 16px 0; padding: 4px 16px; opacity: 0.85; }
@media (max-width: 760px) { .docs-wrap { flex-direction: column; } .docs-side { position: static; flex: auto; } }
</style>
<script src="/js/theme.js" defer></script>
<script src="/js/i18n.js" defer></script>
<script>document.addEventListener("click",function(e){var t=e.target.closest?e.target.closest(".site-nav"):null;if(t){var n=document.getElementById("nav-toggle");if(n)n.checked=false}})</script>
</head>
<body>
<header class="site-header">
<a class="skip-link" href="#main">Aller au contenu</a>
<a class="brand" href="/"><span class="brand-mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16" role="presentation" focusable="false"><path d="M5 4h22L5 12h22" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="brand-name">Zektyc</span></a>
<input type="checkbox" id="nav-toggle" class="nav-toggle-input" />
<label for="nav-toggle" class="nav-burger" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
</label>
<nav class="site-nav" aria-label="Navigation">
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button type="button" data-lang="fr" class="lang-btn active"><strong>FR</strong></button>
<button type="button" data-lang="en" class="lang-btn">EN</button>
</div>
<button type="button" class="theme-btn" aria-label="Theme">
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
</nav>
</header>
<main id="main" class="docs-wrap">
<nav class="docs-side" aria-label="Documentation">
<h3>API</h3>
<ul>
<li><a href="/docs/maxspeed" class="active">Maxspeed (vitesses)</a></li>
</ul>
</nav>
<article class="docs-body">
${body}
</article>
</main>
<footer class="site-footer"></footer>
</body>
</html>`
}
const DOCS_DIR = normalize(join(import.meta.dir))
export function docsPage(service: string): string | null {
if (service !== "maxspeed") return null
const md = readFileSync(join(DOCS_DIR, "maxspeed-doc.md"), "utf8")
const body = marked.parse(md) as string
return layout(
"API Maxspeed",
"API publique des limitations de vitesse (maxspeed) en Europe, depuis OpenStreetMap. Endpoints point & route, exemples, performance.",
body,
"",
)
}

163
src/maxspeed-api.ts Normal file
View file

@ -0,0 +1,163 @@
import { readFileSync, existsSync } from "node:fs"
import { gunzipSync } from "node:zlib"
// ============================================================================
// API publique "maxspeed" — jeu de données OSM des limitations de vitesse.
//
// Données : grille de cellules 0.5° x 0.5° couvrant l'Europe (produite par
// data/maxspeed/build_maxspeed.py à partir du planet OSM). Chaque fichier
// <x>_<y>.bin.gz = blob gzip :
// int32 count ; int32 offsets[32*32] ; puis count points de
// (lonq uint16, latq uint16, speed uint8) triés par sous-cellule 128.
//
// Ce module est autonome (indépendant de ZMap). Lecture lazy par cellule avec
// cache LRU borné.
// ============================================================================
const CELL = 0.5
const SUB = 32
const MAX_DIST = 40.0 // mètres: distance maximale route->point pour un "hit"
const DEFAULT_DIR = process.env.ZMAP_MAXSPEED_DIR || ""
let GRID_DIR = DEFAULT_DIR
let index: Record<string, { x: number; y: number; count: number }> | null = null
const cells = new Map<string, { off: Int32Array; data: Buffer }>()
export function setMaxspeedDir(dir: string) {
GRID_DIR = dir
index = null
cells.clear()
}
export function gridDir(): string {
return GRID_DIR
}
export function meta() {
const idx = loadIndex()
if (!idx) return null
let points = 0
let files = 0
for (const k in idx) {
files++
points += idx[k]!.count
}
return {
cell: CELL,
sub: SUB,
stepM: 40.0,
points,
files,
dir: GRID_DIR,
}
}
function loadIndex(): Record<string, { x: number; y: number; count: number }> | null {
if (!GRID_DIR) return null
if (!index) {
const f = `${GRID_DIR}/index.json`
if (!existsSync(f)) return null
try {
index = JSON.parse(readFileSync(f, "utf8"))
} catch {
return null
}
}
return index
}
function loadCell(x: number, y: number): { off: Int32Array; data: Buffer } | null {
const key = `${x}_${y}`
const hit = cells.get(key)
if (hit) return hit
const f = `${GRID_DIR}/${key}.bin.gz`
if (!existsSync(f)) return null
try {
const raw = gunzipSync(readFileSync(f))
const off = new Int32Array(raw.buffer, raw.byteOffset + 4, SUB * SUB)
const data = raw.subarray(4 + SUB * SUB * 4)
const c = { off, data }
cells.set(key, c)
if (cells.size > 64) cells.delete(cells.keys().next().value!)
return c
} catch {
return null
}
}
// Recherche la vitesse au coordonnée donnée. Retour km/h, ou null si aucun
// point de route dans les 40 m. Coordonnées (lon, lat).
export function snapPoint(lon: number, lat: number): number | null {
const idx = loadIndex()
if (!idx) return null
const x = Math.floor(lon / CELL)
const y = Math.floor(lat / CELL)
const cell = loadCell(x, y)
if (!cell) return null
return snapInCell(cell, x, y, lat, lon)
}
function snapInCell(cell: { off: Int32Array; data: Buffer }, cx: number, cy: number, lat: number, lon: number): number | null {
const lonq = Math.round(((lon - cx * CELL) / CELL) * 65535)
const latq = Math.round(((lat - cy * CELL) / CELL) * 65535)
if (lonq < 0 || lonq > 65535 || latq < 0 || latq > 65535) return null
const sx = Math.min(SUB - 1, Math.floor((lonq * SUB) / 65536))
const sy = Math.min(SUB - 1, Math.floor((latq * SUB) / 65536))
let best: number | null = null
let bestD2 = Infinity
const cosLat = Math.cos((lat * Math.PI) / 180)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const sxx = sx + dx
const syy = sy + dy
if (sxx < 0 || syy < 0 || sxx >= SUB || syy >= SUB) continue
const sub = sxx + syy * SUB
const start = cell.off[sub]!
const end = sub + 1 < SUB * SUB ? cell.off[sub + 1]! : cell.data.length / 5
for (let i = start; i < end; i++) {
const base = i * 5
const plon = cx * CELL + (cell.data.readUInt16LE(base) / 65535) * CELL
const plat = cy * CELL + (cell.data.readUInt16LE(base + 2) / 65535) * CELL
const dlonp = (lon - plon) * 111320 * cosLat
const dlatp = (lat - plat) * 110540
const d2 = dlonp * dlonp + dlatp * dlatp
if (d2 < bestD2) {
bestD2 = d2
best = cell.data.readUInt8(base + 4)
}
}
}
}
if (bestD2 > MAX_DIST * MAX_DIST) return null
return best
}
// Snap sur un tracé (liste de [lon, lat]). Retour un tableau de vitesses,
// null quand aucun point de route proche.
export function snapRoute(coords: [number, number][]): (number | null)[] {
const out: (number | null)[] = new Array(coords.length).fill(null)
const idx = loadIndex()
if (!idx) return out
const byCell = new Map<string, { x: number; y: number; indices: number[] }>()
for (let i = 0; i < coords.length; i++) {
const [lon, lat] = coords[i]!
const x = Math.floor(lon / CELL)
const y = Math.floor(lat / CELL)
const k = `${x}_${y}`
let e = byCell.get(k)
if (!e) {
e = { x, y, indices: [] }
byCell.set(k, e)
}
e.indices.push(i)
}
for (const { x, y, indices } of byCell.values()) {
const cell = loadCell(x, y)
if (!cell) continue
for (const i of indices) {
const [lon, lat] = coords[i]!
out[i] = snapInCell(cell, x, y, lat, lon)
}
}
return out
}

261
src/maxspeed-doc.md Normal file
View file

@ -0,0 +1,261 @@
# Documentation API · Zektyc
Bienvenue sur la documentation des API publiques Zektyc. Chaque service expose une
ou plusieurs routes JSON, documentées ci-dessous. Cette page est la **version unique
de référence** : ajoutez ici toute nouvelle API.
> Toutes les réponses sont en `application/json; charset=utf-8`, compatibles CORS
> (n'importe quel navigateur peut les appeler directement). Le trafic passe par un
> reverse-proxy qui applique un rate-limit par IP.
---
## 📚 Sommaire
- [1. API Maxspeed — limitations de vitesse](#1-api-maxspeed)
- [2. Format de réponse standard](#2-format-de-reponse-standard)
- [3. Erreurs & codes HTTP](#3-erreurs--codes-http)
- [4. Rate limiting](#4-rate-limiting)
- [5. Performance mesurée](#5-performance-mesuree)
- [6. Exemples](#6-exemples)
- [7. Playground](#7-playground)
---
# 1. API Maxspeed
Découvre la **limitation de vitesse** (maxspeed) d'un point géographique en France
et en Europe, calculée depuis les données OpenStreetMap.
La base est une grille de **90 518 792 points** échantillonnés le long des routes
(europa entière), à une résolution d'environ **0,8 mètre**. Chaque point porte la
vitesse maximale de la route sur laquelle il se trouve.
**Base URL** (racine de l'API) :
```
https://riricdev.tail5ea5cd.ts.net/api/maxspeed
```
| Endpoint | Méthode | Description |
|----------|---------|--------------------------------------|
| `/api/maxspeed/` | GET | Métadonnées du jeu de données (points, cellules, résolution) |
| `/api/maxspeed/point` | GET | Limitation de vitesse à un point (lat, lon) |
| `/api/maxspeed/route` | GET | Limitation le long d'un tracé (liste de points) |
### 1.1 Point isolé
Interroge la vitesse pour **une seule coordonnée**.
```
GET /api/maxspeed/point?lat=48.87&lon=2.307
```
**Paramètres :**
| Nom | Type | Requis | Description |
|-----|------|--------|-------------|
| `lat` | float | oui | Latitude ∈ `[-90, 90]` |
| `lon` | float | oui | Longitude ∈ `[-180, 180]` |
**Réponse (`200`) :**
```json
{
"lat": 48.87,
"lon": 2.307,
"maxspeed": 50,
"unit": "km/h",
"hit": true
}
```
### 1.2 Tracé (route)
Interroge la vitesse le long d'un **parcours**. Utile pour colorer un itinéraire
selon la vitesse autorisée, ou estimer des temps de trajet.
```
GET /api/maxspeed/route?coords=2.307,48.87;2.404,48.66;2.589,48.66
```
**Paramètres :**
| Nom | Type | Requis | Description |
|-----|------|--------|-------------|
| `coords` | string | oui | Suite de `lon,lat` séparés par des `;` — **jusqu'à 5000 points** |
**Réponse (`200`) :**
```json
{
"points": [
{ "lon": 2.307, "lat": 48.87, "maxspeed": 50 },
{ "lon": 2.404, "lat": 48.66, "maxspeed": 50 },
{ "lon": 2.589, "lat": 48.66, "maxspeed": 80 }
]
}
```
> `maxspeed` est `null` quand aucun point de route n'est trouvé à moins de **40 mètres**
> (zone non couverte, champ, mer, etc.).
---
# 2. Format de réponse standard
- En-tête `Content-Type: application/json; charset=utf-8`.
- CORS ouvert : `Access-Control-Allow-Origin: *` (appelable depuis n'importe quel site).
- Cache HTTP : `public, max-age=300` (5 min) — les valeurs ne changent qu'avec une
mise à jour du jeu de données (hebdomadaire).
- Les réponses sont compactes (pas de champs redondants).
---
# 3. Erreurs & codes HTTP
| Code | Signification |
|------|---------------|
| `200` | Succès |
| `400` | Paramètre manquant / invalide (ex. `lat` hors bornes, `coords` mal formé) |
| `404` | Endpoint inconnu |
| `429` | Trop de requêtes (rate-limit dépassé) — voir ci-dessous |
| `503` | Données non disponibles (jeu de données non chargé) |
Format d'erreur :
```json
{
"error": "lat/lon invalides (lat ∈ [-90,90], lon ∈ [-180,180])"
}
```
---
# 4. Rate limiting
Chaque IP a un **quota global** sur le site : **200 requêtes / 10 s**. Une fois le
quota dépassé, l'API retourne `429` avec un en-tête `Retry-After` (secondes).
```
HTTP/1.1 429 Too Many Requests
Retry-After: 3
```
Ces limites protègent le service public contre les abus et les DoS (surcouche au
reverse-proxy qui absorbe les attaques). Pour un usage intensif légitime (batch sur
tout le continent), contacte-nous — on pourra te fournir un accès dédié.
---
# 5. Performance mesurée
Benchmarks **réels** effectués sur la machine de production (ARM 64, 4 cœurs, Bun) :
| Opération | Latence moyenne | Débit maximal (mono-cœur) |
|-----------|-----------------|---------------------------|
| Lookup point unique | **~0,10 ms** | ~9 900 req/s |
| Lookup par point d'un tracé | **~0,11 ms** | ~9 000 req/s |
Chiffres clés du jeu de données :
```
Points maxspeed : 90 518 792
Cellules (tuiles) : 5 104
Résolution : ~0,8 m (échantillonnage tous les 40 m le long des routes)
Taille grille : 370 Mo (compressé)
Source : OpenStreetMap (planet hebdo)
```
---
# 6. Exemples
Quelques exemples de valeurs réelles en Île-de-France (balayage sur l'axe A6 / A10) :
| Localisation (approx.) | Lon | Lat | Maxspeed |
|------------------------|-----|-----|----------|
| Zone urbaine (Boulevard) | `2.307` | `48.87` | **50 km/h** |
| Secteur péri-urbain (A6 sud) | `2.404` | `48.66` | **50 km/h** |
| Voie rapide (A10) | `2.439` | `48.60` | **110 km/h** |
| Autoroute dégagée (A6) | `2.589` | `48.66` | **80 km/h** |
### JavaScript (fetch)
```js
const res = await fetch(
"https://riricdev.tail5ea5cd.ts.net/api/maxspeed/point?lat=48.87&lon=2.307"
)
const data = await res.json()
console.log(data.maxspeed) // 50
```
### Python (urllib)
```python
import json, urllib.request
url = "https://riricdev.tail5ea5cd.ts.net/api/maxspeed/point?lat=48.87&lon=2.307"
with urllib.request.urlopen(url) as r:
data = json.load(r)
print(data["maxspeed"]) # 50
```
### curl
```bash
curl "https://riricdev.tail5ea5cd.ts.net/api/maxspeed/point?lat=48.87&lon=2.307"
# {"lat":48.87,"lon":2.307,"maxspeed":50,"unit":"km/h","hit":true}
```
---
# 7. Playground
Outil interactif : clique sur la carte ou saisis des coordonnées pour interroger
l'API en direct.
<div class="ms-playground">
<form id="ms-form">
<label>Latitude
<input type="number" step="any" id="ms-lat" value="48.87" />
</label>
<label>Longitude
<input type="number" step="any" id="ms-lon" value="2.307" />
</label>
<button type="submit">Interroger</button>
</form>
<pre id="ms-out" class="ms-out">—</pre>
</div>
<style>
.ms-playground { background: #0d1117; border: 1px solid #222; border-radius: 8px; padding: 16px; }
.ms-playground form { display: flex; gap: 12px; flex-wrap: wrap; align-items: end; }
.ms-playground label { display: flex; flex-direction: column; font-size: 12px; gap: 4px; }
.ms-playground input { background: #111; color: inherit; border: 1px solid #333; border-radius: 4px; padding: 6px 8px; width: 130px; }
.ms-playground button { background: #333; color: inherit; border: 0; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.ms-playground button:hover { background: #444; }
.ms-out { white-space: pre-wrap; margin-top: 12px; font-size: 13px; }
</style>
<script>
document.addEventListener("DOMContentLoaded", function () {
var form = document.getElementById("ms-form")
var out = document.getElementById("ms-out")
form.addEventListener("submit", function (e) {
e.preventDefault()
var lat = document.getElementById("ms-lat").value
var lon = document.getElementById("ms-lon").value
out.textContent = "Chargement…"
fetch("/api/maxspeed/point?lat=" + encodeURIComponent(lat) + "&lon=" + encodeURIComponent(lon))
.then(function (r) { return r.json() })
.then(function (d) { out.textContent = JSON.stringify(d, null, 2) })
.catch(function (err) { out.textContent = "Erreur: " + err })
})
})
</script>
---
_Documentation générée automatiquement. Dernière vérification de la section
Performance : septembre 2026._

74
src/maxspeed-router.ts Normal file
View file

@ -0,0 +1,74 @@
import { meta, snapPoint, snapRoute } from "./maxspeed-api.ts"
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Accept",
"Cache-Control": "public, max-age=300",
"X-Robots-Tag": "noindex, nofollow",
}
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json; charset=utf-8", ...CORS } })
}
function bad(msg: string) {
return json({ error: msg }, 400)
}
function parseLat(s: string | null): number | null {
if (!s) return null
const v = Number(s)
if (!Number.isFinite(v) || v < -90 || v > 90) return null
return v
}
function parseLon(s: string | null): number | null {
if (!s) return null
const v = Number(s)
if (!Number.isFinite(v) || v < -180 || v > 180) return null
return v
}
export async function handleMaxspeedRoute(req: Request, url: URL): Promise<Response | null> {
const p = url.pathname
if (!p.startsWith("/api/maxspeed")) return null
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
const base = "/api/maxspeed"
// Racine : métadonnées du jeu de données
if (p === base || p === base + "/") {
const m = meta()
return json(m ? { ok: true, ...m } : { ok: false, error: "données non disponibles" }, m ? 200 : 503)
}
// GET /api/maxspeed/point?lat=&lon=
if (p === base + "/point") {
const lat = parseLat(url.searchParams.get("lat"))
const lon = parseLon(url.searchParams.get("lon"))
if (lat === null || lon === null) return bad("lat/lon invalides (lat ∈ [-90,90], lon ∈ [-180,180])")
const speed = snapPoint(lon, lat)
return json({ lat, lon, maxspeed: speed, unit: speed === null ? null : "km/h", hit: speed !== null })
}
// GET /api/maxspeed/route?coords=lon,lat;lon,lat;...
if (p === base + "/route") {
const raw = url.searchParams.get("coords")
if (!raw) return bad("paramètre coords manquant")
const coords: [number, number][] = []
for (const part of raw.split(";")) {
const [a, b] = part.split(",")
const lon = parseLon(a ?? null)
const lat = parseLat(b ?? null)
if (lon === null || lat === null) return bad(`coordonnée invalide: "${part}"`)
coords.push([lon, lat])
}
if (coords.length === 0) return bad("aucune coordonnée")
if (coords.length > 5000) return bad("maximum 5000 points pour route")
const speeds = snapRoute(coords)
return json({ points: coords.map(([lon, lat], i) => ({ lon, lat, maxspeed: speeds[i] })) })
}
// Autres chemins sous /api/maxspeed -> 404
return json({ ok: false, error: "endpoint inconnu" }, 404)
}

1241
src/server.ts Normal file

File diff suppressed because it is too large Load diff

451
src/zmap-api.ts Normal file
View file

@ -0,0 +1,451 @@
import { snapSpeeds } from "./zmap-maxspeed.ts"
const OVERPASS_MIRRORS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://maps.mail.ru/osm/tools/overpass/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
]
const NOMINATIM = "https://nominatim.openstreetmap.org/search"
const OSRM = "https://router.project-osrm.org/route/v1/driving"
interface CacheEntry {
body: string
ts: number
contentType: string
}
const cache = new Map<string, CacheEntry>()
const CACHE_TTL_STATIONS = 10 * 60 * 1000
const CACHE_TTL_SEARCH = 24 * 60 * 60 * 1000
const CACHE_TTL_ROUTE = 12 * 60 * 60 * 1000
const clientLimits = new Map<string, number[]>()
const RATE_MAX = 30
const RATE_WINDOW = 60_000
const UA =
"ZMapProxy/1.0 (zektyc.duckdns.org; contact: riric65@protonmail.com; OSM proxy)"
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
function cacheKey(prefix: string, q: string): string {
return prefix + ":" + q
}
function getCached(key: string, ttl: number): CacheEntry | null {
const e = cache.get(key)
if (e && Date.now() - e.ts < ttl) return e
cache.delete(key)
return null
}
function setCached(key: string, body: string, contentType: string) {
cache.set(key, { body, ts: Date.now(), contentType })
}
async function queryOverpass(query: string): Promise<string> {
let lastErr: string = "aucun miroir joignable"
for (const mirror of OVERPASS_MIRRORS) {
try {
const res = await fetch(mirror, {
method: "POST",
headers: {
"User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "data=" + encodeURIComponent(query),
signal: AbortSignal.timeout(12_000),
})
if (!res.ok) {
lastErr = `HTTP ${res.status} sur ${mirror}`
continue
}
return await res.text()
} catch (e: any) {
lastErr = (e?.message ?? "connexion impossible") + " sur " + mirror
}
}
throw new Error(lastErr)
}
async function fetchText(url: string): Promise<string> {
const res = await fetch(url, { headers: { "User-Agent": UA } })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.text()
}
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const recent = (clientLimits.get(ip) ?? []).filter((t) => now - t < RATE_WINDOW)
if (recent.length >= RATE_MAX) {
clientLimits.set(ip, recent)
return false
}
recent.push(now)
clientLimits.set(ip, recent)
return true
}
function clientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for")
const first = fwd ? fwd.split(",")[0]?.trim() : undefined
return (
req.headers.get("cf-connecting-ip") ||
first ||
"unknown"
) as string
}
async function handleStations(req: Request, url: URL): Promise<Response> {
const south = url.searchParams.get("south")
const west = url.searchParams.get("west")
const north = url.searchParams.get("north")
const east = url.searchParams.get("east")
if (!south || !west || !north || !east) {
return json({ error: "bbox requise (south,west,north,east)" }, 400)
}
const slat = Number(south), wlon = Number(west), nlat = Number(north), elon = Number(east)
if ([slat, wlon, nlat, elon].some((n) => isNaN(n)) || slat >= nlat || wlon >= elon) {
return json({ error: "bbox invalide" }, 400)
}
const area = (nlat - slat) * (elon - wlon)
if (area > 40) return json({ error: "zone trop grande, zoomez" }, 400)
const q = `${slat},${wlon},${nlat},${elon}`
const key = cacheKey("stations", q)
const cached = getCached(key, CACHE_TTL_STATIONS)
if (cached) {
return new Response(cached.body, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
const query = `
[out:json][timeout:30];
(
node["amenity"="charging_station"](${q});
way["amenity"="charging_station"](${q});
);
out center tags;`
try {
const raw = await queryOverpass(query)
const parsed = parseOverpass(raw)
const out = JSON.stringify({ stations: parsed, count: parsed.length })
setCached(key, out, "application/json")
return new Response(out, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur inconnue") }, 502)
}
}
function parseOverpass(raw: string): unknown[] {
try {
const j = JSON.parse(raw)
const out: unknown[] = []
for (const el of j.elements || []) {
const tags = el.tags || {}
if (tags.amenity !== "charging_station") continue
const lat = el.lat ?? el.center?.lat
const lon = el.lon ?? el.center?.lon
if (lat == null || lon == null) continue
const connectors: { kind: string; count: number | null; powerKw: number | null }[] = []
for (const key of Object.keys(tags)) {
const m = /^socket:([^:]+)$/.exec(key)
if (!m || tags[key] === undefined || tags[key] === "no") continue
const kind = m[1]!
const powerRaw = tags[`socket:${kind}:output`] ?? tags[`socket:${kind}:voltage`]
const countRaw = tags[`capacity:${kind}`]
connectors.push({
kind,
powerKw: typeof powerRaw === "string" ? parseFloat(powerRaw.replace(/[^0-9.]/g, "")) || null : null,
count: typeof countRaw === "string" ? parseInt(countRaw, 10) || null : null,
})
}
if (connectors.length === 0 && tags.socket) {
for (const part of String(tags.socket).split(",")) {
const kind = part.trim().replace(/\s*x\d+.*$/i, "")
if (kind) connectors.push({ kind, count: null, powerKw: null })
}
}
const power = tags.power || tags.maxpower || tags.output || null
const capacity = tags.capacity ? parseInt(tags.capacity, 10) || null : null
out.push({
id: el.id,
lat,
lon,
name: tags.name || null,
operator: tags.operator || null,
brand: tags.brand || null,
power,
maxpower: tags.maxpower || null,
capacity,
connectors,
fee: tags.fee === "no" ? false : tags.fee === "yes" ? true : null,
parking_fee: tags["parking:fee"] === "no" ? false : tags["parking:fee"] === "yes" ? true : null,
access: tags.access || null,
opening_hours: tags.opening_hours || null,
phone: tags.phone || tags["contact:phone"] || null,
website: tags.website || tags["contact:website"] || null,
ref: tags.ref || null,
address: [tags["addr:street"], tags["addr:housenumber"], tags["addr:city"]]
.filter((x) => x)
.join(", ") || null,
})
}
return out
} catch {
return []
}
}
async function handleSearch(req: Request, url: URL): Promise<Response> {
const q = url.searchParams.get("q")
if (!q || q.trim().length < 2) return json({ error: "q requis" }, 400)
const key = cacheKey("search", q.toLowerCase().trim())
const cached = getCached(key, CACHE_TTL_SEARCH)
if (cached) {
return new Response(cached.body, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
const target = `${NOMINATIM}?q=${encodeURIComponent(q)}&format=jsonv2&limit=5&accept-language=fr`
try {
const body = await fetchText(target)
const parsed = JSON.parse(body).map((r: any) => ({
display_name: r.display_name,
lat: parseFloat(r.lat),
lon: parseFloat(r.lon),
type: r.type,
category: r.category,
}))
const out = JSON.stringify({ results: parsed })
setCached(key, out, "application/json")
return new Response(out, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur") }, 502)
}
}
async function handleRoute(req: Request, url: URL): Promise<Response> {
const from = url.searchParams.get("from")
const to = url.searchParams.get("to")
if (!from || !to) return json({ error: "from et to requis (lat,lon)" }, 400)
const [fromLat, fromLon] = from.split(",").map(Number) as [number, number]
const [toLat, toLon] = to.split(",").map(Number) as [number, number]
if (isNaN(fromLat) || isNaN(fromLon) || isNaN(toLat) || isNaN(toLon)) {
return json({ error: "coordonnées invalides" }, 400)
}
const key = cacheKey("route", `${from}|${to}`)
const cached = getCached(key, CACHE_TTL_ROUTE)
if (cached) {
return new Response(cached.body, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
const target = `${OSRM}/${fromLon},${fromLat};${toLon},${toLat}?overview=full&geometries=geojson&steps=true&annotations=true`
try {
const body = await fetchText(target)
const j = JSON.parse(body)
if (j.code !== "Ok" || !j.routes?.length) {
return json({ error: "itinéraire introuvable" }, 404)
}
const route = j.routes[0]
const steps: unknown[] = []
for (const leg of route.legs || []) {
for (const st of leg.steps || []) {
const man = st.maneuver || {}
steps.push({
type: man.type || null,
modifier: man.modifier || null,
name: st.name || "",
distance: st.distance || 0,
duration: st.duration || 0,
instruction: st.maneuver?.instruction || st.instruction || null,
location: man.location ? [man.location[1], man.location[0]] : null,
})
}
}
const out = JSON.stringify({
distance_km: (route.distance / 1000).toFixed(1),
duration_min: Math.round(route.duration / 60),
geometry: route.geometry,
summary: route.legs?.[0]?.summary || null,
steps,
speeds: snapSpeeds(route.geometry?.coordinates || []),
})
setCached(key, out, "application/json")
return new Response(out, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur") }, 502)
}
}
async function handleMaxSpeed(req: Request, url: URL): Promise<Response> {
const lat = url.searchParams.get("lat")
const lon = url.searchParams.get("lon")
if (!lat || !lon || isNaN(Number(lat)) || isNaN(Number(lon))) {
return json({ error: "lat et lon requis" }, 400)
}
const key = cacheKey("maxspeed", `${lat}|${lon}`)
const cached = getCached(key, 5 * 60 * 1000)
if (cached) {
return new Response(cached.body, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
const query = `
[out:json][timeout:15];
(
way["highway"]["maxspeed"](around:40,${lat},${lon});
);
out tags center;
out center;`
try {
const raw = await queryOverpass(query)
const j = JSON.parse(raw)
let best: number | null = null
let bestDist = Infinity
for (const el of j.elements || []) {
const ms = el.tags?.maxspeed
if (!ms) continue
const n = parseSpeed(ms)
if (n == null) continue
const c = el.center || el
if (c.lat == null) continue
const d = Math.hypot(c.lat - Number(lat), c.lon - Number(lon))
if (d < bestDist) {
bestDist = d
best = n
}
}
const out = JSON.stringify({ maxspeed: best })
setCached(key, out, "application/json")
return new Response(out, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur") }, 502)
}
}
async function handleMaxSpeeds(req: Request, url: URL): Promise<Response> {
const pointsRaw = url.searchParams.get("points")
if (!pointsRaw) return json({ error: "points requis (lat,lon;lat,lon;…)" }, 400)
const points: { lat: number; lon: number; key: string }[] = []
for (const p of pointsRaw.split(";")) {
const [la, lo] = p.split(",").map(Number)
if (la === undefined || lo === undefined || isNaN(la) || isNaN(lo)) continue
points.push({ lat: la, lon: lo, key: p })
}
if (points.length === 0) return json({ error: "points invalides" }, 400)
if (points.length > 150) points.length = 150
const key = cacheKey("maxspeeds", pointsRaw)
const cached = getCached(key, 10 * 60 * 1000)
if (cached) {
return new Response(cached.body, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
}
const arounds = points.map((p) => `way["highway"]["maxspeed"](around:40,${p.lat},${p.lon});`).join("\n")
const query = `[out:json][timeout:25];\n(\n${arounds}\n);\nout tags center;`
try {
const raw = await queryOverpass(query)
const j = JSON.parse(raw)
const result: Record<string, number> = {}
const ways: { lat: number; lon: number; kmh: number }[] = []
for (const el of j.elements || []) {
const ms = el.tags?.maxspeed
if (!ms) continue
const n = parseSpeed(ms)
if (n == null) continue
const c = el.center || el
if (c.lat == null || c.lon == null) continue
ways.push({ lat: c.lat, lon: c.lon, kmh: n })
}
for (const p of points) {
let best: number | null = null
let bestD = Infinity
for (const w of ways) {
const d = Math.hypot(w.lat - p.lat, w.lon - p.lon)
if (d < bestD) {
bestD = d
best = w.kmh
}
}
if (best != null && bestD < 0.1) result[p.key] = best
}
const out = JSON.stringify({ limits: result })
setCached(key, out, "application/json")
return new Response(out, {
headers: { "Content-Type": "application/json; charset=utf-8" },
})
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur") }, 502)
}
}
function parseSpeed(v: string): number | null {
const m = /(\d+)\s*(mph)?/.exec(v)
if (!m) return null
return m[2] === "mph" ? Math.round(parseInt(m[1]!) * 1.609) : +(m[1]!)
}
export async function handleZMapRoute(req: Request, url: URL): Promise<Response | null> {
if (!url.pathname.startsWith("/api/zmap/")) return null
const ip = clientIp(req)
if (!checkRateLimit(ip)) {
return json({ error: "trop de requêtes", retryAfter: 60 }, 429)
}
const p = url.pathname.replace("/api/zmap/", "")
try {
switch (p) {
case "stations":
return await handleStations(req, url)
case "search":
return await handleSearch(req, url)
case "route":
return await handleRoute(req, url)
case "maxspeed":
return await handleMaxSpeed(req, url)
case "maxspeeds":
return await handleMaxSpeeds(req, url)
case "ping":
return json({ ok: true, v: "0.1.0" })
default:
return json({ error: "endpoint inconnu" }, 404)
}
} catch (e: any) {
return json({ error: "proxy: " + (e?.message ?? "erreur") }, 500)
}
}
export function clearZMapCache() {
cache.clear()
}

109
src/zmap-maxspeed.ts Normal file
View file

@ -0,0 +1,109 @@
import { readFileSync, existsSync } from "node:fs"
import { gunzipSync } from "node:zlib"
const CELL = 0.5
const SUB = 32
const MAX_DIST = 40.0
let GRID_DIR = process.env.ZMAP_MAXSPEED_DIR || ""
let index: Record<string, { x: number; y: number; count: number }> | null = null
const cells = new Map<string, { off: Int32Array; data: Buffer }>()
export function setMaxspeedDir(dir: string) {
GRID_DIR = dir
index = null
cells.clear()
}
function loadIndex() {
if (!GRID_DIR) return null
if (!index) {
const f = `${GRID_DIR}/index.json`
if (!existsSync(f)) return null
index = JSON.parse(readFileSync(f, "utf8"))
}
return index
}
function loadCell(x: number, y: number): { off: Int32Array; data: Buffer } | null {
const key = `${x}_${y}`
const hit = cells.get(key)
if (hit) return hit
if (!GRID_DIR) return null
const f = `${GRID_DIR}/${key}.bin.gz`
if (!existsSync(f)) return null
try {
const raw = gunzipSync(readFileSync(f))
const off = new Int32Array(raw.buffer, raw.byteOffset + 4, SUB * SUB)
const data = raw.subarray(4 + SUB * SUB * 4)
const c = { off, data }
cells.set(key, c)
if (cells.size > 200) cells.delete(cells.keys().next().value!)
return c
} catch {
return null
}
}
function snapCell(cell: { off: Int32Array; data: Buffer }, cx: number, cy: number, lat: number, lon: number): number | null {
const lonq = Math.round(((lon - cx * CELL) / CELL) * 65535)
const latq = Math.round(((lat - cy * CELL) / CELL) * 65535)
if (lonq < 0 || lonq > 65535 || latq < 0 || latq > 65535) return null
const sx = Math.min(SUB - 1, Math.floor((lonq * SUB) / 65536))
const sy = Math.min(SUB - 1, Math.floor((latq * SUB) / 65536))
let best: number | null = null
let bestD2 = Infinity
const cosLat = Math.cos((lat * Math.PI) / 180)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const sxx = sx + dx
const syy = sy + dy
if (sxx < 0 || syy < 0 || sxx >= SUB || syy >= SUB) continue
const sub = sxx + syy * SUB
const start = cell.off[sub]!
const end = sub + 1 < SUB * SUB ? cell.off[sub + 1]! : (cell.data.length / 5)
for (let i = start; i < end; i++) {
const base = i * 5
const plon = cx * CELL + (cell.data.readUInt16LE(base) / 65535) * CELL
const plat = cy * CELL + (cell.data.readUInt16LE(base + 2) / 65535) * CELL
const dlonp = (lon - plon) * 111320 * cosLat
const dlatp = (lat - plat) * 110540
const d2 = dlonp * dlonp + dlatp * dlatp
if (d2 < bestD2) {
bestD2 = d2
best = cell.data.readUInt8(base + 4)
}
}
}
}
if (bestD2 > MAX_DIST * MAX_DIST) return null
return best
}
export function snapSpeeds(coords: [number, number][]): (number | null)[] {
const idx = loadIndex()
if (!idx) return coords.map(() => null)
const byCell = new Map<string, { x: number; y: number; indices: number[] }>()
for (let i = 0; i < coords.length; i++) {
const [lon, lat] = coords[i]!
const x = Math.floor(lon / CELL)
const y = Math.floor(lat / CELL)
const k = `${x}_${y}`
let entry = byCell.get(k)
if (!entry) {
entry = { x, y, indices: [] }
byCell.set(k, entry)
}
entry.indices.push(i)
}
const out: (number | null)[] = new Array(coords.length).fill(null)
for (const { x, y, indices } of byCell.values()) {
const cell = loadCell(x, y)
if (!cell) continue
for (const i of indices) {
const [lon, lat] = coords[i]!
out[i] = snapCell(cell, x, y, lat, lon)
}
}
return out
}

52
src/zmap-ws.ts Normal file
View file

@ -0,0 +1,52 @@
import type { ServerWebSocket } from "bun"
const clients = new Set<ServerWebSocket<unknown>>()
export function zmapWsHandler(ws: ServerWebSocket<unknown>) {
clients.add(ws)
ws.send(JSON.stringify({ type: "hello", v: "0.3.0", ts: Date.now() }))
}
export function zmapWsMessage(ws: ServerWebSocket<unknown>, raw: string | Buffer) {
const msg = raw.toString()
try {
const j = JSON.parse(msg)
if (j?.type === "ping") {
ws.send(JSON.stringify({ type: "pong", ts: Date.now() }))
return
}
ws.send(JSON.stringify({ type: "ack", ts: Date.now() }))
} catch {
ws.send(JSON.stringify({ type: "error", error: "json invalide" }))
}
}
export function zmapWsClose(ws: ServerWebSocket<unknown>) {
clients.delete(ws)
}
export function zmapBroadcast(payload: unknown) {
const data = JSON.stringify(payload)
for (const ws of clients) {
try {
ws.send(data)
} catch {
}
}
}
export function zmapWsKeepAlive() {
setInterval(() => {
const now = Date.now()
for (const ws of clients) {
try {
ws.send(JSON.stringify({ type: "ping", ts: now }))
} catch {
}
}
}, 25_000)
}
export function zmapWsCount(): number {
return clients.size
}