@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,406 @@
1
+ /**
2
+ * Lecture d'une page web pour le savoir local : téléchargement prudent, puis extraction du texte.
3
+ *
4
+ * Les adresses viennent des résultats d'une recherche, donc d'internet : le serveur ne doit pas s'en servir pour joindre
5
+ * une machine de ton réseau ou ce PC lui-même (SSRF). D'où :
6
+ * - http ou https, ports 80 et 443 seulement, pas d'identifiants dans l'adresse ;
7
+ * - l'adresse IP est vérifiée au moment même de la connexion (résolution DNS faite par nous, refus de toute adresse privée,
8
+ * de boucle locale, locale au lien, de partage d'opérateur, multicast ou réservée), y compris à chaque redirection ;
9
+ * - 4 redirections au plus, 3 Mo au plus (décompressés), 12 s au plus, du HTML ou du texte seulement, aucun cookie.
10
+ * Le User-Agent nomme l'application, rien de plus.
11
+ */
12
+ import dns from 'node:dns';
13
+ import http from 'node:http';
14
+ import https from 'node:https';
15
+ import net from 'node:net';
16
+ import zlib from 'node:zlib';
17
+ import { VERSION } from '../config.js';
18
+ export const MAX_PAGE_BYTES = 3 * 1024 * 1024;
19
+ export const MAX_TEXT_CHARS = 60_000;
20
+ export const PAGE_TIMEOUT_MS = 12_000;
21
+ const MAX_REDIRECTS = 4;
22
+ const USER_AGENT = `Mozilla/5.0 (compatible; Promethee/${VERSION}; lecture locale pour son utilisateur)`;
23
+ export class ReadError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'ReadError';
27
+ }
28
+ }
29
+ // ------------------------------------------------------------------ Adresses
30
+ const v4 = (ip) => ip.split('.').map(Number);
31
+ /** Vrai pour une adresse joignable sur internet. Faux pour tout ce qui mène chez toi ou nulle part. */
32
+ export function publicAddress(ip) {
33
+ if (net.isIPv4(ip)) {
34
+ const [a = 0, b = 0, c = 0] = v4(ip);
35
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
36
+ return false;
37
+ if (a === 100 && b >= 64 && b <= 127)
38
+ return false;
39
+ if (a === 169 && b === 254)
40
+ return false;
41
+ if (a === 172 && b >= 16 && b <= 31)
42
+ return false;
43
+ if (a === 192 && b === 168)
44
+ return false;
45
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
46
+ return false;
47
+ if (a === 198 && (b === 18 || b === 19))
48
+ return false;
49
+ if (a === 198 && b === 51 && c === 100)
50
+ return false;
51
+ if (a === 203 && b === 0 && c === 113)
52
+ return false;
53
+ return true;
54
+ }
55
+ if (net.isIPv6(ip)) {
56
+ const low = ip.toLowerCase();
57
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(low);
58
+ if (mapped?.[1])
59
+ return publicAddress(mapped[1]);
60
+ if (low === '::' || low === '::1')
61
+ return false;
62
+ const first = parseInt(low.split(':')[0] || '0', 16);
63
+ if ((first & 0xfe00) === 0xfc00)
64
+ return false; // fc00::/7, adresses locales uniques
65
+ if ((first & 0xffc0) === 0xfe80)
66
+ return false; // fe80::/10, locales au lien
67
+ if ((first & 0xff00) === 0xff00)
68
+ return false; // multicast
69
+ if (low.startsWith('64:ff9b:') || low.startsWith('100::') || low.startsWith('2001:db8:') || low.startsWith('::'))
70
+ return false;
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ /** Résolution DNS qui refuse toute adresse non publique : c'est elle que la connexion utilise, il n'y a pas de seconde résolution. */
76
+ export function safeLookup(hostname, options, callback) {
77
+ dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
78
+ if (err)
79
+ return callback(err, '');
80
+ const list = addresses;
81
+ const bad = list.find((a) => !publicAddress(a.address));
82
+ if (bad || list.length === 0) {
83
+ const refused = new ReadError(`Adresse refusée : ${hostname} mène à une adresse privée ou réservée (${bad?.address ?? 'aucune'}).`);
84
+ refused.code = 'EPRIVATE';
85
+ return callback(refused, '');
86
+ }
87
+ if (options.all)
88
+ return callback(null, list);
89
+ const first = list[0];
90
+ callback(null, first?.address ?? '', first?.family);
91
+ });
92
+ }
93
+ /** Vérifie une adresse avant de la demander. Rend l'URL normalisée, ou lève une ReadError qui dit pourquoi. */
94
+ export function checkUrl(raw) {
95
+ let url;
96
+ try {
97
+ url = new URL(raw);
98
+ }
99
+ catch {
100
+ throw new ReadError('Adresse invalide.');
101
+ }
102
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
103
+ throw new ReadError(`Protocole refusé : ${url.protocol}`);
104
+ if (url.username || url.password)
105
+ throw new ReadError('Adresse avec identifiants refusée.');
106
+ if (url.port && url.port !== '80' && url.port !== '443')
107
+ throw new ReadError(`Port refusé : ${url.port}. Seuls 80 et 443 sont lus.`);
108
+ const host = url.hostname.replace(/^\[|\]$/g, '');
109
+ if (net.isIP(host) && !publicAddress(host))
110
+ throw new ReadError(`Adresse refusée : ${host} est privée ou réservée.`);
111
+ if (/^(localhost|.*\.localhost|.*\.local|.*\.internal|.*\.lan|.*\.home\.arpa)$/i.test(host))
112
+ throw new ReadError(`Nom refusé : ${host} désigne une machine locale.`);
113
+ url.hash = '';
114
+ return url;
115
+ }
116
+ export const getOnce = (url, signal) => {
117
+ return new Promise((resolve, reject) => {
118
+ const client = url.protocol === 'https:' ? https : http;
119
+ const req = client.request(url, {
120
+ method: 'GET',
121
+ lookup: safeLookup,
122
+ agent: false,
123
+ headers: { 'User-Agent': USER_AGENT, Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1', 'Accept-Language': 'fr,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br' },
124
+ signal,
125
+ timeout: PAGE_TIMEOUT_MS,
126
+ }, (res) => {
127
+ const status = res.statusCode ?? 0;
128
+ if (status >= 300 && status < 400) {
129
+ res.resume();
130
+ return resolve({ status, headers: res.headers, body: Buffer.alloc(0) });
131
+ }
132
+ const encoding = String(res.headers['content-encoding'] ?? '').toLowerCase();
133
+ const stream = encoding === 'gzip' ? res.pipe(zlib.createGunzip()) : encoding === 'deflate' ? res.pipe(zlib.createInflate()) : encoding === 'br' ? res.pipe(zlib.createBrotliDecompress()) : res;
134
+ const chunks = [];
135
+ let size = 0;
136
+ stream.on('data', (chunk) => {
137
+ size += chunk.length;
138
+ if (size > MAX_PAGE_BYTES) {
139
+ req.destroy();
140
+ reject(new ReadError('Page trop lourde (plus de 3 Mo).'));
141
+ return;
142
+ }
143
+ chunks.push(chunk);
144
+ });
145
+ stream.on('end', () => resolve({ status, headers: res.headers, body: Buffer.concat(chunks) }));
146
+ stream.on('error', (err) => reject(err));
147
+ });
148
+ req.on('timeout', () => req.destroy(new ReadError(`La page n’a pas répondu en ${PAGE_TIMEOUT_MS / 1000} s.`)));
149
+ req.on('error', reject);
150
+ req.end();
151
+ });
152
+ };
153
+ /** Télécharge une page, redirections comprises, chaque étape vérifiée. get : la requête elle-même (remplacée dans les tests). */
154
+ export const pageFetcher = (get = getOnce) => async (raw, signal) => {
155
+ let url = checkUrl(raw);
156
+ for (let hop = 0;; hop++) {
157
+ let res;
158
+ try {
159
+ res = await get(url, signal);
160
+ }
161
+ catch (err) {
162
+ if (err instanceof ReadError)
163
+ throw err;
164
+ if (err.code === 'EPRIVATE')
165
+ throw new ReadError(err.message);
166
+ if (signal.aborted)
167
+ throw new ReadError('Lecture interrompue.');
168
+ throw new ReadError(`Page injoignable : ${err.code ?? err.message}`);
169
+ }
170
+ if (res.status >= 300 && res.status < 400) {
171
+ const location = res.headers.location;
172
+ if (!location)
173
+ throw new ReadError(`Redirection ${res.status} sans adresse.`);
174
+ if (hop >= MAX_REDIRECTS)
175
+ throw new ReadError('Trop de redirections.');
176
+ url = checkUrl(new URL(location, url).toString());
177
+ continue;
178
+ }
179
+ if (res.status !== 200)
180
+ throw new ReadError(`Le site a répondu ${res.status}.`);
181
+ const contentType = String(res.headers['content-type'] ?? '').toLowerCase();
182
+ if (!/^(text\/html|application\/xhtml\+xml|text\/plain)\b/.test(contentType))
183
+ throw new ReadError(`Pas une page de texte (${contentType.split(';')[0] || 'type inconnu'}).`);
184
+ return { url: url.toString(), contentType, body: res.body };
185
+ }
186
+ };
187
+ export const fetchPage = pageFetcher();
188
+ // ------------------------------------------------------------------ Extraction du texte
189
+ /** Jeu de caractères : en-tête, puis balise meta, sinon UTF-8. */
190
+ export function decodeBody(body, contentType) {
191
+ const head = body.subarray(0, 4096).toString('latin1');
192
+ const label = /charset=["']?([\w-]+)/i.exec(contentType)?.[1] ?? /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1] ?? 'utf-8';
193
+ try {
194
+ return new TextDecoder(label.toLowerCase()).decode(body);
195
+ }
196
+ catch {
197
+ return new TextDecoder('utf-8').decode(body);
198
+ }
199
+ }
200
+ const ENTITIES = {
201
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', shy: '',
202
+ laquo: '«', raquo: '»', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', sbquo: '‚', bdquo: '„', hellip: '…', ndash: '-', mdash: '-', middot: '·', bull: '•',
203
+ copy: '©', reg: '®', trade: '™', deg: '°', euro: '€', pound: '£', yen: '¥', cent: '¢', sect: '§', para: '¶', times: '×', divide: '÷', plusmn: '±', micro: 'µ',
204
+ agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë',
205
+ igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', oslash: 'ø', oelig: 'œ',
206
+ ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', yuml: 'ÿ', szlig: 'ß',
207
+ Agrave: 'À', Aacute: 'Á', Acirc: 'Â', Auml: 'Ä', Ccedil: 'Ç', Egrave: 'È', Eacute: 'É', Ecirc: 'Ê', Euml: 'Ë', Icirc: 'Î', Iuml: 'Ï', Ocirc: 'Ô', Ouml: 'Ö', OElig: 'Œ', Ugrave: 'Ù', Ucirc: 'Û', Uuml: 'Ü',
208
+ };
209
+ export function decodeEntities(s) {
210
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (all, name) => {
211
+ if (name[0] === '#') {
212
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
213
+ return code > 0 && code < 0x110000 && !(code >= 0xd800 && code < 0xe000) ? String.fromCodePoint(code) : '';
214
+ }
215
+ return ENTITIES[name] ?? ENTITIES[name.toLowerCase()] ?? all;
216
+ });
217
+ }
218
+ /** Balises dont le contenu n'est jamais du texte de la page. */
219
+ const DROP_WITH_CONTENT = /<(script|style|noscript|template|svg|math|iframe|object|canvas|select|textarea|button)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
220
+ /** Conteneurs de navigation et d'habillage, sautés avec tout ce qu'ils contiennent. */
221
+ const SKIP_TAGS = new Set(['nav', 'header', 'footer', 'aside', 'form', 'dialog', 'menu']);
222
+ /**
223
+ * Un mot de classe ou d'id qui désigne l'habillage : navigation, pied de page, bandeau de cookies, partage, publicité… Le mot
224
+ * doit être entier ou entre tirets (« site-footer », « cookie_banner »), pour qu'un « shared-content » ou un « loads » restent.
225
+ * Pas « menu » : python.org l'emploie pour la liste de ses versions, et les vrais menus sont déjà sautés avec nav et header.
226
+ */
227
+ const NOISE_TOKEN = /(^|[-_])(nav|navbar|navigation|breadcrumbs?|footer|sidebar|cookies?|consent|gdpr|share|sharing|social|newsletter|subscribe|advert|ads|promo|popup|modal|related)([-_]|$)/;
228
+ const NOISE_ROLE = /\brole=["']?(navigation|banner|contentinfo|complementary|search)\b/i;
229
+ /** Faut-il sauter ce bloc et tout son contenu ? Jamais pour article, main ou body : le texte principal y est. */
230
+ function noisy(name, attrs, strict) {
231
+ if (SKIP_TAGS.has(name))
232
+ return true;
233
+ if (!strict || name === 'article' || name === 'main' || name === 'body')
234
+ return false;
235
+ if (NOISE_ROLE.test(attrs))
236
+ return true;
237
+ for (const m of attrs.matchAll(/\b(?:class|id)=["']([^"']*)["']/gi))
238
+ for (const token of (m[1] ?? '').toLowerCase().split(/\s+/))
239
+ if (NOISE_TOKEN.test(token))
240
+ return true;
241
+ return false;
242
+ }
243
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
244
+ const BLOCK = new Set(['p', 'div', 'section', 'article', 'main', 'li', 'ul', 'ol', 'dl', 'dt', 'dd', 'tr', 'table', 'thead', 'tbody', 'pre', 'blockquote', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'hr', 'td', 'th', 'caption', 'summary', 'details', 'address']);
245
+ /** Le contenu principal : le plus long <article>, sinon <main>, sinon <body>, sinon tout. */
246
+ function mainPart(html) {
247
+ const articles = [...html.matchAll(/<article\b[^>]*>([\s\S]*?)<\/article\s*>/gi)].map((m) => m[1] ?? '');
248
+ const longest = articles.sort((a, b) => b.length - a.length)[0];
249
+ if (longest && longest.length > 2000)
250
+ return longest;
251
+ const main = /<main\b[^>]*>([\s\S]*)<\/main\s*>/i.exec(html)?.[1];
252
+ if (main && main.length > 1000)
253
+ return main;
254
+ return /<body\b[^>]*>([\s\S]*)<\/body\s*>/i.exec(html)?.[1] ?? html;
255
+ }
256
+ /**
257
+ * Le texte lisible d'une page HTML : titres, paragraphes, listes, tableaux, sans menus ni scripts. Si le tri des blocs
258
+ * d'habillage laisse moins de 300 caractères (classes mal choisies, HTML mal fermé), on recommence sans lui.
259
+ */
260
+ export function extractText(html) {
261
+ const strict = extractWith(html, true);
262
+ return strict.text.length >= 300 ? strict : extractWith(html, false);
263
+ }
264
+ function extractWith(html, strict) {
265
+ const title = decodeEntities(/<meta[^>]+property=["']og:title["'][^>]*content=["']([^"']+)/i.exec(html)?.[1] ?? /<title[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html)?.[1] ?? '').replace(/\s+/g, ' ').trim();
266
+ const lang = /<html[^>]*\blang=["']?([a-zA-Z-]{2,10})/i.exec(html)?.[1]?.toLowerCase() ?? null;
267
+ const description = decodeEntities(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']+)/i.exec(html)?.[1] ?? '').trim() || null;
268
+ const body = mainPart(html.replace(/<!--[\s\S]*?-->/g, ' ').replace(DROP_WITH_CONTENT, ' '));
269
+ const out = [];
270
+ const stack = [];
271
+ let skip = 0;
272
+ let last = 0;
273
+ const tag = /<\/?([a-zA-Z][\w:-]*)([^>]*)>/g;
274
+ const push = (text) => {
275
+ if (skip === 0 && text)
276
+ out.push(text);
277
+ };
278
+ for (let m = tag.exec(body); m; m = tag.exec(body)) {
279
+ push(body.slice(last, m.index));
280
+ last = m.index + m[0].length;
281
+ const name = (m[1] ?? '').toLowerCase();
282
+ const closing = m[0][1] === '/';
283
+ const attrs = m[2] ?? '';
284
+ if (VOID.has(name)) {
285
+ if (name === 'br' || name === 'hr')
286
+ push('\n');
287
+ continue;
288
+ }
289
+ if (!closing && !attrs.trimEnd().endsWith('/')) {
290
+ const skipped = noisy(name, attrs, strict);
291
+ stack.push(skipped ? `!${name}` : name);
292
+ if (skipped)
293
+ skip++;
294
+ if (BLOCK.has(name))
295
+ push(/^h[1-6]$/.test(name) ? '\n\n# ' : name === 'li' ? '\n- ' : '\n');
296
+ }
297
+ else if (closing) {
298
+ // On remonte jusqu'à la balise ouverte de même nom (HTML mal fermé toléré)
299
+ const at = stack.map((s) => s.replace(/^!/, '')).lastIndexOf(name);
300
+ if (at >= 0) {
301
+ for (const s of stack.splice(at))
302
+ if (s.startsWith('!'))
303
+ skip--;
304
+ }
305
+ if (BLOCK.has(name))
306
+ push(/^h[1-6]$/.test(name) ? '\n\n' : '\n');
307
+ if (name === 'td' || name === 'th')
308
+ push(' | ');
309
+ }
310
+ }
311
+ push(body.slice(last));
312
+ const lines = decodeEntities(out.join(''))
313
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200f\u2028\u2029\ufeff]/g, '')
314
+ .split('\n')
315
+ .map((l) => l.replace(/[ \t\u00a0]+/g, ' ').trim());
316
+ const kept = [];
317
+ for (const line of lines) {
318
+ if (!line || line === '-' || line === '#' || line === '|') {
319
+ if (kept.at(-1) !== '')
320
+ kept.push('');
321
+ continue;
322
+ }
323
+ if (line === kept.at(-1))
324
+ continue;
325
+ kept.push(line);
326
+ }
327
+ // Les suites de lignes très courtes (menus restés dans le corps) ne sont pas du texte : on les retire.
328
+ const paragraphs = kept.join('\n').split(/\n{2,}/).map((p) => p.trim());
329
+ const text = paragraphs
330
+ .filter((p) => {
331
+ const words = p.split(/\s+/).length;
332
+ return p.startsWith('# ') || words >= 6 || /[.!?:;]$/.test(p);
333
+ })
334
+ .join('\n\n')
335
+ .trim();
336
+ return { title, lang, description, text: text.slice(0, MAX_TEXT_CHARS) };
337
+ }
338
+ /** Un texte brut (text/plain) : lignes regroupées en paragraphes. */
339
+ export function plainText(raw) {
340
+ const text = raw.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').replace(/\n{3,}/g, '\n\n').trim();
341
+ return { title: text.split('\n')[0]?.slice(0, 120) ?? '', lang: null, description: null, text: text.slice(0, MAX_TEXT_CHARS) };
342
+ }
343
+ /** Lit une page et rend son texte. Moins de 300 caractères : la page n'a rien d'utile à retenir. */
344
+ export async function readPage(url, signal, fetcher = fetchPage) {
345
+ const page = await fetcher(url, signal);
346
+ const raw = decodeBody(page.body, page.contentType);
347
+ const extracted = page.contentType.startsWith('text/plain') ? plainText(raw) : extractText(raw);
348
+ if (extracted.text.length < 300)
349
+ throw new ReadError('Presque aucun texte lisible : page chargée par JavaScript, page de consentement ou accès refusé.');
350
+ return { ...extracted, url: page.url };
351
+ }
352
+ /**
353
+ * Découpe un texte en passages d'environ 1 000 caractères, sans couper un paragraphe quand c'est possible. Un intertitre
354
+ * ouvre un nouveau passage, sauf si le passage en cours est encore court : les petites parties sont regroupées (une page
355
+ * faite de titres et de deux lignes donnerait sinon des passages presque vides, que le classement favorise). Un passage
356
+ * qui ne commence pas par un intertitre reprend celui qui était en vigueur, pour qu'il se comprenne seul.
357
+ */
358
+ export function chunkText(text, size = 1000) {
359
+ const chunks = [];
360
+ const short = size * 0.4;
361
+ let heading = '';
362
+ let lead = '';
363
+ let current = '';
364
+ const flush = () => {
365
+ const body = current.trim();
366
+ if (body)
367
+ chunks.push(lead && !body.startsWith('# ') ? `${lead}\n${body}` : body);
368
+ current = '';
369
+ };
370
+ const add = (para) => {
371
+ if (!current)
372
+ lead = heading;
373
+ current += `${para}\n\n`;
374
+ };
375
+ for (const para of text.split(/\n{2,}/)) {
376
+ if (para.startsWith('# ')) {
377
+ if (current.length >= short)
378
+ flush();
379
+ heading = para.slice(0, 160);
380
+ add(heading);
381
+ continue;
382
+ }
383
+ if (para.length > size * 1.5) {
384
+ if (current.length >= short)
385
+ flush();
386
+ // Paragraphe très long : coupé aux fins de phrase
387
+ let piece = '';
388
+ for (const sentence of para.split(/(?<=[.!?])\s+/)) {
389
+ if ((current + piece + sentence).length > size && piece) {
390
+ add(piece.trim());
391
+ flush();
392
+ piece = '';
393
+ }
394
+ piece += `${sentence} `;
395
+ }
396
+ if (piece.trim())
397
+ add(piece.trim());
398
+ continue;
399
+ }
400
+ if ((current + para).length > size && current.length >= short)
401
+ flush();
402
+ add(para);
403
+ }
404
+ flush();
405
+ return chunks.map((c) => c.slice(0, size * 2));
406
+ }
@@ -0,0 +1,115 @@
1
+ import { queryTerms } from './base.js';
2
+ export const READ_PAGES = 3;
3
+ export const MAX_EXTRACTS = 6;
4
+ export const EXTRACT_CHARS = 1200;
5
+ export const EXTRACTS_TOTAL = 6000;
6
+ /** Temps laissé à la lecture des pages, toutes en même temps, avant de répondre avec ce qui est arrivé. */
7
+ export const READ_BUDGET_MS = 15_000;
8
+ /** Sites qui ne montrent leur texte qu'avec JavaScript ou derrière un compte : pas la peine de les télécharger. */
9
+ const UNREADABLE_HOST = /(^|\.)(youtube\.com|youtu\.be|x\.com|twitter\.com|facebook\.com|instagram\.com|tiktok\.com|linkedin\.com)$/i;
10
+ const NOT_TEXT = /\.(pdf|docx?|xlsx?|pptx?|odt|zip|rar|7z|exe|msi|mp3|mp4|avi|mkv|jpe?g|png|gif|webp)$/i;
11
+ /** Les premières sources qui ont une chance d'être lisibles : pages web, hors vidéos, réseaux sociaux et fichiers. */
12
+ export function pagesToRead(sources, count = READ_PAGES) {
13
+ const picked = [];
14
+ for (const s of sources) {
15
+ let url;
16
+ try {
17
+ url = new URL(s.url);
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
23
+ continue;
24
+ if (UNREADABLE_HOST.test(url.hostname) || NOT_TEXT.test(url.pathname))
25
+ continue;
26
+ if (picked.some((p) => p.url === s.url))
27
+ continue;
28
+ picked.push(s);
29
+ if (picked.length >= count)
30
+ break;
31
+ }
32
+ return picked;
33
+ }
34
+ /** Coupe un passage à max caractères, à la fin d'une phrase ou d'un mot. */
35
+ export function cutPassage(text, max = EXTRACT_CHARS) {
36
+ const clean = text.trim();
37
+ if (clean.length <= max)
38
+ return clean;
39
+ const head = clean.slice(0, max);
40
+ const sentence = Math.max(head.lastIndexOf('. '), head.lastIndexOf('.\n'), head.lastIndexOf('? '), head.lastIndexOf('! '));
41
+ if (sentence > max * 0.6)
42
+ return head.slice(0, sentence + 1);
43
+ const space = head.lastIndexOf(' ');
44
+ return `${head.slice(0, space > max * 0.6 ? space : max)} […]`;
45
+ }
46
+ /** Prend les passages dans l'ordre de priorité, dans le budget, puis les range par numéro et par position dans la page. */
47
+ function fill(candidates) {
48
+ const kept = [];
49
+ let total = 0;
50
+ for (const c of candidates) {
51
+ if (kept.length >= MAX_EXTRACTS)
52
+ break;
53
+ if (kept.some((k) => k.pageId === c.hit.pageId && k.ord === c.hit.ord))
54
+ continue;
55
+ const room = Math.min(EXTRACT_CHARS, EXTRACTS_TOTAL - total);
56
+ if (room < 300)
57
+ break;
58
+ const text = cutPassage(c.hit.text, room);
59
+ total += text.length;
60
+ kept.push({ n: c.n, pageId: c.hit.pageId, url: c.hit.url, title: c.hit.title, host: c.hit.host, learnedAt: c.hit.learnedAt, fresh: c.fresh, text, ord: c.hit.ord });
61
+ }
62
+ return kept.sort((a, b) => a.n - b.n || a.ord - b.ord).map(({ ord: _ord, ...extract }) => extract);
63
+ }
64
+ /**
65
+ * Les passages donnés au modèle après une recherche. question : la demande et la requête, pour choisir dans les pages lues ;
66
+ * query : la requête seule, plus nette, pour chercher dans le savoir plus ancien ; next : premier numéro libre après les sources.
67
+ */
68
+ export function searchExtracts(savoir, read, question, query, next) {
69
+ const best = [];
70
+ const openings = [];
71
+ const second = [];
72
+ const minFound = Math.min(2, queryTerms(question).length);
73
+ for (const page of read) {
74
+ const hits = savoir.search(question, { only: [page.pageId], perPage: 2, limit: 2, minFound });
75
+ // Le début de la page compte toujours : le moteur l'a classée sur son titre et son haut de page, où la réponse se
76
+ // trouve souvent (la liste des versions de python.org, le chapeau d'un article), même sans les mots de la question.
77
+ const [start] = savoir.opening(page.pageId, 1);
78
+ const [first, next2] = hits;
79
+ if (first)
80
+ best.push({ hit: first, n: page.n, fresh: page.fresh });
81
+ if (start && start.ord !== first?.ord)
82
+ (first ? openings : best).push({ hit: start, n: page.n, fresh: page.fresh });
83
+ if (next2)
84
+ second.push({ hit: next2, n: page.n, fresh: page.fresh });
85
+ }
86
+ const terms = queryTerms(query);
87
+ const older = [];
88
+ if (terms.length > 0) {
89
+ const hits = savoir.search(query, { except: read.map((p) => p.pageId), minRatio: 0.5, minFound: Math.min(2, terms.length), perPage: 1, limit: 2 });
90
+ for (const hit of hits)
91
+ older.push({ hit, n: next++, fresh: false });
92
+ }
93
+ const extracts = fill([...best, ...older, ...openings, ...second]);
94
+ savoir.used(extracts.map((e) => e.pageId));
95
+ return extracts;
96
+ }
97
+ /**
98
+ * Rappel sans recherche web : des passages seulement s'ils couvrent au moins 60 % des mots de la question, et deux mots
99
+ * au moins quand elle en a deux. Numérotés de 1 à n, une page par numéro.
100
+ */
101
+ export function recallExtracts(savoir, question) {
102
+ const terms = queryTerms(question);
103
+ if (terms.length === 0)
104
+ return [];
105
+ const hits = savoir.search(question, { minRatio: 0.6, minFound: Math.min(2, terms.length), perPage: 2, limit: 4 });
106
+ const numbers = new Map();
107
+ const candidates = hits.map((hit) => {
108
+ if (!numbers.has(hit.pageId))
109
+ numbers.set(hit.pageId, numbers.size + 1);
110
+ return { hit, n: numbers.get(hit.pageId) ?? 1, fresh: false };
111
+ });
112
+ const extracts = fill(candidates);
113
+ savoir.used(extracts.map((e) => e.pageId));
114
+ return extracts;
115
+ }