@nosserb/prmt 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const c = (label, none, singular, plural, filter, notBefore) => ({ label, none, singular, plural, filter, ...(notBefore ? { notBefore } : {}) });
|
|
2
|
+
export const categoryByLabel = (label) => CATEGORIES.find((k) => k.label === label) ?? null;
|
|
3
|
+
export const CATEGORIES = [
|
|
4
|
+
c('hôtels', 'Aucun hôtel trouvé', ['hotel'], ['hotels'], '["tourism"="hotel"]'),
|
|
5
|
+
c('auberges de jeunesse', 'Aucune auberge de jeunesse trouvée', ['auberge de jeunesse'], ['auberges de jeunesse'], '["tourism"="hostel"]'),
|
|
6
|
+
c('campings', 'Aucun camping trouvé', ['camping'], ['campings'], '["tourism"="camp_site"]'),
|
|
7
|
+
c('restaurants', 'Aucun restaurant trouvé', ['restaurant', 'resto'], ['restaurants', 'restos'], '["amenity"="restaurant"]'),
|
|
8
|
+
c('bars', 'Aucun bar trouvé', ['bar', 'pub'], ['bars', 'pubs'], '["amenity"~"^(bar|pub)$"]'),
|
|
9
|
+
c('cafés', 'Aucun café trouvé', ['cafe'], ['cafes'], '["amenity"="cafe"]'),
|
|
10
|
+
c('boulangeries', 'Aucune boulangerie trouvée', ['boulangerie'], ['boulangeries'], '["shop"="bakery"]'),
|
|
11
|
+
c('supermarchés', 'Aucun supermarché trouvé', ['supermarche'], ['supermarches'], '["shop"="supermarket"]'),
|
|
12
|
+
c('pharmacies', 'Aucune pharmacie trouvée', ['pharmacie'], ['pharmacies'], '["amenity"="pharmacy"]'),
|
|
13
|
+
c('hôpitaux', 'Aucun hôpital trouvé', ['hopital'], ['hopitaux'], '["amenity"="hospital"]'),
|
|
14
|
+
c('cliniques', 'Aucune clinique trouvée', ['clinique'], ['cliniques'], '["amenity"="clinic"]'),
|
|
15
|
+
c('médecins', 'Aucun médecin trouvé', ['medecin'], ['medecins'], '["amenity"="doctors"]'),
|
|
16
|
+
c('dentistes', 'Aucun dentiste trouvé', ['dentiste'], ['dentistes'], '["amenity"="dentist"]'),
|
|
17
|
+
c('banques', 'Aucune banque trouvée', ['banque'], ['banques'], '["amenity"="bank"]'),
|
|
18
|
+
c('distributeurs de billets', 'Aucun distributeur de billets trouvé', ['distributeur de billets', 'distributeur'], ['distributeurs de billets', 'distributeurs'], '["amenity"="atm"]'),
|
|
19
|
+
c('stations-service', 'Aucune station-service trouvée', ['station service', 'station essence'], ['stations service', 'stations essence'], '["amenity"="fuel"]'),
|
|
20
|
+
c('bornes de recharge', 'Aucune borne de recharge trouvée', ['borne de recharge'], ['bornes de recharge'], '["amenity"="charging_station"]'),
|
|
21
|
+
c('parkings', 'Aucun parking trouvé', ['parking'], ['parkings'], '["amenity"="parking"]'),
|
|
22
|
+
c('gares', 'Aucune gare trouvée', ['gare'], ['gares'], '["railway"="station"]'),
|
|
23
|
+
c('musées', 'Aucun musée trouvé', ['musee'], ['musees'], '["tourism"="museum"]'),
|
|
24
|
+
c('cinémas', 'Aucun cinéma trouvé', ['cinema'], ['cinemas'], '["amenity"="cinema"]'),
|
|
25
|
+
c('théâtres', 'Aucun théâtre trouvé', ['theatre'], ['theatres'], '["amenity"="theatre"]'),
|
|
26
|
+
c('bibliothèques', 'Aucune bibliothèque trouvée', ['bibliotheque'], ['bibliotheques'], '["amenity"="library"]'),
|
|
27
|
+
c('écoles', 'Aucune école trouvée', ['ecole'], ['ecoles'], '["amenity"="school"]'),
|
|
28
|
+
c('universités', 'Aucune université trouvée', ['universite'], ['universites'], '["amenity"="university"]'),
|
|
29
|
+
c('parcs', 'Aucun parc trouvé', ['parc'], ['parcs'], '["leisure"="park"]', ['informatique', 'informatiques', 'de machines', 'de serveurs', 'de postes', 'reseau', 'materiel']),
|
|
30
|
+
c('salles de sport', 'Aucune salle de sport trouvée', ['salle de sport'], ['salles de sport'], '["leisure"="fitness_centre"]'),
|
|
31
|
+
c('commissariats', 'Aucun commissariat trouvé', ['commissariat', 'gendarmerie'], ['commissariats', 'gendarmeries'], '["amenity"="police"]'),
|
|
32
|
+
c('bureaux de poste', 'Aucun bureau de poste trouvé', ['bureau de poste'], ['bureaux de poste'], '["amenity"="post_office"]'),
|
|
33
|
+
c('mairies', 'Aucune mairie trouvée', ['mairie'], ['mairies'], '["amenity"="townhall"]'),
|
|
34
|
+
c('églises', 'Aucune église trouvée', ['eglise'], ['eglises'], '["amenity"="place_of_worship"]["religion"="christian"]'),
|
|
35
|
+
c('laveries', 'Aucune laverie trouvée', ['laverie'], ['laveries'], '["shop"="laundry"]'),
|
|
36
|
+
];
|
|
37
|
+
/** Sans accents ni majuscules, apostrophes et tirets changés en espaces : « l'hôtel » donne « l hotel ». */
|
|
38
|
+
const norm = (s) => s
|
|
39
|
+
.normalize('NFD')
|
|
40
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
const tokens = (s) => (norm(s) ? norm(s).split(' ') : []);
|
|
45
|
+
const DEFINITE = new Set(['le', 'la', 'l']);
|
|
46
|
+
function find(words) {
|
|
47
|
+
for (let i = 0; i < words.length; i++) {
|
|
48
|
+
for (const category of CATEGORIES) {
|
|
49
|
+
// Les formes longues d'abord : « distributeurs de billets » avant « distributeurs »
|
|
50
|
+
const forms = [...category.plural.map((f) => ({ f, plural: true })), ...category.singular.map((f) => ({ f, plural: false }))].sort((a, b) => b.f.length - a.f.length);
|
|
51
|
+
for (const { f, plural } of forms) {
|
|
52
|
+
const parts = f.split(' ');
|
|
53
|
+
if (parts.every((p, k) => words[i + k] === p)) {
|
|
54
|
+
const end = i + parts.length;
|
|
55
|
+
const next = words.slice(end, end + 2).join(' ');
|
|
56
|
+
if (category.notBefore?.some((n) => next === n || next.startsWith(`${n} `)))
|
|
57
|
+
continue;
|
|
58
|
+
return { category, plural, start: i, end };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** Le mot qui suit la catégorie dans le texte d'origine commence par une majuscule : un nom propre (« hôtel Lutetia »). */
|
|
66
|
+
function namedAfter(text, found) {
|
|
67
|
+
const original = text.replace(/[’']/g, ' ').split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
68
|
+
const next = original[found.end];
|
|
69
|
+
return next !== undefined && /^\p{Lu}/u.test(next);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* La catégorie d'une demande de plusieurs lieux, ou null pour un lieu seul ou une demande sans lieu.
|
|
73
|
+
* `text` : la demande de l'utilisateur. `query` : la requête du classement, qui porte la catégorie dans une relance
|
|
74
|
+
* (« et à Lyon ? » après « tous les hôtels de Paris » donne « hôtels Lyon »).
|
|
75
|
+
*/
|
|
76
|
+
export function detectMany(text, query) {
|
|
77
|
+
const words = tokens(text);
|
|
78
|
+
const inText = find(words);
|
|
79
|
+
if (inText) {
|
|
80
|
+
if (inText.plural)
|
|
81
|
+
return inText.category;
|
|
82
|
+
// « la gare d'Amiens », « l'hôtel Lutetia » : un lieu précis. « hôtel Lutetia » aussi.
|
|
83
|
+
if (inText.start > 0 && DEFINITE.has(words[inText.start - 1] ?? ''))
|
|
84
|
+
return null;
|
|
85
|
+
if (namedAfter(text, inText))
|
|
86
|
+
return null;
|
|
87
|
+
return inText.category;
|
|
88
|
+
}
|
|
89
|
+
const inQuery = find(tokens(query));
|
|
90
|
+
return inQuery?.plural ? inQuery.category : null;
|
|
91
|
+
}
|
|
92
|
+
/** Mots qui disent ce qu'on veut de la zone, pas la zone elle-même. */
|
|
93
|
+
const ANCHOR_NOISE = new Set([
|
|
94
|
+
...['adresse', 'adresses', 'carte', 'plan', 'itineraire', 'localisation', 'emplacement', 'coordonnees', 'gps', 'trouver', 'situe', 'situes', 'ou', 'acces'],
|
|
95
|
+
...['tous', 'toutes', 'tout', 'liste', 'quel', 'quels', 'quelle', 'quelles', 'meilleur', 'meilleurs', 'meilleure', 'meilleures', 'top'],
|
|
96
|
+
...['bon', 'bons', 'bonne', 'bonnes', 'pas', 'cher', 'chers', 'chere', 'cheres', 'ouvert', 'ouverts', 'ouverte', 'ouvertes', 'garde'],
|
|
97
|
+
...['proche', 'proches', 'pres', 'autour', 'cote', 'alentours', 'proximite', 'loin', 'non', 'avis', 'prix', 'luxe', 'etoiles', 'sympa', 'sympas'],
|
|
98
|
+
]);
|
|
99
|
+
/**
|
|
100
|
+
* Un mot de la catégorie ou de service. « stations-service » compte, mot par mot. Les mots courts des formes (« de »
|
|
101
|
+
* dans « distributeur de billets ») ne comptent pas : « Saint-Jean de Lyon » les garde.
|
|
102
|
+
*/
|
|
103
|
+
function serviceWord(word, category) {
|
|
104
|
+
const forms = new Set([...category.singular, ...category.plural].flatMap((f) => f.split(' ')).filter((p) => p.length > 2));
|
|
105
|
+
const parts = tokens(word);
|
|
106
|
+
return parts.length > 0 && parts.every((p) => forms.has(p) || ANCHOR_NOISE.has(p));
|
|
107
|
+
}
|
|
108
|
+
/** Mots de liaison retirés aux bords d'une zone : « de la Tour Eiffel » donne « Tour Eiffel ». */
|
|
109
|
+
const EDGE = new Set(['de', 'du', 'des', 'la', 'le', 'les', 'l', 'd', 'a', 'au', 'aux', 'en', 'dans', 'sur', 'pour', 'et', 'vers']);
|
|
110
|
+
/** Ce qui reste d'un texte sans la catégorie, les mots de service ni les mots de liaison des bords. */
|
|
111
|
+
function withoutCategory(text, category) {
|
|
112
|
+
const kept = text
|
|
113
|
+
.replace(/[’']/g, "' ")
|
|
114
|
+
.split(/\s+/)
|
|
115
|
+
.filter((w) => norm(w) !== '' && !serviceWord(w, category));
|
|
116
|
+
while (kept.length > 0 && EDGE.has(norm(kept[0] ?? '')))
|
|
117
|
+
kept.shift();
|
|
118
|
+
while (kept.length > 0 && EDGE.has(norm(kept.at(-1) ?? '')))
|
|
119
|
+
kept.pop();
|
|
120
|
+
// La ponctuation n'est retirée qu'aux bords : la virgule de « Saint-Jean de Lyon, Lyon » sert à toPlace
|
|
121
|
+
return kept
|
|
122
|
+
.join(' ')
|
|
123
|
+
.replace(/' /g, "'")
|
|
124
|
+
.replace(/^[\s,.;:!?«»"()]+|[\s,.;:!?«»"()]+$/g, '');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Où chercher la zone, dans l'ordre : la requête sans la catégorie, le lieu du classement, puis les noms propres de la
|
|
128
|
+
* requête. Une zone sans majuscule n'est retenue que si Photon y trouve une zone administrative (anchorAccepted).
|
|
129
|
+
*/
|
|
130
|
+
export function anchorCandidates(query, place, category) {
|
|
131
|
+
const proper = query
|
|
132
|
+
.replace(/[’']/g, ' ')
|
|
133
|
+
.split(/\s+/)
|
|
134
|
+
.filter((w) => /^\p{Lu}/u.test(w) && !serviceWord(w, category))
|
|
135
|
+
.join(' ');
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
return [withoutCategory(query, category), place ? withoutCategory(place, category) : '', proper].filter((q) => {
|
|
138
|
+
const key = norm(q);
|
|
139
|
+
if (!key || seen.has(key))
|
|
140
|
+
return false;
|
|
141
|
+
seen.add(key);
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* La zone trouvée pour un candidat est-elle crédible ? Un nom propre suffit (« Tour Eiffel », « Lyon »). Sans majuscule
|
|
147
|
+
* (le modèle écrit parfois « musées de paris »), il faut une relation OpenStreetMap, une ville ou un quartier :
|
|
148
|
+
* « meilleures écoles de cybersécurité » ne doit pas devenir une carte autour d'un lieu nommé « cybersécurité ».
|
|
149
|
+
*/
|
|
150
|
+
export function anchorAccepted(candidate, found) {
|
|
151
|
+
return /\p{Lu}/u.test(candidate) || (found.osm ?? '').startsWith('relation/');
|
|
152
|
+
}
|
|
153
|
+
/** Rayon autour d'un lieu sans surface (un bâtiment, une station) : 1 km. */
|
|
154
|
+
export const AROUND_RADIUS = 1000;
|
|
155
|
+
/** Au-delà de cette diagonale, la zone est trop grande pour y lister des lieux (une région, un pays). */
|
|
156
|
+
export const MAX_ZONE_KM = 100;
|
|
157
|
+
/** En dessous, un lieu avec une surface (un parc, un site) se traite comme un point. */
|
|
158
|
+
const MIN_AREA_KM = 1.5;
|
|
159
|
+
/** Diagonale d'une boîte [sud, nord, ouest, est], en kilomètres. */
|
|
160
|
+
export function diagonalKm([south, north, west, east]) {
|
|
161
|
+
const rad = Math.PI / 180;
|
|
162
|
+
const a = Math.sin(((north - south) * rad) / 2) ** 2 + Math.cos(south * rad) * Math.cos(north * rad) * Math.sin(((east - west) * rad) / 2) ** 2;
|
|
163
|
+
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* La zone d'un lieu trouvé par Photon : une ville (relation OpenStreetMap) se cherche dans ses limites, un grand chemin
|
|
167
|
+
* fermé dans sa boîte, un bâtiment ou un point dans un rayon de 1 km. null : trop grand (plus de 100 km de diagonale).
|
|
168
|
+
*/
|
|
169
|
+
export function zoneOf(place) {
|
|
170
|
+
const size = place.bbox ? diagonalKm(place.bbox) : 0;
|
|
171
|
+
if (size > MAX_ZONE_KM)
|
|
172
|
+
return null;
|
|
173
|
+
const [type, id] = (place.osm ?? '').split('/');
|
|
174
|
+
if (size >= MIN_AREA_KM && type === 'relation' && id)
|
|
175
|
+
return { kind: 'area', id: 3_600_000_000 + Number(id), bbox: place.bbox };
|
|
176
|
+
if (size >= MIN_AREA_KM && place.bbox)
|
|
177
|
+
return { kind: 'bbox', bbox: place.bbox };
|
|
178
|
+
return { kind: 'around', lat: place.lat, lon: place.lon, radius: AROUND_RADIUS };
|
|
179
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La fiche d'un lieu, ouverte d'un clic sur son point : GET /api/map/card et GET /api/map/image/:id
|
|
3
|
+
* (.equipe/recherche/CONTRAT-LIEUX.md).
|
|
4
|
+
*
|
|
5
|
+
* 1. Les étiquettes OpenStreetMap du lieu (Overpass) : son site, son article Wikipédia ou son élément Wikidata.
|
|
6
|
+
* 2. Un article Wikipédia : son résumé et sa photo.
|
|
7
|
+
* 3. Sinon, ou pour ce qui manque : SearXNG, avec « nom zone ». L'extrait du site du lieu s'il est dans les résultats,
|
|
8
|
+
* sinon du premier résultat dont le titre porte la majorité des mots du nom. Même règle pour l'image.
|
|
9
|
+
*
|
|
10
|
+
* Les images passent par le serveur, comme les tuiles : la page ne contacte que lui. Il ne télécharge que les adresses
|
|
11
|
+
* qu'il a lui-même choisies (id opaque), en https, sur une liste fermée d'hôtes de vignettes, sans redirection, 2 Mo au
|
|
12
|
+
* plus, et seulement des jpeg, png, webp ou gif.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { VERSION } from '../config.js';
|
|
16
|
+
import { AppError, badRequest, describeError } from '../errors.js';
|
|
17
|
+
import { cut, hasMajority, placeWords } from './searxng.js';
|
|
18
|
+
const USER_AGENT = `Promethee/${VERSION} (console locale, fiches des lieux)`;
|
|
19
|
+
const WIKI_TIMEOUT_MS = 8_000;
|
|
20
|
+
const IMAGE_TIMEOUT_MS = 8_000;
|
|
21
|
+
export const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
23
|
+
/** Les hôtes des vignettes : Wikimédia, Bing et Brave (ce que rend SearXNG ici). */
|
|
24
|
+
const IMAGE_HOSTS = [/^upload\.wikimedia\.org$/, /^thumb\.wikimedia\.org$/, /^tse?\d{0,2}\.mm\.bing\.net$/, /^th\.bing\.com$/, /^imgs\.search\.brave\.com$/];
|
|
25
|
+
const TAGS_TIMEOUT_MS = 6_000;
|
|
26
|
+
const MAX_CARDS = 300;
|
|
27
|
+
const MAX_IMAGES = 500;
|
|
28
|
+
const MAX_IMAGE_BYTES_KEPT = 100;
|
|
29
|
+
const clean = (s) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '');
|
|
30
|
+
/** Une vignette que le serveur accepte de télécharger. */
|
|
31
|
+
export function imageAllowed(url) {
|
|
32
|
+
try {
|
|
33
|
+
const u = new URL(url);
|
|
34
|
+
return u.protocol === 'https:' && !u.username && !u.password && u.port === '' && IMAGE_HOSTS.some((h) => h.test(u.hostname));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const DATA_IMAGE = /^data:(image\/(?:jpeg|png|webp|gif));base64,([A-Za-z0-9+/=]{16,700000})$/;
|
|
41
|
+
/** Le domaine d'une adresse, sans « www. », ou null. */
|
|
42
|
+
export function hostOf(url) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(url).hostname.replace(/^www\./, '') || null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Le site d'un lieu d'après OpenStreetMap, s'il est en http(s). */
|
|
51
|
+
export function safeWebsite(value) {
|
|
52
|
+
const v = clean(value);
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(v);
|
|
55
|
+
return (u.protocol === 'https:' || u.protocol === 'http:') && !u.username && !u.password ? u.toString() : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const ENGINE_NAMES = {
|
|
62
|
+
google: 'Google',
|
|
63
|
+
bing: 'Bing',
|
|
64
|
+
duckduckgo: 'DuckDuckGo',
|
|
65
|
+
brave: 'Brave',
|
|
66
|
+
qwant: 'Qwant',
|
|
67
|
+
startpage: 'Startpage',
|
|
68
|
+
wikipedia: 'Wikipédia',
|
|
69
|
+
wikicommons: 'Wikimedia Commons',
|
|
70
|
+
mojeek: 'Mojeek',
|
|
71
|
+
yahoo: 'Yahoo',
|
|
72
|
+
pinterest: 'Pinterest',
|
|
73
|
+
};
|
|
74
|
+
/** Le moteur qui a donné le résultat, lisible : « bing images » donne « Bing », « google cse » donne « Google ». */
|
|
75
|
+
export function engineName(engines) {
|
|
76
|
+
const first = engines[0];
|
|
77
|
+
if (!first)
|
|
78
|
+
return null;
|
|
79
|
+
return ENGINE_NAMES[first.toLowerCase().split(/[\s.]/)[0] ?? ''] ?? first;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Le titre porte le nom du lieu : tous ses mots significatifs, ou tous sauf un au-delà de trois. Plus strict que la
|
|
83
|
+
* majorité des lieux de carte (essai réel : « Pharmacie Saint-Jean, Lyon » prenait une herboristerie Saint-Jean).
|
|
84
|
+
*/
|
|
85
|
+
export function namesPlace(name, text) {
|
|
86
|
+
const words = placeWords(name);
|
|
87
|
+
if (words.length === 0)
|
|
88
|
+
return false;
|
|
89
|
+
const own = new Set(placeWords(text));
|
|
90
|
+
const found = words.filter((w) => own.has(w)).length;
|
|
91
|
+
return found >= (words.length <= 3 ? words.length : words.length - 1);
|
|
92
|
+
}
|
|
93
|
+
/** L'extrait qui décrit le lieu : celui de son site s'il est dans les résultats, sinon le premier dont le titre porte son nom. */
|
|
94
|
+
export function pickDescription(sources, name, website) {
|
|
95
|
+
const site = website ? hostOf(website) : null;
|
|
96
|
+
const usable = sources.filter((s) => s.snippet);
|
|
97
|
+
const hit = (site ? usable.find((s) => hostOf(s.url) === site) : undefined) ?? usable.find((s) => namesPlace(name, s.title));
|
|
98
|
+
if (!hit)
|
|
99
|
+
return null;
|
|
100
|
+
return { text: hit.snippet, source: { label: hostOf(hit.url) ?? hit.url, url: hit.url, via: engineName(hit.engines) } };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* L'image du lieu, parmi les vignettes que le serveur sait télécharger : d'abord celle d'un site de confiance (le site du
|
|
104
|
+
* lieu, celui de l'extrait), sinon la première dont le titre porte le nom et, s'il y a une zone, la ville (essai réel : une
|
|
105
|
+
* « Pharmacie Saint-Jean » d'Orléans pour celle de Lyon).
|
|
106
|
+
*/
|
|
107
|
+
export function pickImage(hits, name, zone, trusted) {
|
|
108
|
+
const hosts = new Set(trusted.filter((h) => Boolean(h)));
|
|
109
|
+
const zoneWords = placeWords(zone);
|
|
110
|
+
const usable = hits.filter((h) => imageAllowed(h.thumbnail) || DATA_IMAGE.test(h.thumbnail));
|
|
111
|
+
const hit = usable.find((h) => hosts.has(hostOf(h.url) ?? '')) ??
|
|
112
|
+
usable.find((h) => namesPlace(name, h.title) && (zoneWords.length === 0 || hasMajority(zoneWords, `${h.title} ${h.url.replace(/[^a-z0-9]+/gi, ' ')}`)));
|
|
113
|
+
if (!hit)
|
|
114
|
+
return null;
|
|
115
|
+
return { url: hit.thumbnail, source: { label: hostOf(hit.url) ?? hit.url, url: hit.url, via: engineName(hit.engines) } };
|
|
116
|
+
}
|
|
117
|
+
/** Un résultat web qui est l'article Wikipédia du lieu : sa langue et son titre, pour lire son résumé. */
|
|
118
|
+
export function wikipediaArticle(sources, name) {
|
|
119
|
+
for (const s of sources) {
|
|
120
|
+
const m = /^https:\/\/(fr|en)\.(?:m\.)?wikipedia\.org\/wiki\/([^?#]+)$/.exec(s.url);
|
|
121
|
+
if (!m?.[1] || !m[2])
|
|
122
|
+
continue;
|
|
123
|
+
let title;
|
|
124
|
+
try {
|
|
125
|
+
title = decodeURIComponent(m[2]).replace(/_/g, ' ');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (namesPlace(name, title))
|
|
131
|
+
return { lang: m[1], title };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/** Les paramètres de GET /api/map/card, vérifiés. */
|
|
136
|
+
export function parseCardRequest(params) {
|
|
137
|
+
const name = clean(params.get('name'));
|
|
138
|
+
const zone = clean(params.get('zone'));
|
|
139
|
+
const osm = clean(params.get('osm')) || null;
|
|
140
|
+
if (!name || name.length > 160)
|
|
141
|
+
throw badRequest('Nom de lieu manquant ou trop long (160 caractères au plus).', 'invalid_input');
|
|
142
|
+
if (zone.length > 120)
|
|
143
|
+
throw badRequest('Zone trop longue (120 caractères au plus).', 'invalid_input');
|
|
144
|
+
if (osm !== null && !/^(node|way|relation)\/\d{1,12}$/.test(osm))
|
|
145
|
+
throw badRequest('Objet OpenStreetMap invalide.', 'invalid_input');
|
|
146
|
+
return { name, zone, osm };
|
|
147
|
+
}
|
|
148
|
+
export class Fiches {
|
|
149
|
+
deps;
|
|
150
|
+
cards = new Map();
|
|
151
|
+
images = new Map();
|
|
152
|
+
kept = [];
|
|
153
|
+
constructor(deps) {
|
|
154
|
+
this.deps = deps;
|
|
155
|
+
}
|
|
156
|
+
async json(url, signal) {
|
|
157
|
+
const res = await this.deps.fetcher(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, signal: AbortSignal.any([signal, AbortSignal.timeout(WIKI_TIMEOUT_MS)]) });
|
|
158
|
+
if (res.status === 404)
|
|
159
|
+
return null;
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
throw new AppError('wiki_error', 502, `Wikipédia a répondu ${res.status}.`);
|
|
162
|
+
return (await res.json());
|
|
163
|
+
}
|
|
164
|
+
async summary(lang, title, signal) {
|
|
165
|
+
const body = await this.json(`https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title.replace(/ /g, '_'))}`, signal);
|
|
166
|
+
if (!body || body.type === 'disambiguation')
|
|
167
|
+
return null;
|
|
168
|
+
const extract = clean(body.extract);
|
|
169
|
+
const page = clean(body.content_urls?.desktop?.page);
|
|
170
|
+
const url = page.startsWith(`https://${lang}.wikipedia.org/`) ? page : `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
|
|
171
|
+
const thumbnail = clean(body.thumbnail?.source) || null;
|
|
172
|
+
return extract ? { extract, url, thumbnail } : null;
|
|
173
|
+
}
|
|
174
|
+
/** L'article du lieu : l'étiquette wikipedia en français, sinon l'article français ou anglais de son élément Wikidata. */
|
|
175
|
+
async wiki(tags, signal) {
|
|
176
|
+
const tagged = /^([a-z]{2,3}):(.{1,250})$/.exec(tags.wikipedia ?? '');
|
|
177
|
+
if (tagged?.[1] === 'fr' && tagged[2])
|
|
178
|
+
return this.summary('fr', tagged[2], signal);
|
|
179
|
+
const qid = tags.wikidata ?? '';
|
|
180
|
+
if (/^Q\d{1,12}$/.test(qid)) {
|
|
181
|
+
const body = await this.json(`https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${qid}&props=sitelinks&sitefilter=frwiki%7Cenwiki&format=json`, signal);
|
|
182
|
+
const links = (body?.entities?.[qid]?.sitelinks ?? {});
|
|
183
|
+
const fr = clean(links.frwiki?.title);
|
|
184
|
+
const en = clean(links.enwiki?.title);
|
|
185
|
+
if (fr)
|
|
186
|
+
return this.summary('fr', fr, signal);
|
|
187
|
+
if (en)
|
|
188
|
+
return this.summary('en', en, signal);
|
|
189
|
+
}
|
|
190
|
+
if (tagged?.[1] && tagged[2])
|
|
191
|
+
return this.summary(tagged[1], tagged[2], signal);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/** Retient une adresse d'image choisie par le serveur et rend son id. Une image data: est décodée tout de suite. */
|
|
195
|
+
register(url) {
|
|
196
|
+
const id = crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
|
|
197
|
+
if (!this.images.has(id)) {
|
|
198
|
+
const data = DATA_IMAGE.exec(url);
|
|
199
|
+
this.images.set(id, data?.[1] && data[2] ? { url: '', type: data[1], bytes: Buffer.from(data[2], 'base64') } : { url });
|
|
200
|
+
while (this.images.size > MAX_IMAGES)
|
|
201
|
+
this.images.delete(this.images.keys().next().value);
|
|
202
|
+
}
|
|
203
|
+
return id;
|
|
204
|
+
}
|
|
205
|
+
async card(request, signal) {
|
|
206
|
+
const key = JSON.stringify([request.osm, request.name, request.zone]);
|
|
207
|
+
const cached = this.cards.get(key);
|
|
208
|
+
if (cached)
|
|
209
|
+
return cached;
|
|
210
|
+
// Les étiquettes sont un plus : sans Overpass, ou s'il tarde (essai réel : 40 s pour la Tour Eiffel un jour chargé),
|
|
211
|
+
// la fiche se cherche par le nom
|
|
212
|
+
let tags = null;
|
|
213
|
+
if (request.osm) {
|
|
214
|
+
try {
|
|
215
|
+
tags = await this.deps.places().tags(request.osm, AbortSignal.any([signal, AbortSignal.timeout(this.deps.tagsTimeoutMs ?? TAGS_TIMEOUT_MS)]));
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (signal.aborted)
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const website = safeWebsite(tags?.website ?? tags?.['contact:website']);
|
|
223
|
+
let description = null;
|
|
224
|
+
let descriptionSource = null;
|
|
225
|
+
let image = null;
|
|
226
|
+
let note = null;
|
|
227
|
+
let wiki = null;
|
|
228
|
+
try {
|
|
229
|
+
wiki = tags ? await this.wiki(tags, signal) : null;
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (signal.aborted)
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
const useWiki = (found) => {
|
|
236
|
+
description = cut(found.extract, 600);
|
|
237
|
+
descriptionSource = { label: 'Wikipédia', url: found.url, via: null };
|
|
238
|
+
if (!image && found.thumbnail && imageAllowed(found.thumbnail))
|
|
239
|
+
image = { id: this.register(found.thumbnail), source: { label: 'Wikipédia', url: found.url, via: null } };
|
|
240
|
+
};
|
|
241
|
+
if (wiki)
|
|
242
|
+
useWiki(wiki);
|
|
243
|
+
if (!description || !image) {
|
|
244
|
+
const query = `${request.name} ${request.zone}`.trim();
|
|
245
|
+
const backend = this.deps.search();
|
|
246
|
+
const [web, images] = await Promise.allSettled([description ? Promise.resolve([]) : backend.web(query, signal), image ? Promise.resolve([]) : backend.images(query, signal)]);
|
|
247
|
+
if (signal.aborted)
|
|
248
|
+
throw new DOMException('Opération interrompue.', 'AbortError');
|
|
249
|
+
if (!description && web.status === 'fulfilled') {
|
|
250
|
+
// L'article Wikipédia du lieu parmi les résultats : son résumé et sa photo valent mieux qu'un extrait
|
|
251
|
+
const article = wikipediaArticle(web.value, request.name);
|
|
252
|
+
let fromWiki = null;
|
|
253
|
+
try {
|
|
254
|
+
fromWiki = article ? await this.summary(article.lang, article.title, signal) : null;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
if (signal.aborted)
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
const found = fromWiki ? null : pickDescription(web.value, request.name, website);
|
|
261
|
+
if (fromWiki)
|
|
262
|
+
useWiki(fromWiki);
|
|
263
|
+
else if (found) {
|
|
264
|
+
description = cut(found.text, 600);
|
|
265
|
+
descriptionSource = found.source;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!image && images.status === 'fulfilled') {
|
|
269
|
+
const trusted = [website ? hostOf(website) : null, descriptionSource ? hostOf(descriptionSource.url) : null];
|
|
270
|
+
const found = pickImage(images.value, request.name, request.zone, trusted);
|
|
271
|
+
if (found)
|
|
272
|
+
image = { id: this.register(found.url), source: found.source };
|
|
273
|
+
}
|
|
274
|
+
const failed = [web, images].find((r) => r.status === 'rejected');
|
|
275
|
+
if (failed)
|
|
276
|
+
note = describeError(failed.reason).message;
|
|
277
|
+
}
|
|
278
|
+
const card = { name: request.name, description, descriptionSource, image, website, note };
|
|
279
|
+
if (!note) {
|
|
280
|
+
this.cards.set(key, card);
|
|
281
|
+
while (this.cards.size > MAX_CARDS)
|
|
282
|
+
this.cards.delete(this.cards.keys().next().value);
|
|
283
|
+
}
|
|
284
|
+
this.deps.onLookup?.(request, card);
|
|
285
|
+
return card;
|
|
286
|
+
}
|
|
287
|
+
/** L'image d'une fiche, téléchargée une fois puis gardée en mémoire (les 100 dernières). */
|
|
288
|
+
async image(id, signal) {
|
|
289
|
+
const stored = /^[a-f0-9]{24}$/.test(id) ? this.images.get(id) : undefined;
|
|
290
|
+
if (!stored)
|
|
291
|
+
throw new AppError('image_unknown', 404, 'Image inconnue. Rouvre la fiche du lieu.');
|
|
292
|
+
if (stored.bytes && stored.type)
|
|
293
|
+
return { bytes: stored.bytes, type: stored.type };
|
|
294
|
+
if (!imageAllowed(stored.url))
|
|
295
|
+
throw new AppError('image_unknown', 404, 'Image inconnue. Rouvre la fiche du lieu.');
|
|
296
|
+
let res;
|
|
297
|
+
try {
|
|
298
|
+
res = await this.deps.fetcher(stored.url, { headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' }, redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(IMAGE_TIMEOUT_MS)]) });
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
throw err;
|
|
303
|
+
throw new AppError('image_unreachable', 502, 'L’image n’a pas pu être téléchargée.');
|
|
304
|
+
}
|
|
305
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
306
|
+
const length = Number(res.headers.get('content-length') ?? 0);
|
|
307
|
+
if (!res.ok)
|
|
308
|
+
throw new AppError('image_unreachable', 502, `L’image n’a pas pu être téléchargée (${res.status}).`);
|
|
309
|
+
if (!IMAGE_TYPES.has(type) || length > MAX_IMAGE_BYTES)
|
|
310
|
+
throw new AppError('image_unreachable', 502, 'Le fichier reçu n’est pas une image utilisable.');
|
|
311
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
312
|
+
if (bytes.length > MAX_IMAGE_BYTES || bytes.length === 0)
|
|
313
|
+
throw new AppError('image_unreachable', 502, 'Le fichier reçu n’est pas une image utilisable.');
|
|
314
|
+
stored.bytes = bytes;
|
|
315
|
+
stored.type = type;
|
|
316
|
+
this.kept.push(id);
|
|
317
|
+
while (this.kept.length > MAX_IMAGE_BYTES_KEPT) {
|
|
318
|
+
const old = this.images.get(this.kept.shift());
|
|
319
|
+
if (old?.url) {
|
|
320
|
+
delete old.bytes;
|
|
321
|
+
delete old.type;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { bytes, type };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Les points d'une carte à plusieurs lieux : GET /api/map/places (.equipe/recherche/CONTRAT-LIEUX.md).
|
|
3
|
+
* Le cerveau lance la requête dès qu'il connaît la zone (prefetch), l'interface la lit ensuite : une seule requête Overpass
|
|
4
|
+
* pour les deux. Le résultat est gardé 7 jours sur le disque ({données}/lieux). Plus vieux, il sert encore si Overpass échoue.
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { badRequest } from '../errors.js';
|
|
10
|
+
import { AROUND_RADIUS, categoryByLabel, diagonalKm, MAX_ZONE_KM } from './categories.js';
|
|
11
|
+
const KEEP_MS = 7 * 86_400_000;
|
|
12
|
+
const MAX_MEMORY = 30;
|
|
13
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
14
|
+
function parseBox(v) {
|
|
15
|
+
if (!Array.isArray(v) || v.length !== 4 || !v.every(finite))
|
|
16
|
+
return null;
|
|
17
|
+
const [south, north, west, east] = v;
|
|
18
|
+
if (south < -90 || north > 90 || west < -180 || east > 180 || south > north || west > east)
|
|
19
|
+
return null;
|
|
20
|
+
return [south, north, west, east];
|
|
21
|
+
}
|
|
22
|
+
/** La demande de l'interface, vérifiée : une catégorie connue (son filtre vient du serveur) et une zone raisonnable. */
|
|
23
|
+
export function parsePlacesRequest(category, where) {
|
|
24
|
+
const known = categoryByLabel(category ?? '');
|
|
25
|
+
if (!known)
|
|
26
|
+
throw badRequest('Catégorie de lieux inconnue.', 'invalid_input');
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = JSON.parse(where ?? '');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw badRequest('Zone illisible.', 'invalid_input');
|
|
33
|
+
}
|
|
34
|
+
const tooBig = badRequest(`Zone trop grande : ${MAX_ZONE_KM} km de diagonale au plus.`, 'invalid_input');
|
|
35
|
+
if (raw?.kind === 'area') {
|
|
36
|
+
const bbox = parseBox(raw.bbox);
|
|
37
|
+
if (!Number.isInteger(raw.id) || raw.id <= 3_600_000_000 || raw.id >= 3_700_000_000 || !bbox)
|
|
38
|
+
throw badRequest('Zone invalide.', 'invalid_input');
|
|
39
|
+
if (diagonalKm(bbox) > MAX_ZONE_KM)
|
|
40
|
+
throw tooBig;
|
|
41
|
+
return { category: known, where: { kind: 'area', id: raw.id, bbox } };
|
|
42
|
+
}
|
|
43
|
+
if (raw?.kind === 'bbox') {
|
|
44
|
+
const bbox = parseBox(raw.bbox);
|
|
45
|
+
if (!bbox)
|
|
46
|
+
throw badRequest('Zone invalide.', 'invalid_input');
|
|
47
|
+
if (diagonalKm(bbox) > MAX_ZONE_KM)
|
|
48
|
+
throw tooBig;
|
|
49
|
+
return { category: known, where: { kind: 'bbox', bbox } };
|
|
50
|
+
}
|
|
51
|
+
if (raw?.kind === 'around') {
|
|
52
|
+
const { lat, lon, radius } = raw;
|
|
53
|
+
if (!finite(lat) || !finite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180 || !Number.isInteger(radius) || radius < 100 || radius > 5 * AROUND_RADIUS)
|
|
54
|
+
throw badRequest('Zone invalide.', 'invalid_input');
|
|
55
|
+
return { category: known, where: { kind: 'around', lat, lon, radius: radius } };
|
|
56
|
+
}
|
|
57
|
+
throw badRequest('Zone invalide.', 'invalid_input');
|
|
58
|
+
}
|
|
59
|
+
export class PlacesService {
|
|
60
|
+
backend;
|
|
61
|
+
dir;
|
|
62
|
+
memory = new Map();
|
|
63
|
+
pending = new Map();
|
|
64
|
+
constructor(backend, dir) {
|
|
65
|
+
this.backend = backend;
|
|
66
|
+
this.dir = dir;
|
|
67
|
+
}
|
|
68
|
+
key(category, where) {
|
|
69
|
+
return crypto.createHash('sha256').update(JSON.stringify([category.filter, where])).digest('hex').slice(0, 32);
|
|
70
|
+
}
|
|
71
|
+
read(key) {
|
|
72
|
+
const inMemory = this.memory.get(key);
|
|
73
|
+
if (inMemory)
|
|
74
|
+
return inMemory;
|
|
75
|
+
if (!this.dir)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const file = path.join(this.dir, `${key}.json`);
|
|
79
|
+
const at = fs.statSync(file).mtimeMs;
|
|
80
|
+
const result = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
if (!Array.isArray(result.points) || typeof result.total !== 'number')
|
|
82
|
+
return null;
|
|
83
|
+
return { at, result };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
write(key, result) {
|
|
90
|
+
this.memory.set(key, { at: Date.now(), result });
|
|
91
|
+
while (this.memory.size > MAX_MEMORY)
|
|
92
|
+
this.memory.delete(this.memory.keys().next().value);
|
|
93
|
+
if (!this.dir)
|
|
94
|
+
return;
|
|
95
|
+
try {
|
|
96
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(this.dir, `${key}.json`), JSON.stringify(result));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Un cache qui ne s'écrit pas ne doit pas faire échouer la carte
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Les points, depuis le cache s'il a moins de 7 jours, sinon par Overpass. Deux demandes identiques en même temps n'en
|
|
105
|
+
* font qu'une. La requête ne dépend pas de qui l'attend : une page fermée ne l'interrompt pas, et le cache se remplit.
|
|
106
|
+
*/
|
|
107
|
+
get(category, where) {
|
|
108
|
+
const key = this.key(category, where);
|
|
109
|
+
const cached = this.read(key);
|
|
110
|
+
if (cached && Date.now() - cached.at < KEEP_MS)
|
|
111
|
+
return Promise.resolve(cached.result);
|
|
112
|
+
const inFlight = this.pending.get(key);
|
|
113
|
+
if (inFlight)
|
|
114
|
+
return inFlight;
|
|
115
|
+
const run = this.backend()
|
|
116
|
+
.many(category.filter, where, new AbortController().signal)
|
|
117
|
+
.then((result) => {
|
|
118
|
+
this.write(key, result);
|
|
119
|
+
return result;
|
|
120
|
+
})
|
|
121
|
+
.catch((err) => {
|
|
122
|
+
if (cached)
|
|
123
|
+
return cached.result;
|
|
124
|
+
throw err;
|
|
125
|
+
})
|
|
126
|
+
.finally(() => this.pending.delete(key));
|
|
127
|
+
this.pending.set(key, run);
|
|
128
|
+
return run;
|
|
129
|
+
}
|
|
130
|
+
/** Lance la requête sans l'attendre : l'interface la retrouvera en vol ou dans le cache. */
|
|
131
|
+
prefetch(category, where) {
|
|
132
|
+
this.get(category, where).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
}
|