@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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,216 @@
1
+ import { VERSION } from '../config.js';
2
+ import { AppError } from '../errors.js';
3
+ import { cut } from './searxng.js';
4
+ /**
5
+ * Deux instances publiques, dans l'ordre. Mesuré le 15/11/2026 aux heures chargées : la principale répondait 502 « at
6
+ * capacity » ou en 38 s, celle de VK en 17 s, pour les hôtels de Paris (757 Ko). La seconde part si la première échoue
7
+ * ou n'a pas répondu au bout de HEDGE_MS. La première réponse gagne, l'autre est abandonnée.
8
+ */
9
+ export const OVERPASS_URLS = ['https://overpass-api.de/api/interpreter', 'https://maps.mail.ru/osm/tools/overpass/api/interpreter'];
10
+ /** Points rendus au plus. total dit combien il y en a en tout. */
11
+ export const MAX_POINTS = 2000;
12
+ const TIMEOUT_MS = 45_000;
13
+ const HEDGE_MS = 8_000;
14
+ /**
15
+ * Quand toutes les instances refusent (429, 502, 503, 504), on réessaie après 3 s, puis 6 s, 12 s, tant que le tout
16
+ * tient en une minute, comme le demande Overpass. Mesuré le 15/11/2026 : VK répondait 504 en 0,5 s pendant que la
17
+ * principale refusait par moments, et répondait en 33 s à d'autres.
18
+ */
19
+ const RETRY_MS = 3_000;
20
+ const BUDGET_MS = 60_000;
21
+ /** Attend `ms`, ou rejette dès que `signal` est arrêté. */
22
+ function sleep(ms, signal) {
23
+ return new Promise((resolve, reject) => {
24
+ if (signal.aborted)
25
+ return reject(new DOMException('Opération interrompue.', 'AbortError'));
26
+ const timer = setTimeout(() => {
27
+ signal.removeEventListener('abort', stop);
28
+ resolve();
29
+ }, ms);
30
+ const stop = () => {
31
+ clearTimeout(timer);
32
+ reject(new DOMException('Opération interrompue.', 'AbortError'));
33
+ };
34
+ signal.addEventListener('abort', stop, { once: true });
35
+ });
36
+ }
37
+ const clean = (s) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '');
38
+ const coord = (n) => n.toFixed(7);
39
+ /** La requête Overpass : le nombre de lieux nommés de la catégorie dans la zone, puis leurs noms, centres et étiquettes. */
40
+ export function overpassQuery(filter, zone) {
41
+ let scope = '';
42
+ let clause;
43
+ if (zone.kind === 'area') {
44
+ scope = `area(${zone.id})->.zone;`;
45
+ clause = '(area.zone)';
46
+ }
47
+ else if (zone.kind === 'bbox') {
48
+ const [south, north, west, east] = zone.bbox;
49
+ clause = `(${coord(south)},${coord(west)},${coord(north)},${coord(east)})`;
50
+ }
51
+ else
52
+ clause = `(around:${Math.round(zone.radius)},${coord(zone.lat)},${coord(zone.lon)})`;
53
+ return `[out:json][timeout:20];${scope}nwr${filter}["name"]${clause}->.lieux;.lieux out count;.lieux out tags center ${MAX_POINTS};`;
54
+ }
55
+ /** L'adresse d'après les étiquettes addr:* : « 12 Rue de Tolbiac, 75013 Paris », ou null. */
56
+ export function addressOf(tags) {
57
+ const street = [clean(tags['addr:housenumber']), clean(tags['addr:street'])].filter(Boolean).join(' ');
58
+ const city = [clean(tags['addr:postcode']), clean(tags['addr:city'])].filter(Boolean).join(' ');
59
+ const out = [street, city].filter(Boolean).join(', ');
60
+ return out ? cut(out, 160) : null;
61
+ }
62
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
63
+ /** Les lieux d'une réponse Overpass : nommés, avec des coordonnées, dans l'ordre d'Overpass. */
64
+ export function toPoints(body) {
65
+ const elements = Array.isArray(body?.elements) ? body.elements : [];
66
+ let total = 0;
67
+ const points = [];
68
+ for (const e of elements) {
69
+ const tags = (e?.tags ?? {});
70
+ if (e?.type === 'count') {
71
+ total = Number(tags.total) || 0;
72
+ continue;
73
+ }
74
+ if (!['node', 'way', 'relation'].includes(String(e?.type)) || typeof e.id !== 'number')
75
+ continue;
76
+ const center = (e.type === 'node' ? e : (e.center ?? {}));
77
+ const lat = num(center.lat);
78
+ const lon = num(center.lon);
79
+ const name = clean(tags.name);
80
+ if (lat === null || lon === null || Math.abs(lat) > 90 || Math.abs(lon) > 180 || !name)
81
+ continue;
82
+ points.push({ name: cut(name, 120), lat, lon, address: addressOf(tags), osm: `${String(e.type)}/${e.id}` });
83
+ }
84
+ return { total: Math.max(total, points.length), points };
85
+ }
86
+ export class Overpass {
87
+ fetcher;
88
+ urls;
89
+ hedgeMs;
90
+ retryMs;
91
+ budgetMs;
92
+ constructor(fetcher = fetch, urls = OVERPASS_URLS, hedgeMs = HEDGE_MS, retryMs = RETRY_MS, budgetMs = BUDGET_MS) {
93
+ this.fetcher = fetcher;
94
+ this.urls = urls;
95
+ this.hedgeMs = hedgeMs;
96
+ this.retryMs = retryMs;
97
+ this.budgetMs = budgetMs;
98
+ }
99
+ /** Des tours sur les instances, avec une attente qui double entre deux tours refusés, dans le budget d'une minute. */
100
+ async run(query, signal) {
101
+ const started = Date.now();
102
+ let wait = this.retryMs;
103
+ for (;;) {
104
+ try {
105
+ return await this.round(query, signal);
106
+ }
107
+ catch (err) {
108
+ const busy = err instanceof AppError && err.code === 'map_busy';
109
+ if (signal.aborted || !busy || Date.now() - started + wait > this.budgetMs)
110
+ throw err;
111
+ await sleep(wait, signal);
112
+ wait *= 2;
113
+ }
114
+ }
115
+ }
116
+ /** Un tour : les instances l'une après l'autre, ou ensemble passé HEDGE_MS. La première réponse lisible gagne. */
117
+ round(query, signal) {
118
+ return new Promise((resolve, reject) => {
119
+ const controllers = [];
120
+ let next = 0;
121
+ let running = 0;
122
+ let settled = false;
123
+ let lastError = null;
124
+ let timer;
125
+ const finish = (fn) => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ for (const c of controllers)
131
+ c.abort();
132
+ signal.removeEventListener('abort', onAbort);
133
+ fn();
134
+ };
135
+ const onAbort = () => finish(() => reject(signal.reason instanceof Error ? signal.reason : new DOMException('Opération interrompue.', 'AbortError')));
136
+ const start = () => {
137
+ const url = this.urls[next++];
138
+ if (url === undefined)
139
+ return false;
140
+ const controller = new AbortController();
141
+ controllers.push(controller);
142
+ running++;
143
+ clearTimeout(timer);
144
+ timer = setTimeout(() => {
145
+ if (!settled)
146
+ start();
147
+ }, this.hedgeMs);
148
+ this.once(url, query, controller.signal).then((body) => finish(() => resolve(body)), (err) => {
149
+ running--;
150
+ if (settled)
151
+ return;
152
+ lastError = err;
153
+ if (!start() && running === 0)
154
+ finish(() => reject(lastError));
155
+ });
156
+ return true;
157
+ };
158
+ if (signal.aborted)
159
+ return onAbort();
160
+ signal.addEventListener('abort', onAbort, { once: true });
161
+ start();
162
+ });
163
+ }
164
+ async once(url, query, signal) {
165
+ let res;
166
+ try {
167
+ res = await this.fetcher(url, {
168
+ method: 'POST',
169
+ body: new URLSearchParams({ data: query }).toString(),
170
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'User-Agent': `Promethee/${VERSION} (console locale, carte des recherches)` },
171
+ signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)]),
172
+ });
173
+ }
174
+ catch (err) {
175
+ if (signal.aborted)
176
+ throw err;
177
+ if (err instanceof Error && err.name === 'TimeoutError')
178
+ throw new AppError('map_timeout', 504, `OpenStreetMap (Overpass) n’a pas répondu en ${TIMEOUT_MS / 1000} s.`);
179
+ throw new AppError('map_unreachable', 502, 'OpenStreetMap (Overpass) ne répond pas. Vérifie la connexion à internet.');
180
+ }
181
+ if ([429, 502, 503, 504].includes(res.status))
182
+ throw new AppError('map_busy', 503, 'OpenStreetMap (Overpass) est occupé. Réessaie dans une minute.');
183
+ if (!res.ok)
184
+ throw new AppError('map_error', 502, `OpenStreetMap (Overpass) a répondu ${res.status}.`);
185
+ let body;
186
+ try {
187
+ body = (await res.json());
188
+ }
189
+ catch {
190
+ throw new AppError('map_error', 502, 'OpenStreetMap (Overpass) a renvoyé une réponse illisible.');
191
+ }
192
+ // Overpass répond 200 avec une remarque quand la requête dépasse son temps ou sa mémoire
193
+ if (typeof body.remark === 'string' && /error|timed out/i.test(body.remark))
194
+ throw new AppError('map_error', 502, 'OpenStreetMap (Overpass) n’a pas pu finir la recherche. Essaie une zone plus petite.');
195
+ return body;
196
+ }
197
+ async many(filter, zone, signal) {
198
+ const found = toPoints(await this.run(overpassQuery(filter, zone), signal));
199
+ // Une relation sans zone calculée par Overpass (un site, un quartier récent) ne rend rien : sa boîte prend le relais
200
+ if (found.total === 0 && zone.kind === 'area' && zone.bbox)
201
+ return toPoints(await this.run(overpassQuery(filter, { kind: 'bbox', bbox: zone.bbox }), signal));
202
+ return found;
203
+ }
204
+ async tags(osm, signal) {
205
+ const m = /^(node|way|relation)\/(\d{1,12})$/.exec(osm);
206
+ if (!m)
207
+ return null;
208
+ const body = await this.run(`[out:json][timeout:10];${m[1]}(${m[2]});out tags;`, signal);
209
+ const elements = Array.isArray(body.elements) ? body.elements : [];
210
+ const first = elements.find((e) => e?.type === m[1]);
211
+ const tags = first?.tags;
212
+ if (!tags || typeof tags !== 'object')
213
+ return null;
214
+ return Object.fromEntries(Object.entries(tags).filter((e) => typeof e[1] === 'string'));
215
+ }
216
+ }
@@ -0,0 +1,223 @@
1
+ import { AppError } from '../errors.js';
2
+ export const SEARCH_TIMEOUT_MS = 12_000;
3
+ export const MAX_SOURCES = 6;
4
+ const MAX_SNIPPET = 280;
5
+ /** Même règle que le schéma des réglages : l'adresse de SearXNG reste sur cette machine. */
6
+ export const isLoopbackUrl = (url) => /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d{1,5})?\/?$/i.test(url);
7
+ const clean = (s) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '');
8
+ /** Coupe sans casser un mot (sauf un mot seul plus long que la limite). */
9
+ export function cut(text, max) {
10
+ if (text.length <= max)
11
+ return text;
12
+ const head = text.slice(0, max);
13
+ const space = text[max] === ' ' ? max : head.lastIndexOf(' ');
14
+ return `${(space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:]+$/, '')}…`;
15
+ }
16
+ /** Les sources d'une recherche générale : adresses http(s) seulement, sans doublon, dans l'ordre de SearXNG. */
17
+ export function toSources(results) {
18
+ if (!Array.isArray(results))
19
+ return [];
20
+ const seen = new Set();
21
+ const out = [];
22
+ for (const r of results) {
23
+ const url = clean(r?.url);
24
+ const title = clean(r?.title);
25
+ if (!/^https?:\/\/[^\s]+$/i.test(url) || !title || seen.has(url))
26
+ continue;
27
+ seen.add(url);
28
+ const engines = Array.isArray(r.engines) ? r.engines.filter((e) => typeof e === 'string') : [];
29
+ out.push({
30
+ n: out.length + 1,
31
+ title: cut(title, 160),
32
+ url,
33
+ snippet: cut(clean(r.content), MAX_SNIPPET),
34
+ engines,
35
+ publishedDate: typeof r.publishedDate === 'string' && r.publishedDate ? r.publishedDate : null,
36
+ });
37
+ if (out.length >= MAX_SOURCES)
38
+ break;
39
+ }
40
+ return out;
41
+ }
42
+ const STOP = new Set(['de', 'du', 'des', 'la', 'le', 'les', 'l', 'd', 'a', 'au', 'aux', 'en', 'et', 'un', 'une', 'the', 'of']);
43
+ /** Mots significatifs d'un nom de lieu, sans accents ni majuscules. */
44
+ export function placeWords(name) {
45
+ return name
46
+ .normalize('NFD')
47
+ .replace(/[\u0300-\u036f]/g, '')
48
+ .toLowerCase()
49
+ .split(/[^a-z0-9]+/)
50
+ .filter((w) => w.length >= 3 && !STOP.has(w));
51
+ }
52
+ /** Mots qui disent ce qu'on veut savoir d'un lieu (son adresse, sa carte, le chemin), pas le lieu lui-même. */
53
+ const PLACE_NOISE = new Set(['adresse', 'carte', 'plan', 'itineraire', 'localisation', 'emplacement', 'coordonnees', 'gps', 'trouver', 'situe', 'ou', 'acces']);
54
+ const plain = (word) => word
55
+ .normalize('NFD')
56
+ .replace(/[\u0300-\u036f]/g, '')
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9']/g, '');
59
+ /**
60
+ * Ce qu'on cherche sur la carte, dans l'ordre : la requête sans ses mots de service, puis le lieu du classificateur.
61
+ * Le lieu se réduit souvent à la ville (« Amiens, France » pour la gare d'Amiens), la requête garde l'endroit.
62
+ */
63
+ export function placeCandidates(query, place) {
64
+ const fromQuery = query
65
+ .split(/\s+/)
66
+ .filter((w) => w && !PLACE_NOISE.has(plain(w)))
67
+ .join(' ');
68
+ const seen = new Set();
69
+ return [fromQuery, place.trim()].filter((q) => {
70
+ const key = q.split(/\s+/).map(plain).join(' ');
71
+ if (!q || seen.has(key))
72
+ return false;
73
+ seen.add(key);
74
+ return true;
75
+ });
76
+ }
77
+ function formatAddress(address, name) {
78
+ if (!address || typeof address !== 'object')
79
+ return null;
80
+ const a = address;
81
+ const street = [clean(a.house_number), clean(a.road)].filter(Boolean).join(' ');
82
+ const city = [clean(a.postcode), clean(a.locality)].filter(Boolean).join(' ');
83
+ const parts = [street, city, clean(a.country)].filter((p) => p && p !== name);
84
+ return parts.length > 0 ? parts.join(', ') : null;
85
+ }
86
+ /** Un nombre fini, ou null. Une chaîne vide ou blanche n'est pas 0. */
87
+ const num = (v) => {
88
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : Number.NaN;
89
+ return Number.isFinite(n) ? n : null;
90
+ };
91
+ /** Plus de la moitié des mots voulus (placeWords) se trouvent dans le texte. Sans mot voulu, rien ne passe. */
92
+ export function hasMajority(wanted, text) {
93
+ if (wanted.length === 0)
94
+ return false;
95
+ const own = new Set(placeWords(text));
96
+ return wanted.filter((w) => own.has(w)).length * 2 > wanted.length;
97
+ }
98
+ /** Les images d'une recherche : vignettes en https ou en data:image, sans doublon, dans l'ordre de SearXNG. */
99
+ export function toImages(results) {
100
+ if (!Array.isArray(results))
101
+ return [];
102
+ const seen = new Set();
103
+ const out = [];
104
+ for (const r of results) {
105
+ const thumbnail = clean(r?.thumbnail_src) || clean(r?.thumbnail) || clean(r?.img_src);
106
+ const url = clean(r?.url);
107
+ if (!/^(https:\/\/|data:image\/)/i.test(thumbnail) || !/^https?:\/\/[^\s]+$/i.test(url) || seen.has(thumbnail))
108
+ continue;
109
+ seen.add(thumbnail);
110
+ const engines = Array.isArray(r.engines) ? r.engines.filter((e) => typeof e === 'string') : typeof r.engine === 'string' ? [r.engine] : [];
111
+ out.push({ thumbnail, url, title: cut(clean(r.title), 160), engines });
112
+ if (out.length >= 40)
113
+ break;
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Le lieu à montrer : le premier résultat avec des coordonnées dont le nom et l'adresse contiennent PLUS de la moitié
119
+ * des mots du lieu demandé (la partie avant la virgule). Sinon, pas de carte : « ANSSI, Paris » ne doit montrer ni une
120
+ * assurance (« Assurances ANSSUR ») ni le « Boulevard de Paris ».
121
+ */
122
+ export function toPlace(results, wanted) {
123
+ if (!Array.isArray(results))
124
+ return null;
125
+ const words = placeWords(wanted.split(',')[0] ?? wanted);
126
+ for (const r of results) {
127
+ const lat = num(r?.latitude);
128
+ const lon = num(r?.longitude);
129
+ const name = clean(r?.title);
130
+ if (lat === null || lon === null || Math.abs(lat) > 90 || Math.abs(lon) > 180 || !name)
131
+ continue;
132
+ const address = (r.address ?? {});
133
+ if (words.length > 0 && !hasMajority(words, `${name} ${clean(address.name)} ${clean(address.locality)}`))
134
+ continue;
135
+ const box = Array.isArray(r.boundingbox) ? r.boundingbox.map(num) : [];
136
+ const bbox = box.length === 4 && box.every((v) => v !== null) ? box : null;
137
+ const url = clean(r.url);
138
+ const osm = (r.osm ?? {});
139
+ const osmId = ['node', 'way', 'relation'].includes(String(osm.type)) && /^\d{1,12}$/.test(String(osm.id)) ? `${String(osm.type)}/${String(osm.id)}` : null;
140
+ return {
141
+ name: cut(name, 120),
142
+ address: formatAddress(r.address, name),
143
+ lat,
144
+ lon,
145
+ bbox,
146
+ osmUrl: /^https:\/\/(www\.)?openstreetmap\.org\//.test(url) ? url : osmLink(lat, lon),
147
+ osm: osmId,
148
+ city: clean(address.locality) || null,
149
+ };
150
+ }
151
+ return null;
152
+ }
153
+ export const osmLink = (lat, lon) => `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
154
+ export class Searxng {
155
+ url;
156
+ fetcher;
157
+ constructor(url, fetcher = fetch) {
158
+ this.url = url;
159
+ this.fetcher = fetcher;
160
+ }
161
+ base() {
162
+ const url = this.url().replace(/\/+$/, '');
163
+ if (!isLoopbackUrl(url))
164
+ throw new AppError('search_url', 400, 'L’adresse de SearXNG doit rester sur cette machine (127.0.0.1).');
165
+ return url;
166
+ }
167
+ async get(params, signal) {
168
+ const base = this.base();
169
+ const query = new URLSearchParams({ format: 'json', language: 'fr', safesearch: '1', ...params });
170
+ let res;
171
+ try {
172
+ res = await this.fetcher(`${base}/search?${query}`, { signal: AbortSignal.any([signal, AbortSignal.timeout(SEARCH_TIMEOUT_MS)]), headers: { Accept: 'application/json' } });
173
+ }
174
+ catch (err) {
175
+ if (signal.aborted)
176
+ throw err;
177
+ if (err instanceof Error && err.name === 'TimeoutError')
178
+ throw new AppError('search_timeout', 504, `SearXNG n’a pas répondu en ${SEARCH_TIMEOUT_MS / 1000} s.`);
179
+ throw new AppError('search_unreachable', 502, `SearXNG ne répond pas sur ${base}. Lance « npm run searxng » pour le démarrer.`);
180
+ }
181
+ if (res.status === 403)
182
+ throw new AppError('search_format', 502, 'SearXNG refuse le format JSON : ajoute json à search.formats dans son settings.yml.');
183
+ if (!res.ok)
184
+ throw new AppError('search_error', 502, `SearXNG a répondu ${res.status}.`);
185
+ try {
186
+ return (await res.json());
187
+ }
188
+ catch {
189
+ throw new AppError('search_error', 502, 'SearXNG a renvoyé une réponse illisible.');
190
+ }
191
+ }
192
+ async web(query, signal) {
193
+ const body = await this.get({ q: query }, signal);
194
+ return toSources(body.results);
195
+ }
196
+ async place(name, signal) {
197
+ const body = await this.get({ q: name, categories: 'map' }, signal);
198
+ return toPlace(body.results, name);
199
+ }
200
+ async images(query, signal) {
201
+ const body = await this.get({ q: query, categories: 'images' }, signal);
202
+ return toImages(body.results);
203
+ }
204
+ async health() {
205
+ const started = performance.now();
206
+ let base;
207
+ try {
208
+ base = this.base();
209
+ }
210
+ catch (err) {
211
+ return { available: false, latencyMs: null, error: err.message };
212
+ }
213
+ try {
214
+ const res = await this.fetcher(`${base}/healthz`, { signal: AbortSignal.timeout(2000) });
215
+ if (!res.ok)
216
+ return { available: false, latencyMs: null, error: `SearXNG a répondu ${res.status}.` };
217
+ return { available: true, latencyMs: Math.round(performance.now() - started), error: null };
218
+ }
219
+ catch {
220
+ return { available: false, latencyMs: null, error: `SearXNG ne répond pas sur ${base}. Lance « npm run searxng ».` };
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tuiles de carte d'OpenStreetMap, servies par le serveur local.
3
+ *
4
+ * La CSP de la console n'autorise que ses propres images : le serveur va chercher la tuile, la garde sept jours
5
+ * sur le disque et la renvoie. Règles d'usage d'OSM respectées : User-Agent qui nomme l'application, deux
6
+ * connexions au plus, cache local. https://operations.osmfoundation.org/policies/tiles/
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { VERSION } from '../config.js';
11
+ import { AppError, badRequest } from '../errors.js';
12
+ export const TILE_URL = 'https://tile.openstreetmap.org';
13
+ export const MAX_ZOOM = 19;
14
+ const KEEP_MS = 7 * 86_400_000;
15
+ const MAX_PARALLEL = 2;
16
+ const MAX_TILE_BYTES = 512 * 1024;
17
+ /** z, x, y entiers et dans la grille du niveau de zoom. */
18
+ export function parseTile(z, x, y) {
19
+ const ok = (s) => /^\d{1,7}$/.test(s);
20
+ if (!ok(z) || !ok(x) || !ok(y.replace(/\.png$/, '')))
21
+ throw badRequest('Tuile invalide.');
22
+ const t = { z: Number(z), x: Number(x), y: Number(y.replace(/\.png$/, '')) };
23
+ const size = 2 ** t.z;
24
+ if (t.z > MAX_ZOOM || t.x >= size || t.y >= size)
25
+ throw badRequest('Tuile hors de la carte.');
26
+ return t;
27
+ }
28
+ export class TileCache {
29
+ dir;
30
+ fetcher;
31
+ memory = new Map();
32
+ pending = new Map();
33
+ active = 0;
34
+ queue = [];
35
+ /** dir null : cache en mémoire seulement (tests). */
36
+ constructor(dir, fetcher = fetch) {
37
+ this.dir = dir;
38
+ this.fetcher = fetcher;
39
+ }
40
+ file(z, x, y) {
41
+ return this.dir ? path.join(this.dir, String(z), String(x), `${y}.png`) : null;
42
+ }
43
+ cached(key, file, fresh) {
44
+ if (!file)
45
+ return this.memory.get(key) ?? null;
46
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
47
+ if (!stat || (fresh && Date.now() - stat.mtimeMs > KEEP_MS))
48
+ return null;
49
+ return fs.readFileSync(file);
50
+ }
51
+ async slot(work) {
52
+ if (this.active >= MAX_PARALLEL)
53
+ await new Promise((resolve) => this.queue.push(resolve));
54
+ this.active++;
55
+ try {
56
+ return await work();
57
+ }
58
+ finally {
59
+ this.active--;
60
+ this.queue.shift()?.();
61
+ }
62
+ }
63
+ async get(z, x, y) {
64
+ const key = `${z}/${x}/${y}`;
65
+ const file = this.file(z, x, y);
66
+ const fresh = this.cached(key, file, true);
67
+ if (fresh)
68
+ return fresh;
69
+ const running = this.pending.get(key);
70
+ if (running)
71
+ return running;
72
+ const work = this.slot(() => this.download(key, file)).finally(() => this.pending.delete(key));
73
+ this.pending.set(key, work);
74
+ return work;
75
+ }
76
+ async download(key, file) {
77
+ let body;
78
+ try {
79
+ const res = await this.fetcher(`${TILE_URL}/${key}.png`, { headers: { 'User-Agent': `Promethee/${VERSION} (console locale, carte des recherches)` }, signal: AbortSignal.timeout(10_000) });
80
+ if (!res.ok)
81
+ throw new Error(`HTTP ${res.status}`);
82
+ body = Buffer.from(await res.arrayBuffer());
83
+ if (body.length === 0 || body.length > MAX_TILE_BYTES)
84
+ throw new Error('taille inattendue');
85
+ }
86
+ catch {
87
+ // Hors ligne : une tuile ancienne vaut mieux qu'un trou dans la carte
88
+ const stale = this.cached(key, file, false);
89
+ if (stale)
90
+ return stale;
91
+ throw new AppError('tile_unreachable', 502, 'Impossible de charger la carte depuis OpenStreetMap.');
92
+ }
93
+ if (file) {
94
+ fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ const tmp = `${file}.${process.pid}.tmp`;
96
+ fs.writeFileSync(tmp, body);
97
+ fs.renameSync(tmp, file);
98
+ }
99
+ else
100
+ this.memory.set(key, body);
101
+ return body;
102
+ }
103
+ }