# API.md — Backend REST Node pour la carte des aires de pique-nique

Ce document décrit comment construire l'**API Node** qui sert les données à
l'app mobile (`APP.md`) et, à terme, pourrait remplacer le rendu inline de
`index.php`.

Elle conserve **strictement le modèle actuel** : pas de base de données, les
données vivent en **fichiers JSON sur disque** dans `data/` (cf. `CLAUDE.md` :
« `data/` est la base de données »). Un **grabber** (portage de `lieux.php`)
tourne **à intervalle régulier** pour importer/rafraîchir les données depuis
`pique-nique.info`.

**Stack retenue : Express + TypeScript, planification via `node-cron` in-process.**

---

## 1. Stack technique

| Domaine | Choix | Rôle |
|---|---|---|
| Runtime | Node.js ≥ 18 (fetch natif) | |
| Langage | TypeScript (strict) | |
| Serveur HTTP | **Express** | Routes REST |
| Validation | **zod** | Valide params/bodies (notes, bbox, eq…) |
| Scraping HTML | **cheerio** | Remplace `DOMDocument`/`DOMXPath` de PHP |
| Planification | **node-cron** (in-process) | Lance le grabber à intervalle régulier |
| Sécurité | `cors`, `helmet`, `express-rate-limit` | |
| Logs | `pino` (+ `pino-http`) | |
| HTTP sortant | `fetch` natif (ou `undici`) | Grabber + proxys géocodage/routing |

> **Un seul process** à déployer : Express sert l'API **et** héberge le cron du
> grabber. Un **lock fichier** évite qu'un grab ne se chevauche avec le suivant.

---

## 2. Modèle de données (JSON-sur-disque, inchangé)

```
data/
  departements/{NN}.json   # cache HTML brut d'une page département (AireRecherche)
  lieux/{id}.json          # détail d'une aire (produit par le grabber)
  coords/{id}.json         # override manuel des coordonnées GPS
  notes/{id}.json          # tableau d'avis [{date, note}]
```

### 2.1 `data/lieux/{id}.json` (écrit par le grabber)
```jsonc
{
  "id": "1",
  "nom": "Jardins de la Corderie Royale",
  "description": "Quatre tables sont installées...",
  "latitude": "45.941205",         // string brute
  "longitude": "-0.954939",
  "photos": ["https://www.pique-nique.info/photos/corderie.jpg", "..."],
  "equipements": {                 // map nom → bool (inclut inactifs)
    "Table & banc": true, "Wc": true, "Parking": true, "Poubelle": true,
    "Barbecue": false, "Camping-car": true, "Point eau": true,
    "Aire de jeux": true, "Acces handicape": true
  }
}
```

### 2.2 `data/coords/{id}.json` (override manuel)
```jsonc
{ "id": "1", "latitude": "45.941205", "longitude": "-0.954939" }
```

### 2.3 `data/notes/{id}.json`
```jsonc
[ { "date": "2026-05-10T07:04:28+00:00", "note": 4 } ]
```

### 2.4 Liste de référence des équipements (CONTRAT)
Ordre + emojis identiques à `index.php` / `APP.md` §3.3 :

```ts
export const EQUIPEMENTS = [
  { key: "Table & banc",   emoji: "🪑" },
  { key: "Wc",             emoji: "🚻" },
  { key: "Parking",        emoji: "🅿️" },
  { key: "Poubelle",       emoji: "🗑️" },
  { key: "Barbecue",       emoji: "🔥" },
  { key: "Camping-car",    emoji: "🚐" },
  { key: "Point eau",      emoji: "🚰" },
  { key: "Aire de jeux",   emoji: "🎠" },
  { key: "Acces handicape",emoji: "♿" },
] as const;
```

---

## 3. Arborescence du projet

```
api/
  src/
    server.ts            # bootstrap Express + démarrage cron
    config.ts            # env (PORT, DATA_DIR, CRON_SCHEDULE, ...)
    routes/
      lieux.ts           # GET /api/lieux, GET /api/lieux/:id
      notes.ts           # GET/POST /api/lieux/:id/notes
      equipements.ts     # GET /api/equipements
      proxy.ts           # (option) GET /api/geocode, /api/route
    services/
      store.ts           # lecture/normalisation des JSON + cache mémoire
      notes.ts           # lecture/écriture data/notes
    grabber/
      index.ts           # orchestration 2 phases (port de lieux.php)
      scrape.ts          # scrapeAireDetail() via cheerio
      departements.ts    # liste 01..976 + extraction des ids
      lock.ts            # lock fichier anti-chevauchement
    lib/
      paths.ts           # chemins data/*
      normaliser.ts
    cron.ts              # node-cron → grabber
  data/                  # = la base (monté en volume en prod)
  package.json
  tsconfig.json
```

---

## 4. Routes REST

Toutes en `application/json; charset=utf-8`. Préfixe `/api`. CORS ouvert à l'app.

### 4.1 `GET /api/lieux`
Liste normalisée (équivalent du dump de `index.php`).

Query params (tous optionnels) :
| Param | Type | Effet |
|---|---|---|
| `bbox` | `minLon,minLat,maxLon,maxLat` | Limite à l'emprise |
| `eq` | répétable (`?eq=Wc&eq=Parking`) | Lieux ayant **TOUS** ces équipements (AND) |
| `note_min` / `note_max` | 1–5 | Filtre par note moyenne |
| `updated_since` | ISO 8601 | Sync incrémentale (mtime du fichier lieu) |

Réponse :
```jsonc
{ "count": 6045, "lieux": [ /* objets format liste */ ] }
```
Objet « format liste » :
```jsonc
{
  "id": "1", "nom": "...", "lat": 45.941205, "lon": -0.954939,
  "photos": ["..."],
  "equipements": ["Acces handicape", "Camping-car", "Parking", "..."], // actifs, triés, sans "Logo Pinterest"
  "note_avg": 4.0, "note_count": 1
}
```

### 4.2 `GET /api/lieux/:id`
Détail (ajoute `description`) :
```jsonc
{
  "id":"1","nom":"...","description":"...","lat":45.94,"lon":-0.95,
  "photos":["..."],"equipements":["Parking","Wc","..."],
  "note_avg":4.0,"note_count":1
}
```
`404` si l'`id` n'existe pas.

### 4.3 `GET /api/lieux/:id/notes`
Équivalent `note.php?id=` (GET) :
```jsonc
{ "id": "1", "count": 1, "average": 4.0 }
```

### 4.4 `POST /api/lieux/:id/notes`
Équivalent `note.php` (POST). Body : `{ "note": 4 }` (entier 1–5).
- `400` si `id` non numérique ou `note` hors [1,5].
- Ajoute `{ date: ISO, note }` dans `data/notes/{id}.json`, renvoie la moyenne **mise à jour** :
```jsonc
{ "id": "1", "count": 2, "average": 4.5 }
```
- Invalide l'entrée en cache mémoire du lieu (`note_avg`/`note_count`).

### 4.5 `GET /api/equipements`
Renvoie `EQUIPEMENTS` (§2.4).

### 4.6 (Option) Proxys géocodage / routing
Pour respecter les quotas Photon/Nominatim/OSRM et ajouter du cache (cf. `APP.md` §4.6) :
- `GET /api/geocode?q=...&kind=auto|exact` → Photon (autocomplete) ou Nominatim (résolution).
- `GET /api/route?profile=car|bike&coords=lon,lat;lon,lat[;...]` → OSRM, applique le `durationFactor` (car 0.9 / bike 1.0) et le fallback d'endpoints (`APP.md` §6.2).

---

## 5. Service `store` — lecture & normalisation (cœur porté de `index.php`)

La normalisation aujourd'hui faite par `index.php` au runtime est centralisée ici.
**Recommandation perf** : construire un **index mémoire** au démarrage + après
chaque grab (6045 fichiers → ne pas relire le disque à chaque requête). Les notes
restent relues à la volée (ou invalidées après POST).

```ts
// services/store.ts (extrait)
import fs from "node:fs";
import path from "node:path";
import { EQUIPEMENTS } from "../constants/equipements";

const LIEUX = path.join(DATA_DIR, "lieux");
const COORDS = path.join(DATA_DIR, "coords");
const NOTES = path.join(DATA_DIR, "notes");
const EQ_KEYS = EQUIPEMENTS.map(e => e.key);

function readNotes(id: string) {
  const f = path.join(NOTES, `${id}.json`);
  if (!fs.existsSync(f)) return { count: 0, average: null as number | null };
  const arr = JSON.parse(fs.readFileSync(f, "utf8")) as { note: number }[];
  if (!arr.length) return { count: 0, average: null };
  const avg = Math.round((arr.reduce((s, e) => s + e.note, 0) / arr.length) * 100) / 100;
  return { count: arr.length, average: avg };
}

export function buildIndex() {
  const out = [];
  for (const file of fs.readdirSync(LIEUX)) {
    if (!file.endsWith(".json")) continue;
    const data = JSON.parse(fs.readFileSync(path.join(LIEUX, file), "utf8"));
    if (!data?.latitude || !data?.longitude) continue;        // skip incomplets

    // override coords
    const cf = path.join(COORDS, `${data.id}.json`);
    if (fs.existsSync(cf)) {
      const o = JSON.parse(fs.readFileSync(cf, "utf8"));
      if (o?.latitude && o?.longitude) { data.latitude = o.latitude; data.longitude = o.longitude; }
    }

    // forcer les 9 clés, ne garder que les actives, exclure "Logo Pinterest", trier
    const eq = { ...data.equipements };
    for (const k of EQ_KEYS) if (!(k in eq)) eq[k] = false;
    const actifs = Object.keys(eq).filter(k => eq[k]).filter(k => k !== "Logo Pinterest").sort();

    const { count, average } = readNotes(data.id);
    out.push({
      id: data.id, nom: data.nom ?? "",
      lat: parseFloat(data.latitude), lon: parseFloat(data.longitude),
      description: data.description ?? "",     // exposé seulement par /:id
      photos: data.photos ?? [], equipements: actifs,
      note_avg: average, note_count: count,
    });
  }
  return out;
}
```

> Règles à respecter à l'identique : skip si lat/lon absents, override coords,
> forçage des 9 clés, exclusion `Logo Pinterest`, tri alphabétique, moyenne
> arrondie à 2 décimales, `note_avg = null` si aucun avis.

Notes en écriture (port de `note.php`) :
```ts
// services/notes.ts (POST)
const file = path.join(NOTES, `${id}.json`);
const entries = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
entries.push({ date: new Date().toISOString(), note });   // note ∈ [1,5] déjà validé
fs.writeFileSync(file, JSON.stringify(entries, null, 2));
```

---

## 6. Le grabber (portage de `lieux.php`)

Deux phases, **idempotent et incrémental** : la **présence** d'un fichier
`data/lieux/{id}.json` fait sauter le téléchargement. Throttle **200 ms** entre
deux fiches (comme `usleep(200000)`).

### 6.1 Liste des départements
Identique à `get_departements()` : `01..19`, `21..95`, `2A`, `2B`, `971`, `972`,
`973`, `974`, `976` (le `20` est exclu — Corse via 2A/2B).

```ts
export function getDepartements(): string[] {
  const pad = (n: number) => String(n).padStart(2, "0");
  const range = (a: number, b: number) => Array.from({length: b - a + 1}, (_, i) => pad(a + i));
  return [...range(1, 19), ...range(21, 95), "2A", "2B", "971", "972", "973", "974", "976"];
}
```

### 6.2 Phase 1 — collecte des ids
Pour chaque département : récupérer la page de recherche, en cache disque.
> **Différence avec `lieux.php`** : le PHP met la page département en cache « pour
> toujours ». Pour qu'un grab régulier **découvre les nouvelles aires**, on
> applique un **TTL** (ex. 7 jours) sur `data/departements/{NN}.json` : au-delà,
> on refetch. Les fiches lieux, elles, restent en cache permanent.

```ts
const URL_DEP = (dep: string) => `https://www.pique-nique.info/AireRecherche.php?departement=${dep}`;

async function collectIds(dep: string, ttlMs: number): Promise<string[]> {
  const f = path.join(DATA_DIR, "departements", `${dep}.json`);
  let html: string | null = null;
  const fresh = fs.existsSync(f) && (Date.now() - fs.statSync(f).mtimeMs) < ttlMs;
  if (fresh) html = fs.readFileSync(f, "utf8");
  else {
    html = await fetchText(URL_DEP(dep));
    if (html) fs.writeFileSync(f, html);
  }
  if (!html) return [];
  return [...html.matchAll(/AireDetail\.php\?lieu=(\d+)/g)].map(m => m[1]); // regex identique
}
```

### 6.3 Phase 2 — scraping des fiches (`scrapeAireDetail`, port cheerio de `scrape_aire_detail`)

```ts
import * as cheerio from "cheerio";

const EQ_KNOWN = ["Table & banc","Wc","Parking","Poubelle","Barbecue",
                  "Camping-car","Point eau","Aire de jeux","Acces handicape"];

export async function scrapeAireDetail(id: string) {
  const html = await fetchText(`https://www.pique-nique.info/AireDetail.php?lieu=${id}`);
  if (!html) return null;                                   // échec → skip (comme le PHP)
  const $ = cheerio.load(html);

  // Latitude/Longitude : <strong>Latitude</strong> suivi d'un nœud texte "...: 45.94"
  const afterStrong = (label: string) => {
    const el = $("strong").filter((_, e) => $(e).text().includes(label)).first();
    const txt = el.length ? (el[0].nextSibling as any)?.data ?? "" : "";
    return txt.replace(":", "").trim();
  };

  // Photos : <img src*="photos/">, dédupliquées
  const photos: string[] = [];
  $('img[src*="photos/"]').each((_, img) => {
    const src = ($(img).attr("src") || "").trim();
    if (src && !photos.includes(src)) photos.push(src);
  });

  // Équipements : après le <h4>Equipements</h4>, <img alt=... src=...>
  //   actif = src NE contient PAS "off". On ne garde que les alt connus.
  const equipements: Record<string, boolean> = {};
  const h4Eq = $("h4").filter((_, e) => $(e).text().includes("Equipements")).first();
  h4Eq.nextAll().find("img").addBack("img").each((_, img) => {
    const alt = ($(img).attr("alt") || "").trim();
    const src = $(img).attr("src") || "";
    if (!alt || !EQ_KNOWN.includes(alt)) return;
    equipements[alt] = !src.includes("off");
  });

  return {
    id,
    nom: $("h2").first().text().trim(),
    description: $("h4").filter((_, e) => $(e).text().includes("Description"))
                        .first().nextAll("p").first().text().trim(),
    latitude: afterStrong("Latitude"),
    longitude: afterStrong("Longitude"),
    photos,
    equipements,
  };
}
```
> ⚠️ Le scraping est **fragile par nature** (cf. `CLAUDE.md`) : tout changement de
> markup en amont casse silencieusement l'extraction. Un fetch raté renvoie `null`
> et la fiche est ignorée (jamais d'écriture de fichier partiel/corrompu).

### 6.4 Orchestration
```ts
export async function runGrab({ depTtlMs = 7*24*3600*1000, throttleMs = 200 } = {}) {
  const deps = getDepartements();
  const ids = new Set<string>();
  for (const dep of deps) (await collectIds(dep, depTtlMs)).forEach(i => ids.add(i));

  for (const id of ids) {
    const f = path.join(DATA_DIR, "lieux", `${id}.json`);
    if (fs.existsSync(f)) continue;                         // incrémental : déjà connu
    const data = await scrapeAireDetail(id);
    if (data) fs.writeFileSync(f, JSON.stringify(data, null, 2));
    await sleep(throttleMs);                                // throttle 200 ms
  }
  rebuildIndex();                                           // recharge le cache mémoire de l'API
}
```

### 6.5 Robustesse
- `fetchText` : timeout (ex. 15 s), 1–2 retries, **User-Agent explicite**, renvoie `null` à l'échec.
- Écriture **atomique** : écrire dans `*.tmp` puis `rename` pour éviter un JSON tronqué si le process est tué.
- Le grab peut être long (6045 fiches × 200 ms ≈ 20 min sur un premier remplissage) → voir le lock §7.

---

## 7. Planification (node-cron in-process) + lock

```ts
// cron.ts
import cron from "node-cron";
import { withLock } from "./grabber/lock";
import { runGrab } from "./grabber";

export function startCron() {
  // ex. tous les jours à 04:00 (configurable via CRON_SCHEDULE)
  cron.schedule(process.env.CRON_SCHEDULE ?? "0 4 * * *", () => {
    withLock(async () => { await runGrab(); })
      .catch(err => logger.error({ err }, "grab failed"));
  });
}
```

**Lock fichier** (anti-chevauchement, indispensable avec node-cron in-process) :
```ts
// grabber/lock.ts
const LOCK = path.join(DATA_DIR, ".grab.lock");
export async function withLock(fn: () => Promise<void>) {
  if (fs.existsSync(LOCK)) { logger.warn("grab déjà en cours, skip"); return; }
  fs.writeFileSync(LOCK, String(process.pid));
  try { await fn(); } finally { fs.rmSync(LOCK, { force: true }); }
}
```
> Au démarrage du serveur, nettoyer un lock orphelin (process tué) si son `mtime`
> dépasse une durée raisonnable (ex. > 1 h). Possibilité d'exposer un
> `POST /admin/grab` protégé pour déclencher un grab manuel.

---

## 8. Bootstrap serveur

```ts
// server.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { buildIndex } from "./services/store";
import { startCron } from "./cron";

const app = express();
app.use(helmet());
app.use(cors());                 // restreindre origin en prod si besoin
app.use(express.json());
app.use("/api/lieux", rateLimit({ windowMs: 60_000, max: 120 }));  // protège le POST notes

buildIndex();                    // index mémoire initial
app.use("/api/lieux", lieuxRouter);
app.use("/api/lieux", notesRouter);   // /:id/notes
app.use("/api/equipements", equipementsRouter);
// app.use("/api", proxyRouter);      // option géocodage/routing

app.listen(Number(process.env.PORT ?? 3000), () => {
  startCron();                   // démarre la planification du grabber
});
```

---

## 9. Configuration (env)

| Variable | Défaut | Rôle |
|---|---|---|
| `PORT` | `3000` | Port HTTP |
| `DATA_DIR` | `./data` | Racine des JSON (monter un volume en prod) |
| `CRON_SCHEDULE` | `0 4 * * *` | Cron du grabber (5 champs) |
| `DEP_TTL_DAYS` | `7` | Fraîcheur du cache des pages département |
| `GRAB_THROTTLE_MS` | `200` | Délai entre 2 fiches |
| `CORS_ORIGIN` | `*` | Origine autorisée |
| `ADMIN_TOKEN` | — | (option) protège `POST /admin/grab` |

---

## 10. Setup & commandes

```bash
mkdir api && cd api && npm init -y
npm i express cors helmet express-rate-limit zod cheerio node-cron pino pino-http
npm i -D typescript tsx @types/express @types/node @types/cors

npx tsc --init               # strict: true
# scripts package.json :
#   "dev":   "tsx watch src/server.ts"
#   "build": "tsc -p ."
#   "start": "node dist/server.js"
#   "grab":  "tsx src/grabber/index.ts"   # grab manuel (one-shot)

npm run dev                  # API + cron en dev
npm run grab                 # forcer un import immédiat
```

---

## 11. Déploiement & exploitation

- **Volume persistant** monté sur `DATA_DIR` (les JSON = la base ; ne pas les perdre).
- Premier remplissage long (~20 min) : lancer `npm run grab` une fois avant
  d'ouvrir le trafic, puis laisser le cron entretenir l'incrémental.
- Healthcheck : `GET /api/equipements` (rapide, sans I/O lourde).
- Sauvegarde : versionner/snapshotter `data/notes` et `data/coords` (contenu
  produit par les utilisateurs / corrigé à la main, non régénérable par le grab).
- Logs : tracer le résumé de chaque grab (nouveaux lieux, échecs, durée).

---

## 12. Écarts assumés vs le code PHP actuel

- **Index mémoire** au lieu d'un glob disque à chaque requête (perf pour 6045 lieux).
- **TTL sur les pages département** pour découvrir les nouvelles aires (le PHP les
  cachait indéfiniment).
- **Écriture atomique** des fiches (`.tmp` + `rename`).
- **Lock fichier** car le grabber partage le process de l'API (node-cron).
- Normalisation, throttle 200 ms, regex d'extraction, liste des équipements et
  logique des notes : **identiques** à `index.php` / `lieux.php` / `note.php`.
```
