SPB Git

spb/trouve-ka Public

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

Python 76.8% TypeScript 15.7% SQL 3.9% Shell 1.4% CSS 1.3% Dockerfile 0.7%

Trouve-KA — moteur de recherche Québec-first (crawler, index, ranking, API, web)

Moteur complet : crawler distribué poli (robots.txt, SSRF, pièges), classification
Québec (page + domaine), indexation immédiate OpenSearch (cherchable en ~2 s),
ranking BM25 bilingue + function_score Québec-first, galerie d'images, web Next.js
au design Groupe KA, déployé sur m2m32 + satellites via www.trouve-ka.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Aug 13, 2026)

Showing 151 changed files with +20,001 and −0

added .env.example +34 −0
@@ -0,0 +1,34 @@
1 +# Trouve-KA — variables d'environnement (exemple, sans secrets)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +# --- Bases de données ---
6 +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka
7 +REDIS_URL=redis://localhost:6379/0
8 +SEARCH_URL=http://localhost:9200
9 +
10 +# --- Crawler ---
11 +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)"
12 +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot
13 +MAX_GLOBAL_CONCURRENCY=24
14 +MAX_PER_HOST_CONCURRENCY=2
15 +DEFAULT_HOST_DELAY=2.0
16 +MAX_RESPONSE_BYTES=3000000
17 +MAX_REDIRECTS=5
18 +FETCH_TIMEOUT=20
19 +MAX_CRAWL_DEPTH=8
20 +MAX_LINKS_PER_PAGE=300
21 +MAX_URLS_PER_DOMAIN=5000
22 +
23 +# --- API ---
24 +API_HOST=0.0.0.0
25 +API_PORT=8080
26 +ADMIN_TOKEN=change-me-admin-token
27 +
28 +# --- Web ---
29 +PUBLIC_URL=https://www.trouve-ka.com
30 +NGROK_DOMAIN=www.trouve-ka.com
31 +API_URL=http://localhost:8080
32 +
33 +# --- Index ---
34 +SEARCH_INDEX=trouveka-docs
added .gitignore +13 −0
@@ -0,0 +1,13 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +__pycache__/
5 +*.pyc
6 +.venv/
7 +.env
8 +.env.local
9 +*.egg-info/
10 +.pytest_cache/
11 +.ruff_cache/
12 +data/
13 +.DS_Store
added CLAUDE.md +363 −0
@@ -0,0 +1,363 @@
1 +# CLAUDE.md — Trouve-KA
2 +
3 +**Projet :** Trouve-KA — *Cherche le Québec.*
4 +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique.
5 +
6 +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois.
7 +
8 +```
9 +Crawler → Frontier → Fetcher → Parser → Classification Québec
10 +→ Déduplication → Indexer → Index → Ranking → API → Web App
11 +```
12 +
13 +Le crawler fait partie du produit. Tout vit dans un seul monorepo.
14 +
15 +---
16 +
17 +## 0. Règles non négociables
18 +
19 +### 0.1 Header obligatoire dans chaque fichier de code
20 +
21 +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur :
22 +
23 +```ts
24 +/**
25 + * Trouve-KA — <nom du module>
26 + * Author: Simon-Pierre Boucher
27 + * Contact: contact@spboucher.ai
28 + */
29 +```
30 +
31 +```python
32 +# Trouve-KA — <nom du module>
33 +# Author: Simon-Pierre Boucher
34 +# Contact: contact@spboucher.ai
35 +```
36 +
37 +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header.
38 +
39 +### 0.2 Cible de déploiement
40 +
41 +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine.
42 +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`).
43 +- Docker Compose est l'outil de déploiement. Pas de Kubernetes.
44 +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README.
45 +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement.
46 +
47 +### 0.3 Principe cardinal
48 +
49 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.**
50 +
51 +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes.
52 +
53 +```
54 +12:00:00 démarrage crawler
55 +12:00:04 première page fetchée
56 +12:00:06 score Québec calculé
57 +12:00:07 document indexé
58 +12:00:08 cherchable par l'utilisateur
59 +```
60 +
61 +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup).
62 +
63 +---
64 +
65 +## 1. Philosophie produit
66 +
67 +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique.
68 +
69 +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu.
70 +
71 +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local.
72 +
73 +---
74 +
75 +## 2. Architecture du dépôt
76 +
77 +Monorepo :
78 +
79 +```
80 +trouve-ka/
81 +├── apps/
82 +│ ├── web/ # moteur public
83 +│ ├── api/ # search + APIs internes
84 +│ └── admin/ # dashboard crawl/index
85 +├── services/
86 +│ ├── crawler/ frontier/ parser/ classifier/
87 +│ ├── indexer/ ranking/ scheduler/ enrichment/
88 +├── packages/
89 +│ ├── database/ shared/ config/ logging/
90 +│ ├── queue/ types/ search-core/
91 +├── infrastructure/
92 +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32
93 +├── scripts/
94 +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/
95 +├── docs/
96 +├── CLAUDE.md README.md docker-compose.yml
97 +```
98 +
99 +Claude peut améliorer cette structure sur justification technique solide.
100 +
101 +---
102 +
103 +## 3. Stack technologique
104 +
105 +| Couche | Choix |
106 +|---|---|
107 +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui |
108 +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS |
109 +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) |
110 +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) |
111 +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit |
112 +
113 +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut.
114 +
115 +---
116 +
117 +## 4. Qualité de recherche progressive
118 +
119 +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.**
120 +
121 +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25.
122 +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique.
123 +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale.
124 +
125 +---
126 +
127 +## 5. Crawler
128 +
129 +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements.
130 +
131 +### 5.1 Identité
132 +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli.
133 +
134 +### 5.2 robots.txt et politesse
135 +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel.
136 +
137 +### 5.3 Frontier
138 +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication.
139 +
140 +Fonction de priorité conceptuelle :
141 +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S`
142 +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés.
143 +
144 +### 5.4 Canonicalisation d'URL
145 +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.**
146 +
147 +### 5.5 Recrawl adaptatif
148 +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓.
149 +
150 +### 5.6 Détection de changement et doublons
151 +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance.
152 +
153 +### 5.7 Sécurité du crawl (SSRF)
154 +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max.
155 +
156 +### 5.8 Pièges de crawl
157 +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine.
158 +
159 +### 5.9 Erreurs
160 +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué.
161 +
162 +### 5.10 Rendu navigateur
163 +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32.
164 +
165 +---
166 +
167 +## 6. Extraction de contenu
168 +
169 +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse.
170 +
171 +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub.
172 +
173 +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML.
174 +
175 +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants.
176 +
177 +---
178 +
179 +## 7. Détection Québec
180 +
181 +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** :
182 +
183 +- `domain_quebec_score`
184 +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit)
185 +
186 +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer.
187 +
188 +---
189 +
190 +## 8. Découverte
191 +
192 +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises.
193 +
194 +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle.
195 +
196 +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…`
197 +
198 +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA.
199 +
200 +---
201 +
202 +## 9. Index et ranking
203 +
204 +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`.
205 +
206 +**BM25 d'abord.** Puis fonction de ranking dédiée :
207 +
208 +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam`
209 +
210 +Le ranking est un package/service indépendant, évolutif, aux poids non figés.
211 +
212 +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale.
213 +
214 +**Bilingue dès le départ :** comprendre `thermopompe``heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes.
215 +
216 +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.**
217 +
218 +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching.
219 +
220 +---
221 +
222 +## 10. Interface
223 +
224 +**Homepage = la boîte de recherche.** Rien d'autre d'important.
225 +
226 +```
227 + Trouve-KA
228 + [ Rechercher... ]
229 + Chercher
230 + Cherche le Québec.
231 +```
232 +
233 +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement.
234 +
235 +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index.
236 +
237 +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl.
238 +
239 +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA.
240 +
241 +---
242 +
243 +## 11. APIs
244 +
245 +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod.
246 +
247 +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine.
248 +
249 +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide.
250 +
251 +---
252 +
253 +## 12. Coûts, LLM et embeddings
254 +
255 +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants.
256 +
257 +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres.
258 +
259 +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs.
260 +
261 +---
262 +
263 +## 13. Observabilité et dégradation
264 +
265 +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique).
266 +
267 +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue.
268 +
269 +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ.
270 +
271 +---
272 +
273 +## 14. Provenance, droit d'auteur, avenir
274 +
275 +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu.
276 +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément.
277 +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives.
278 +
279 +---
280 +
281 +## 15. Configuration et DX
282 +
283 +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets.
284 +
285 +Expérience développeur cible :
286 +
287 +```
288 +git clone … && cd trouve-ka
289 +cp .env.example .env
290 +docker compose up -d
291 +pnpm install && pnpm dev
292 +pnpm crawl:seed
293 +# → des résultats apparaissent en quelques instants sur localhost:3000
294 +```
295 +
296 +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté.
297 +
298 +---
299 +
300 +## 16. Tests et évaluation
301 +
302 +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche.
303 +
304 +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche.
305 +
306 +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions.
307 +
308 +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée.
309 +
310 +---
311 +
312 +## 17. Ordre d'implémentation
313 +
314 +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique.
315 +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.**
316 +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots.
317 +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines.
318 +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête.
319 +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring.
320 +7. **Couche sémantique** — embeddings, hybride, reranking, entités.
321 +8. **Écosystème KA.**
322 +
323 +---
324 +
325 +## 18. Milestone critique (non négociable)
326 +
327 +La première implémentation n'est réussie que si Claude démontre :
328 +
329 +1. démarrage du stack;
330 +2. seed de sites québécois;
331 +3. le crawler découvre des URLs;
332 +4. télécharge des pages;
333 +5. le parser extrait du texte utile;
334 +6. les pages reçoivent un score Québec;
335 +7. insertion **immédiate** dans l'index;
336 +8. l'app web publique cherche ces pages;
337 +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**;
338 +10. le frontier continue de découvrir des sites.
339 +
340 +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert.
341 +
342 +---
343 +
344 +## 19. Méthode de travail de Claude
345 +
346 +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative).
347 +
348 +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant.
349 +
350 +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1).
351 +
352 +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid.
353 +
354 +---
355 +
356 +## 20. Vision
357 +
358 +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**.
359 +
360 +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.**
361 +
362 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
363 +> Le web ne « finit » jamais. Trouve-KA non plus.
added M2M32brouve-ka/.env.example +34 −0
@@ -0,0 +1,34 @@
1 +# Trouve-KA — variables d'environnement (exemple, sans secrets)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +# --- Bases de données ---
6 +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka
7 +REDIS_URL=redis://localhost:6379/0
8 +SEARCH_URL=http://localhost:9200
9 +
10 +# --- Crawler ---
11 +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)"
12 +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot
13 +MAX_GLOBAL_CONCURRENCY=24
14 +MAX_PER_HOST_CONCURRENCY=2
15 +DEFAULT_HOST_DELAY=2.0
16 +MAX_RESPONSE_BYTES=3000000
17 +MAX_REDIRECTS=5
18 +FETCH_TIMEOUT=20
19 +MAX_CRAWL_DEPTH=8
20 +MAX_LINKS_PER_PAGE=300
21 +MAX_URLS_PER_DOMAIN=5000
22 +
23 +# --- API ---
24 +API_HOST=0.0.0.0
25 +API_PORT=8080
26 +ADMIN_TOKEN=change-me-admin-token
27 +
28 +# --- Web ---
29 +PUBLIC_URL=https://www.trouve-ka.com
30 +NGROK_DOMAIN=www.trouve-ka.com
31 +API_URL=http://localhost:8080
32 +
33 +# --- Index ---
34 +SEARCH_INDEX=trouveka-docs
added M2M32brouve-ka/.gitignore +13 −0
@@ -0,0 +1,13 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +__pycache__/
5 +*.pyc
6 +.venv/
7 +.env
8 +.env.local
9 +*.egg-info/
10 +.pytest_cache/
11 +.ruff_cache/
12 +data/
13 +.DS_Store
added M2M32brouve-ka/CLAUDE.md +363 −0
@@ -0,0 +1,363 @@
1 +# CLAUDE.md — Trouve-KA
2 +
3 +**Projet :** Trouve-KA — *Cherche le Québec.*
4 +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique.
5 +
6 +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois.
7 +
8 +```
9 +Crawler → Frontier → Fetcher → Parser → Classification Québec
10 +→ Déduplication → Indexer → Index → Ranking → API → Web App
11 +```
12 +
13 +Le crawler fait partie du produit. Tout vit dans un seul monorepo.
14 +
15 +---
16 +
17 +## 0. Règles non négociables
18 +
19 +### 0.1 Header obligatoire dans chaque fichier de code
20 +
21 +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur :
22 +
23 +```ts
24 +/**
25 + * Trouve-KA — <nom du module>
26 + * Author: Simon-Pierre Boucher
27 + * Contact: contact@spboucher.ai
28 + */
29 +```
30 +
31 +```python
32 +# Trouve-KA — <nom du module>
33 +# Author: Simon-Pierre Boucher
34 +# Contact: contact@spboucher.ai
35 +```
36 +
37 +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header.
38 +
39 +### 0.2 Cible de déploiement
40 +
41 +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine.
42 +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`).
43 +- Docker Compose est l'outil de déploiement. Pas de Kubernetes.
44 +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README.
45 +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement.
46 +
47 +### 0.3 Principe cardinal
48 +
49 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.**
50 +
51 +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes.
52 +
53 +```
54 +12:00:00 démarrage crawler
55 +12:00:04 première page fetchée
56 +12:00:06 score Québec calculé
57 +12:00:07 document indexé
58 +12:00:08 cherchable par l'utilisateur
59 +```
60 +
61 +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup).
62 +
63 +---
64 +
65 +## 1. Philosophie produit
66 +
67 +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique.
68 +
69 +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu.
70 +
71 +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local.
72 +
73 +---
74 +
75 +## 2. Architecture du dépôt
76 +
77 +Monorepo :
78 +
79 +```
80 +trouve-ka/
81 +├── apps/
82 +│ ├── web/ # moteur public
83 +│ ├── api/ # search + APIs internes
84 +│ └── admin/ # dashboard crawl/index
85 +├── services/
86 +│ ├── crawler/ frontier/ parser/ classifier/
87 +│ ├── indexer/ ranking/ scheduler/ enrichment/
88 +├── packages/
89 +│ ├── database/ shared/ config/ logging/
90 +│ ├── queue/ types/ search-core/
91 +├── infrastructure/
92 +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32
93 +├── scripts/
94 +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/
95 +├── docs/
96 +├── CLAUDE.md README.md docker-compose.yml
97 +```
98 +
99 +Claude peut améliorer cette structure sur justification technique solide.
100 +
101 +---
102 +
103 +## 3. Stack technologique
104 +
105 +| Couche | Choix |
106 +|---|---|
107 +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui |
108 +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS |
109 +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) |
110 +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) |
111 +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit |
112 +
113 +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut.
114 +
115 +---
116 +
117 +## 4. Qualité de recherche progressive
118 +
119 +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.**
120 +
121 +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25.
122 +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique.
123 +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale.
124 +
125 +---
126 +
127 +## 5. Crawler
128 +
129 +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements.
130 +
131 +### 5.1 Identité
132 +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli.
133 +
134 +### 5.2 robots.txt et politesse
135 +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel.
136 +
137 +### 5.3 Frontier
138 +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication.
139 +
140 +Fonction de priorité conceptuelle :
141 +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S`
142 +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés.
143 +
144 +### 5.4 Canonicalisation d'URL
145 +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.**
146 +
147 +### 5.5 Recrawl adaptatif
148 +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓.
149 +
150 +### 5.6 Détection de changement et doublons
151 +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance.
152 +
153 +### 5.7 Sécurité du crawl (SSRF)
154 +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max.
155 +
156 +### 5.8 Pièges de crawl
157 +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine.
158 +
159 +### 5.9 Erreurs
160 +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué.
161 +
162 +### 5.10 Rendu navigateur
163 +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32.
164 +
165 +---
166 +
167 +## 6. Extraction de contenu
168 +
169 +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse.
170 +
171 +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub.
172 +
173 +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML.
174 +
175 +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants.
176 +
177 +---
178 +
179 +## 7. Détection Québec
180 +
181 +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** :
182 +
183 +- `domain_quebec_score`
184 +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit)
185 +
186 +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer.
187 +
188 +---
189 +
190 +## 8. Découverte
191 +
192 +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises.
193 +
194 +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle.
195 +
196 +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…`
197 +
198 +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA.
199 +
200 +---
201 +
202 +## 9. Index et ranking
203 +
204 +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`.
205 +
206 +**BM25 d'abord.** Puis fonction de ranking dédiée :
207 +
208 +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam`
209 +
210 +Le ranking est un package/service indépendant, évolutif, aux poids non figés.
211 +
212 +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale.
213 +
214 +**Bilingue dès le départ :** comprendre `thermopompe``heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes.
215 +
216 +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.**
217 +
218 +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching.
219 +
220 +---
221 +
222 +## 10. Interface
223 +
224 +**Homepage = la boîte de recherche.** Rien d'autre d'important.
225 +
226 +```
227 + Trouve-KA
228 + [ Rechercher... ]
229 + Chercher
230 + Cherche le Québec.
231 +```
232 +
233 +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement.
234 +
235 +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index.
236 +
237 +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl.
238 +
239 +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA.
240 +
241 +---
242 +
243 +## 11. APIs
244 +
245 +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod.
246 +
247 +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine.
248 +
249 +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide.
250 +
251 +---
252 +
253 +## 12. Coûts, LLM et embeddings
254 +
255 +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants.
256 +
257 +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres.
258 +
259 +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs.
260 +
261 +---
262 +
263 +## 13. Observabilité et dégradation
264 +
265 +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique).
266 +
267 +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue.
268 +
269 +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ.
270 +
271 +---
272 +
273 +## 14. Provenance, droit d'auteur, avenir
274 +
275 +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu.
276 +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément.
277 +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives.
278 +
279 +---
280 +
281 +## 15. Configuration et DX
282 +
283 +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets.
284 +
285 +Expérience développeur cible :
286 +
287 +```
288 +git clone … && cd trouve-ka
289 +cp .env.example .env
290 +docker compose up -d
291 +pnpm install && pnpm dev
292 +pnpm crawl:seed
293 +# → des résultats apparaissent en quelques instants sur localhost:3000
294 +```
295 +
296 +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté.
297 +
298 +---
299 +
300 +## 16. Tests et évaluation
301 +
302 +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche.
303 +
304 +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche.
305 +
306 +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions.
307 +
308 +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée.
309 +
310 +---
311 +
312 +## 17. Ordre d'implémentation
313 +
314 +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique.
315 +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.**
316 +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots.
317 +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines.
318 +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête.
319 +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring.
320 +7. **Couche sémantique** — embeddings, hybride, reranking, entités.
321 +8. **Écosystème KA.**
322 +
323 +---
324 +
325 +## 18. Milestone critique (non négociable)
326 +
327 +La première implémentation n'est réussie que si Claude démontre :
328 +
329 +1. démarrage du stack;
330 +2. seed de sites québécois;
331 +3. le crawler découvre des URLs;
332 +4. télécharge des pages;
333 +5. le parser extrait du texte utile;
334 +6. les pages reçoivent un score Québec;
335 +7. insertion **immédiate** dans l'index;
336 +8. l'app web publique cherche ces pages;
337 +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**;
338 +10. le frontier continue de découvrir des sites.
339 +
340 +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert.
341 +
342 +---
343 +
344 +## 19. Méthode de travail de Claude
345 +
346 +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative).
347 +
348 +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant.
349 +
350 +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1).
351 +
352 +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid.
353 +
354 +---
355 +
356 +## 20. Vision
357 +
358 +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**.
359 +
360 +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.**
361 +
362 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
363 +> Le web ne « finit » jamais. Trouve-KA non plus.
added M2M32brouve-ka/README.md +166 −0
@@ -0,0 +1,166 @@
1 +# Trouve-KA — Cherche le Québec.
2 +
3 +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre
4 +index, son propre ranking, son API et son application web publique. Pas un métamoteur :
5 +aucune dépendance à Google, Bing ou Brave pour les résultats.
6 +
7 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
8 +
9 +En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok).
10 +
11 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
12 +
13 +---
14 +
15 +## Ce que c'est
16 +
17 +```
18 +Crawler → Frontier → Fetcher → Parser → Classification Québec
19 +→ Déduplication → Indexer → Index → Ranking → API → Web App
20 +```
21 +
22 +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement,
23 +municipalités, universités, médias), suit les liens sortants, juge la pertinence
24 +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`),
25 +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes,
26 +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités,
27 +embeddings) arrive après, en asynchrone, sans jamais bloquer.
28 +
29 +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et
30 +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres
31 +comme frontier, etc.).
32 +
33 +## Stack
34 +
35 +| Couche | Choix |
36 +|---|---|
37 +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) |
38 +| API | FastAPI (Python 3.12) |
39 +| Pipeline | Python 3.12 async — httpx, selectolax, Protego |
40 +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) |
41 +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) |
42 +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) |
43 +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) |
44 +
45 +## Layout du monorepo
46 +
47 +```
48 +apps/web # moteur public + dashboard /admin (Next.js)
49 +apps/api # FastAPI (trouveka.api)
50 +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment
51 +packages/ # config, database, logging, queue, search-core, shared, types
52 +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok)
53 +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py
54 +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking
55 +```
56 +
57 +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout
58 +(voir `pyproject.toml`).
59 +
60 +## Démarrage rapide (dev)
61 +
62 +```bash
63 +git clone && cd trouve-ka
64 +cp .env.example .env
65 +docker compose up -d postgres redis opensearch # infra
66 +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python
67 +pnpm install
68 +
69 +pnpm crawl:seed # migrations + 60+ seeds québécoises
70 +bash scripts/start-crawler/start.sh & # le crawl démarre
71 +.venv/bin/uvicorn trouveka.api.main:app --port 8080 &
72 +pnpm dev # → http://localhost:3000
73 +# → des résultats apparaissent en quelques secondes
74 +```
75 +
76 +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`,
77 +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes).
78 +
79 +Stack complet en containers : `docker compose up -d --build` (le service `migrate`
80 +applique les migrations, `crawler-worker` se scale avec
81 +`docker compose up -d --scale crawler-worker=3`).
82 +
83 +## Comment ça marche
84 +
85 +### Crawl et politesse
86 +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`,
87 + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth.
88 +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés.
89 +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté),
90 + quel que soit le nombre de workers.
91 +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque
92 + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges
93 + (session IDs, calendriers infinis, facettes explosives, pagination sans fin).
94 +
95 +### Indexation incrémentale
96 +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté
97 +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu +
98 +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2).
99 +
100 +### Détection Québec
101 +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec),
102 +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes
103 +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ,
104 +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve).
105 +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12).
106 +
107 +### Ranking
108 +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes
109 +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine
110 +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents
111 +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul.
112 +
113 +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080`
114 +(dataset dans `scripts/eval/ranking-eval.yaml`).
115 +
116 +## API
117 +
118 +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=`
119 +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler)
120 +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion)
121 +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live,
122 + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier
123 +
124 +## Tests
125 +
126 +```bash
127 +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking
128 +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI)
129 +bash scripts/health-check/check.sh # santé du stack
130 +```
131 +
132 +## Déploiement m2m32 + ngrok (www.trouve-ka.com)
133 +
134 +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié,
135 +domaine `www.trouve-ka.com` réservé dans le compte ngrok.
136 +
137 +```bash
138 +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh
139 +```
140 +
141 +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds
142 +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check.
143 +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues
144 +côté client, cookies/CORS sans surprise derrière le tunnel.
145 +
146 +Opérations courantes sur le node :
147 +
148 +```bash
149 +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct
150 +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3'
151 +```
152 +
153 +## Observabilité
154 +
155 +Logs JSON structurés par service, métriques réelles via `/api/status` et
156 +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences
157 +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md).
158 +
159 +## Règles du dépôt
160 +
161 +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par
162 + `scripts/check-headers.py`.
163 +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés;
164 + les fixtures vivent dans `tests/` uniquement.
165 +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source.
166 + Trouve-KA renvoie vers les éditeurs originaux.
added M2M32brouve-ka/apps/api/__init__.py +5 −0
@@ -0,0 +1,5 @@
1 +# Trouve-KA — application API
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée)."""
added M2M32brouve-ka/apps/api/main.py +297 −0
@@ -0,0 +1,297 @@
1 +# Trouve-KA — API FastAPI
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API de Trouve-KA.
6 +
7 +Publique : /api/search, /api/status, /api/submit, /api/health
8 +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth.
9 +
10 +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même;
11 +si Postgres tombe, la recherche répond quand même (analytics sautées).
12 +"""
13 +
14 +import html
15 +import re
16 +import time
17 +from contextlib import asynccontextmanager
18 +from typing import Annotated
19 +
20 +from fastapi import Depends, FastAPI, Header, HTTPException, Query
21 +from fastapi.middleware.cors import CORSMiddleware
22 +from pydantic import BaseModel
23 +
24 +from trouveka.config import get_settings
25 +from trouveka.database import Database
26 +from trouveka.logging import get_logger
27 +from trouveka.queue import Coordination
28 +from trouveka.ranking import build_search_body
29 +from trouveka.search_core import SearchCore
30 +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url
31 +
32 +log = get_logger("api")
33 +settings = get_settings()
34 +
35 +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max)
36 +coord = Coordination(settings.redis_url)
37 +search = SearchCore(settings.search_url, settings.search_index)
38 +
39 +
40 +@asynccontextmanager
41 +async def lifespan(_app: FastAPI):
42 + await db.connect()
43 + await search.ensure_index()
44 + yield
45 + await search.close()
46 + await coord.close()
47 + await db.close()
48 +
49 +
50 +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan)
51 +
52 +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile
53 +# en prod, mais pratique en dev local (web sur :3000, API sur :8080).
54 +app.add_middleware(
55 + CORSMiddleware,
56 + allow_origins=["http://localhost:3000", settings.public_url],
57 + allow_methods=["GET", "POST"],
58 + allow_headers=["*", "X-Admin-Token"],
59 +)
60 +
61 +
62 +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None:
63 + if not x_admin_token or x_admin_token != settings.admin_token:
64 + raise HTTPException(status_code=401, detail="Jeton admin invalide")
65 +
66 +
67 +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>")
68 +
69 +
70 +def _safe_snippet(fragments: list[str]) -> str:
71 + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch."""
72 + return _TAG_RE.sub("", " … ".join(fragments))[:400]
73 +
74 +
75 +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"}
76 +
77 +
78 +# ---------------------------------------------------------------------- publique
79 +
80 +@app.get("/api/health")
81 +async def health():
82 + return {"ok": True, "search_ok": await search.ping()}
83 +
84 +
85 +@app.get("/api/search")
86 +async def api_search(
87 + q: str = Query(..., min_length=1, max_length=200),
88 + page: int = Query(1, ge=1, le=100),
89 + limit: int = Query(10, ge=1, le=50),
90 + language: str | None = Query(None, pattern="^(fr|en)$"),
91 + location: str | None = None,
92 + category: str | None = Query(None, max_length=40),
93 + quebec_only: bool = False,
94 + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"),
95 +):
96 + started = time.monotonic()
97 + query_text = f"{q} {location}" if location else q
98 + body, analysis = build_search_body(
99 + query_text, page=page, limit=limit, language=language,
100 + category=category, quebec_only=quebec_only, freshness=freshness,
101 + )
102 + try:
103 + res = await search.search(body)
104 + except Exception:
105 + log.exception("recherche échouée", extra={"ctx": {"q": q}})
106 + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible")
107 +
108 + took_ms = int((time.monotonic() - started) * 1000)
109 + results = []
110 + for hit in res["hits"]["hits"]:
111 + src = hit["_source"]
112 + highlight = hit.get("highlight", {})
113 + fragments = highlight.get("body") or highlight.get("description") or []
114 + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400]
115 + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS]
116 + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6:
117 + badges.insert(0, "Québec")
118 + results.append({
119 + "title": src.get("title") or src["url"],
120 + "url": src["url"],
121 + "display_url": display_url(src["url"]),
122 + "snippet": snippet,
123 + "domain": src["domain"],
124 + "language": src.get("language"),
125 + "quebec_score": src.get("page_quebec_score", 0),
126 + "badges": badges,
127 + "published_at": src.get("published_at"),
128 + })
129 +
130 + total = res["hits"]["total"]["value"]
131 + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes
132 + try:
133 + await db.record_search_query(q, analysis["language"], total, took_ms)
134 + except Exception:
135 + log.exception("analytics de recherche sautées")
136 +
137 + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results}
138 +
139 +
140 +@app.get("/api/status")
141 +async def api_status():
142 + snapshot: dict = {}
143 + try:
144 + snapshot = await db.status_snapshot()
145 + except Exception:
146 + log.exception("statut PG indisponible")
147 + search_ok = await search.ping()
148 + try:
149 + paused = await coord.is_paused()
150 + except Exception:
151 + paused = False
152 + return {
153 + "pages_indexed": snapshot.get("pages_indexed", 0),
154 + "domains_count": snapshot.get("domains_count", 0),
155 + "indexed_last_hour": snapshot.get("indexed_last_hour", 0),
156 + "fetched_last_hour": snapshot.get("fetched_last_hour", 0),
157 + "errors_last_hour": snapshot.get("errors_last_hour", 0),
158 + "frontier_pending": snapshot.get("frontier_pending", 0),
159 + "frontier_in_progress": snapshot.get("frontier_in_progress", 0),
160 + "crawler_state": "paused" if paused else "running",
161 + "search_ok": search_ok,
162 + }
163 +
164 +
165 +@app.get("/api/live")
166 +async def api_live():
167 + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer.
168 +
169 + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne.
170 + """
171 + try:
172 + events = await db.recent_events(1)
173 + except Exception:
174 + return {"event": None}
175 + if not events:
176 + return {"event": None}
177 + e = events[0]
178 + return {
179 + "event": {
180 + "at": e["at"],
181 + "url": e["url"],
182 + "domain": extract_domain(e["url"]),
183 + "outcome": e["outcome"],
184 + }
185 + }
186 +
187 +
188 +class SubmitBody(BaseModel):
189 + url: str
190 +
191 +
192 +@app.post("/api/submit")
193 +async def api_submit(payload: SubmitBody):
194 + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide."""
195 + if not is_http_url(payload.url):
196 + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)")
197 + url = canonicalize_url(payload.url)
198 + domain = extract_domain(url) if url else None
199 + if not url or not domain:
200 + raise HTTPException(status_code=422, detail="URL invalide")
201 + await db.add_submission(url)
202 + await db.enqueue_url(url, domain, priority=0.7, depth=0)
203 + return {
204 + "accepted": True,
205 + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.",
206 + }
207 +
208 +
209 +# ---------------------------------------------------------------------- admin
210 +
211 +class SeedsBody(BaseModel):
212 + urls: list[str]
213 +
214 +
215 +class RecrawlBody(BaseModel):
216 + url: str | None = None
217 + domain: str | None = None
218 +
219 +
220 +class DomainBody(BaseModel):
221 + domain: str
222 +
223 +
224 +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)])
225 +async def admin_overview():
226 + overview = await db.admin_overview()
227 + overview["paused"] = await coord.is_paused()
228 + try:
229 + overview["enrich_backlog"] = await coord.enrich_backlog()
230 + except Exception:
231 + overview["enrich_backlog"] = None
232 + return overview
233 +
234 +
235 +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)])
236 +async def admin_recent(limit: int = Query(50, ge=1, le=200)):
237 + return {"events": await db.recent_events(limit)}
238 +
239 +
240 +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)])
241 +async def admin_pause():
242 + await coord.pause_crawler()
243 + return {"paused": True}
244 +
245 +
246 +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)])
247 +async def admin_resume():
248 + await coord.resume_crawler()
249 + return {"paused": False}
250 +
251 +
252 +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)])
253 +async def admin_seeds(payload: SeedsBody):
254 + added = 0
255 + for raw in payload.urls[:500]:
256 + url = canonicalize_url(raw.strip())
257 + domain = extract_domain(url) if url else None
258 + if url and domain:
259 + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):
260 + added += 1
261 + return {"added": added}
262 +
263 +
264 +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)])
265 +async def admin_recrawl(payload: RecrawlBody):
266 + if payload.url:
267 + url = canonicalize_url(payload.url)
268 + ok = await db.requeue_url(url) if url else False
269 + return {"requeued": 1 if ok else 0}
270 + if payload.domain:
271 + return {"requeued": await db.requeue_domain(payload.domain.lower())}
272 + raise HTTPException(status_code=422, detail="url ou domain requis")
273 +
274 +
275 +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)])
276 +async def admin_block_domain(payload: DomainBody):
277 + await db.block_domain(payload.domain.lower())
278 + return {"blocked": payload.domain.lower()}
279 +
280 +
281 +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)])
282 +async def admin_frontier(
283 + domain: str | None = None,
284 + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"),
285 + limit: int = Query(100, ge=1, le=500),
286 +):
287 + return {"items": await db.frontier_inspect(domain, status, limit)}
288 +
289 +
290 +def main() -> None:
291 + import uvicorn
292 +
293 + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1)
294 +
295 +
296 +if __name__ == "__main__":
297 + main()
added M2M32brouve-ka/docker-compose.yml +149 −0
@@ -0,0 +1,149 @@
1 +# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +#
5 +# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger,
6 +# workers scalables via `docker compose up -d --scale crawler-worker=3`.
7 +
8 +services:
9 + postgres:
10 + image: postgres:16-alpine
11 + environment:
12 + POSTGRES_USER: trouveka
13 + POSTGRES_PASSWORD: trouveka
14 + POSTGRES_DB: trouveka
15 + ports:
16 + - "${PG_PORT:-5432}:5432"
17 + volumes:
18 + - pgdata:/var/lib/postgresql/data
19 + healthcheck:
20 + test: ["CMD-SHELL", "pg_isready -U trouveka"]
21 + interval: 5s
22 + timeout: 3s
23 + retries: 20
24 +
25 + redis:
26 + image: redis:7-alpine
27 + ports:
28 + - "${REDIS_PORT:-6379}:6379"
29 + volumes:
30 + - redisdata:/data
31 + command: ["redis-server", "--appendonly", "yes"]
32 + healthcheck:
33 + test: ["CMD", "redis-cli", "ping"]
34 + interval: 5s
35 + timeout: 3s
36 + retries: 20
37 +
38 + opensearch:
39 + image: opensearchproject/opensearch:2.17.1
40 + environment:
41 + discovery.type: single-node
42 + plugins.security.disabled: "true"
43 + OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g"
44 + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1"
45 + bootstrap.memory_lock: "true"
46 + ulimits:
47 + memlock:
48 + soft: -1
49 + hard: -1
50 + ports:
51 + - "${SEARCH_PORT:-9200}:9200"
52 + volumes:
53 + - osdata:/usr/share/opensearch/data
54 + healthcheck:
55 + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
56 + interval: 10s
57 + timeout: 5s
58 + retries: 30
59 +
60 + api:
61 + build:
62 + context: .
63 + dockerfile: infrastructure/docker/backend.Dockerfile
64 + command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"]
65 + environment: &backend_env
66 + DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka
67 + REDIS_URL: redis://redis:6379/0
68 + SEARCH_URL: http://opensearch:9200
69 + ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token}
70 + PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com}
71 + MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24}
72 + DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0}
73 + MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000}
74 + ports:
75 + - "8080:8080"
76 + depends_on:
77 + postgres:
78 + condition: service_healthy
79 + redis:
80 + condition: service_healthy
81 + opensearch:
82 + condition: service_healthy
83 + restart: unless-stopped
84 +
85 + migrate:
86 + build:
87 + context: .
88 + dockerfile: infrastructure/docker/backend.Dockerfile
89 + command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"]
90 + environment: *backend_env
91 + depends_on:
92 + postgres:
93 + condition: service_healthy
94 + restart: "no"
95 +
96 + crawler-worker:
97 + build:
98 + context: .
99 + dockerfile: infrastructure/docker/backend.Dockerfile
100 + command: ["python", "-m", "trouveka.crawler.worker"]
101 + environment: *backend_env
102 + depends_on:
103 + migrate:
104 + condition: service_completed_successfully
105 + opensearch:
106 + condition: service_healthy
107 + redis:
108 + condition: service_healthy
109 + restart: unless-stopped
110 +
111 + enrichment-worker:
112 + build:
113 + context: .
114 + dockerfile: infrastructure/docker/backend.Dockerfile
115 + command: ["python", "-m", "trouveka.enrichment.worker"]
116 + environment: *backend_env
117 + depends_on:
118 + migrate:
119 + condition: service_completed_successfully
120 + restart: unless-stopped
121 +
122 + scheduler:
123 + build:
124 + context: .
125 + dockerfile: infrastructure/docker/backend.Dockerfile
126 + command: ["python", "-m", "trouveka.scheduler.loop"]
127 + environment: *backend_env
128 + depends_on:
129 + migrate:
130 + condition: service_completed_successfully
131 + restart: unless-stopped
132 +
133 + web:
134 + build:
135 + context: .
136 + dockerfile: infrastructure/docker/web.Dockerfile
137 + environment:
138 + API_URL: http://api:8080
139 + PORT: "3000"
140 + ports:
141 + - "3000:3000"
142 + depends_on:
143 + - api
144 + restart: unless-stopped
145 +
146 +volumes:
147 + pgdata:
148 + redisdata:
149 + osdata:
added M2M32brouve-ka/docs/architecture.md +106 −0
@@ -0,0 +1,106 @@
1 +# Trouve-KA — Architecture
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## Vue d'ensemble
6 +
7 +```mermaid
8 +flowchart LR
9 + subgraph Découverte
10 + SEEDS[Seeds §8] --> FRONTIER
11 + SUBMIT[Soumissions /soumettre] --> FRONTIER
12 + LINKS[Liens sortants] --> FRONTIER
13 + end
14 + FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N]
15 + CW -->|politesse SET NX PX| REDIS[(Redis)]
16 + CW --> ROBOTS[robots.txt cache]
17 + CW --> FETCH[Fetcher HTTP<br/>garde SSRF]
18 + FETCH --> PARSE[Parser selectolax]
19 + PARSE --> QC[Classification Québec<br/>page_quebec_score]
20 + QC --> DEDUP[Hash contenu / doublons]
21 + DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)]
22 + DEDUP --> PG[(Postgres<br/>documents, domaines, liens)]
23 + CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich]
24 + STREAM --> EW[enrichment-worker]
25 + EW -->|update partiel| OS
26 + SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG
27 + OS --> API[API FastAPI]
28 + PG --> API
29 + API --> WEB[Next.js web<br/>+ /admin]
30 + WEB --> NGROK[ngrok<br/>www.trouve-ka.com]
31 +```
32 +
33 +## Principe cardinal (§0.3)
34 +
35 +```mermaid
36 +sequenceDiagram
37 + participant F as Frontier
38 + participant W as crawler-worker
39 + participant O as OpenSearch
40 + participant U as Utilisateur
41 + F->>W: claim URL (t+0s)
42 + W->>W: fetch + parse + score Québec (t+2s)
43 + W->>O: index_document (t+3s)
44 + Note over O: refresh_interval 1s
45 + U->>O: recherche (t+4s) — la page est déjà cherchable
46 + W--)W: enrichissement async (étapes 2-3, plus tard)
47 +```
48 +
49 +Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour
50 +des documents **déjà cherchables** (update partiel), il ne conditionne rien.
51 +
52 +## Cycle de vie d'une URL
53 +
54 +```mermaid
55 +stateDiagram-v2
56 + [*] --> pending: découverte (seed, lien, soumission)
57 + pending --> in_progress: claim (priorité DESC, SKIP LOCKED)
58 + in_progress --> pending: politesse (trop tôt pour cet hôte)
59 + in_progress --> pending: succès → next_crawl_at adaptatif
60 + in_progress --> pending: erreur transitoire (retry backoff ≤3)
61 + in_progress --> done: robots refusé / redirection / doublon
62 + in_progress --> failed: erreur permanente ou retries épuisés
63 + in_progress --> blocked: domaine bloqué
64 + pending --> in_progress: recrawl (fréquence mesurée §5.5)
65 +```
66 +
67 +## Scores (§4, §7, §9)
68 +
69 +| Étape | Quand | Champs |
70 +|---|---|---|
71 +| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières |
72 +| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) |
73 +| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) |
74 +
75 +Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) +
76 +w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité`
77 +(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9).
78 +
79 +## Dégradation gracieuse (§13)
80 +
81 +- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit.
82 +- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact.
83 +- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min.
84 +- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin).
85 +
86 +## Déploiement m2m32 (§0.2)
87 +
88 +```mermaid
89 +flowchart LR
90 + NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000]
91 + WEB3000 -->|rewrite /api/*| API8080[api :8080]
92 + subgraph m2m32[Docker Compose sur m2m32 — 32 Go]
93 + WEB3000
94 + API8080
95 + PG5432[postgres]
96 + RD[redis]
97 + OS9200[opensearch 2 Go heap]
98 + CWX[crawler-worker × N]
99 + EN[enrichment-worker]
100 + SC[scheduler]
101 + end
102 +```
103 +
104 +Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go
105 +de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo —
106 +large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`.
added M2M32brouve-ka/docs/decisions.md +43 −0
@@ -0,0 +1,43 @@
1 +# Trouve-KA — Décisions d'architecture
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## D1 — Backend de recherche : OpenSearch 2.x
6 +
7 +**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3).
8 +
9 +**Choix : OpenSearch 2.17**, parce que :
10 +- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP.
11 +- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3).
12 +- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation.
13 +- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion).
14 +- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur.
15 +- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32.
16 +
17 +**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive).
18 +
19 +## D2 — Backend Python unique (FastAPI + pipeline)
20 +
21 +Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS).
22 +
23 +## D3 — Frontier dans Postgres, coordination dans Redis
24 +
25 +- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur.
26 +- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite).
27 +- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde.
28 +
29 +## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker
30 +
31 +Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais.
32 +
33 +## D5 — apps/admin fusionné dans apps/web (route /admin)
34 +
35 +Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »).
36 +
37 +## D6 — Graphe de liens au niveau domaine d'abord
38 +
39 +`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8).
40 +
41 +## D7 — Réseau : le web proxifie /api
42 +
43 +Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod.
added M2M32brouve-ka/infrastructure/deployment/deploy-m2m32.sh +38 −0
@@ -0,0 +1,38 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +#
6 +# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié.
7 +# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok.
8 +set -euo pipefail
9 +
10 +NODE="${NODE:-M2M32}"
11 +REMOTE_DIR="~/trouve-ka"
12 +NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}"
13 +
14 +cd "$(dirname "$0")/../.."
15 +
16 +echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR"
17 +rsync -az --delete \
18 + --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \
19 + --exclude .git --exclude data --exclude '*.egg-info' \
20 + ./ "$NODE:$REMOTE_DIR/"
21 +
22 +echo "→ Build et démarrage du stack sur $NODE"
23 +ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build"
24 +
25 +echo "→ Chargement des seeds (idempotent)"
26 +ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \
27 + docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt"
28 +
29 +echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)"
30 +ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \
31 + nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &"
32 +
33 +echo "→ Vérification de santé"
34 +sleep 5
35 +ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true
36 +
37 +echo "✓ Déploiement terminé : https://$NGROK_DOMAIN"
38 +echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json"
added M2M32brouve-ka/infrastructure/docker/backend.Dockerfile +20 −0
@@ -0,0 +1,20 @@
1 +# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +FROM python:3.12-slim
6 +
7 +WORKDIR /app
8 +
9 +COPY pyproject.toml ./
10 +COPY packages ./packages
11 +COPY services ./services
12 +COPY apps/api ./apps/api
13 +COPY infrastructure/migrations ./infrastructure/migrations
14 +COPY scripts ./scripts
15 +
16 +RUN pip install --no-cache-dir .
17 +
18 +ENV PYTHONUNBUFFERED=1
19 +
20 +CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"]
added M2M32brouve-ka/infrastructure/docker/web.Dockerfile +26 −0
@@ -0,0 +1,26 @@
1 +# Trouve-KA — image web Next.js
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +FROM node:22-alpine AS builder
6 +WORKDIR /repo
7 +RUN corepack enable
8 +COPY package.json pnpm-workspace.yaml ./
9 +COPY apps/web/package.json apps/web/
10 +# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next
11 +RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds
12 +COPY apps/web apps/web
13 +# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici
14 +ARG API_URL=http://api:8080
15 +ENV API_URL=${API_URL}
16 +RUN pnpm --filter @trouveka/web build
17 +
18 +FROM node:22-alpine
19 +WORKDIR /repo
20 +RUN corepack enable
21 +ENV NODE_ENV=production
22 +COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./
23 +COPY --from=builder /repo/node_modules ./node_modules
24 +COPY --from=builder /repo/apps/web ./apps/web
25 +EXPOSE 3000
26 +CMD ["pnpm", "--filter", "@trouveka/web", "start"]
added M2M32brouve-ka/infrastructure/migrations/001_init.sql +152 −0
@@ -0,0 +1,152 @@
1 +-- Trouve-KA — migration 001 : schéma initial
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +
5 +BEGIN;
6 +
7 +CREATE TABLE IF NOT EXISTS schema_migrations (
8 + version INTEGER PRIMARY KEY,
9 + applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
10 +);
11 +
12 +-- ---------------------------------------------------------------------------
13 +-- Domaines connus du web québécois (et non québécois, pour mémoire)
14 +-- ---------------------------------------------------------------------------
15 +CREATE TABLE IF NOT EXISTS domains (
16 + id BIGSERIAL PRIMARY KEY,
17 + domain TEXT NOT NULL UNIQUE,
18 + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
19 + last_crawled_at TIMESTAMPTZ,
20 + robots_fetched_at TIMESTAMPTZ,
21 + robots_body TEXT,
22 + robots_status TEXT, -- ok | not_found | error | forbidden
23 + crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay)
24 + quebec_score REAL NOT NULL DEFAULT 0,
25 + authority_score REAL NOT NULL DEFAULT 0,
26 + page_count INTEGER NOT NULL DEFAULT 0,
27 + inlink_domains INTEGER NOT NULL DEFAULT 0,
28 + outlink_domains INTEGER NOT NULL DEFAULT 0,
29 + language_stats JSONB NOT NULL DEFAULT '{}'::jsonb,
30 + content_change_rate REAL,
31 + blocked BOOLEAN NOT NULL DEFAULT FALSE,
32 + is_seed BOOLEAN NOT NULL DEFAULT FALSE
33 +);
34 +CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC);
35 +
36 +-- ---------------------------------------------------------------------------
37 +-- URLs canoniques connues
38 +-- ---------------------------------------------------------------------------
39 +CREATE TABLE IF NOT EXISTS urls (
40 + id BIGSERIAL PRIMARY KEY,
41 + url TEXT NOT NULL UNIQUE,
42 + canonical_url TEXT,
43 + domain_id BIGINT NOT NULL REFERENCES domains(id),
44 + first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
45 +);
46 +CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id);
47 +
48 +-- ---------------------------------------------------------------------------
49 +-- Frontier : la file d'URLs à crawler, avec priorités et scheduling
50 +-- ---------------------------------------------------------------------------
51 +CREATE TABLE IF NOT EXISTS frontier_items (
52 + id BIGSERIAL PRIMARY KEY,
53 + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
54 + priority REAL NOT NULL DEFAULT 0.5,
55 + depth INTEGER NOT NULL DEFAULT 0,
56 + source_url_id BIGINT REFERENCES urls(id),
57 + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
58 + last_crawled_at TIMESTAMPTZ,
59 + next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60 + status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked
61 + retries INTEGER NOT NULL DEFAULT 0,
62 + error_code TEXT,
63 + locked_by TEXT,
64 + locked_at TIMESTAMPTZ
65 +);
66 +CREATE INDEX IF NOT EXISTS idx_frontier_ready
67 + ON frontier_items (next_crawl_at, priority DESC)
68 + WHERE status = 'pending';
69 +CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status);
70 +
71 +-- ---------------------------------------------------------------------------
72 +-- Historique des tentatives de crawl (l'échec est normal; il est traqué)
73 +-- ---------------------------------------------------------------------------
74 +CREATE TABLE IF NOT EXISTS crawl_attempts (
75 + id BIGSERIAL PRIMARY KEY,
76 + url_id BIGINT NOT NULL REFERENCES urls(id),
77 + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
78 + status_code INTEGER,
79 + error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed|
80 + -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok
81 + outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged
82 + content_hash TEXT,
83 + bytes INTEGER,
84 + duration_ms INTEGER,
85 + redirect_url TEXT,
86 + title TEXT,
87 + quebec_score REAL
88 +);
89 +CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC);
90 +CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id);
91 +
92 +-- ---------------------------------------------------------------------------
93 +-- Documents indexés (métadonnées; le contenu cherchable vit dans l'index)
94 +-- ---------------------------------------------------------------------------
95 +CREATE TABLE IF NOT EXISTS documents (
96 + id BIGSERIAL PRIMARY KEY,
97 + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
98 + content_hash TEXT NOT NULL,
99 + etag TEXT,
100 + last_modified TEXT,
101 + title TEXT,
102 + description TEXT,
103 + language TEXT,
104 + page_quebec_score REAL NOT NULL DEFAULT 0,
105 + published_at TIMESTAMPTZ,
106 + last_changed_at TIMESTAMPTZ,
107 + first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
108 + last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
109 + enrichment_stage INTEGER NOT NULL DEFAULT 1
110 +);
111 +CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash);
112 +
113 +-- ---------------------------------------------------------------------------
114 +-- Graphe de liens agrégé au niveau domaine (page→page viendra plus tard)
115 +-- ---------------------------------------------------------------------------
116 +CREATE TABLE IF NOT EXISTS domain_links (
117 + from_domain_id BIGINT NOT NULL REFERENCES domains(id),
118 + to_domain_id BIGINT NOT NULL REFERENCES domains(id),
119 + link_count INTEGER NOT NULL DEFAULT 1,
120 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
121 + PRIMARY KEY (from_domain_id, to_domain_id)
122 +);
123 +CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id);
124 +
125 +-- ---------------------------------------------------------------------------
126 +-- Analytics de recherche agrégées et respectueuses de la vie privée
127 +-- (les requêtes zéro-résultat sont de l'or : elles pilotent le crawl)
128 +-- ---------------------------------------------------------------------------
129 +CREATE TABLE IF NOT EXISTS search_queries (
130 + id BIGSERIAL PRIMARY KEY,
131 + query TEXT NOT NULL,
132 + language TEXT,
133 + results_total INTEGER NOT NULL,
134 + took_ms INTEGER NOT NULL,
135 + zero_result BOOLEAN NOT NULL DEFAULT FALSE,
136 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
137 +);
138 +CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result;
139 +
140 +-- ---------------------------------------------------------------------------
141 +-- Soumissions publiques d'URL (soumission ≠ inclusion)
142 +-- ---------------------------------------------------------------------------
143 +CREATE TABLE IF NOT EXISTS submissions (
144 + id BIGSERIAL PRIMARY KEY,
145 + url TEXT NOT NULL,
146 + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
147 + status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected
148 +);
149 +
150 +INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
151 +
152 +COMMIT;
added M2M32brouve-ka/infrastructure/migrations/002_frontier_priority_index.sql +17 −0
@@ -0,0 +1,17 @@
1 +-- Trouve-KA — migration 002 : index de claim par priorité
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +--
5 +-- Le claim ordonne par priority DESC; l'index 001 (next_crawl_at, priority)
6 +-- force un tri de toutes les lignes dues à chaque claim. Cet index sert
7 +-- l'ORDER BY directement (la plupart des lignes pending sont dues).
8 +
9 +BEGIN;
10 +
11 +CREATE INDEX IF NOT EXISTS idx_frontier_priority
12 + ON frontier_items (priority DESC, next_crawl_at)
13 + WHERE status = 'pending';
14 +
15 +INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
16 +
17 +COMMIT;
added M2M32brouve-ka/infrastructure/monitoring/README.md +22 −0
@@ -0,0 +1,22 @@
1 +# Trouve-KA — Observabilité
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## En place (MVP)
6 +
7 +- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service,
8 + avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms`
9 + (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`.
10 +- **Métriques opérationnelles** exposées par l'API :
11 + - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler.
12 + - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP,
13 + latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement.
14 + - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) —
15 + aucun compteur simulé (§ fake).
16 +- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage).
17 +
18 +## Prochaine étape (post-MVP)
19 +
20 +Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher
21 +Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité,
22 +l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13.
added M2M32brouve-ka/package.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "name": "trouve-ka",
3 + "private": true,
4 + "version": "0.1.0",
5 + "packageManager": "pnpm@11.1.2",
6 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
7 + "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.",
8 + "pnpm": {
9 + "onlyBuiltDependencies": ["sharp", "unrs-resolver"]
10 + },
11 + "scripts": {
12 + "dev": "pnpm --filter @trouveka/web dev",
13 + "build": "pnpm --filter @trouveka/web build",
14 + "start": "pnpm --filter @trouveka/web start",
15 + "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh",
16 + "check:headers": "python3 scripts/check-headers.py",
17 + "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh"
18 + }
19 +}
added M2M32brouve-ka/packages/config/__init__.py +67 −0
@@ -0,0 +1,67 @@
1 +# Trouve-KA — configuration validée par variables d'environnement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Configuration centrale de Trouve-KA.
6 +
7 +Toutes les valeurs sont surchargeables par variable d'environnement et
8 +dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM).
9 +"""
10 +
11 +from functools import lru_cache
12 +
13 +from pydantic_settings import BaseSettings, SettingsConfigDict
14 +
15 +
16 +class Settings(BaseSettings):
17 + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
18 +
19 + # Bases de données
20 + database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka"
21 + redis_url: str = "redis://localhost:6379/0"
22 + search_url: str = "http://localhost:9200"
23 + search_index: str = "trouveka-docs"
24 +
25 + # Crawler — identité et politesse
26 + crawler_user_agent: str = (
27 + "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)"
28 + )
29 + crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot"
30 + max_global_concurrency: int = 24
31 + max_per_host_concurrency: int = 2
32 + default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte
33 +
34 + # Crawler — limites de sécurité (chaque réponse a des limites)
35 + max_response_bytes: int = 3_000_000
36 + max_redirects: int = 5
37 + fetch_timeout: float = 20.0
38 + max_crawl_depth: int = 8
39 + max_links_per_page: int = 300
40 + max_urls_per_domain: int = 5_000
41 + max_query_params: int = 8
42 + max_path_segments: int = 12
43 +
44 + # Recrawl adaptatif (bornes, en heures)
45 + min_recrawl_hours: float = 1.0
46 + max_recrawl_hours: float = 24 * 30.0
47 + default_recrawl_hours: float = 24.0
48 +
49 + # Indexation
50 + min_quebec_score_to_index: float = 0.15
51 + min_body_length: int = 80
52 +
53 + # API
54 + api_host: str = "0.0.0.0"
55 + api_port: int = 8080
56 + admin_token: str = "change-me-admin-token"
57 + public_url: str = "https://www.trouve-ka.com"
58 + ngrok_domain: str = "www.trouve-ka.com"
59 +
60 + # Postgres pool (dimensionné pour 32 Go / plusieurs workers)
61 + pg_pool_min: int = 2
62 + pg_pool_max: int = 10
63 +
64 +
65 +@lru_cache
66 +def get_settings() -> Settings:
67 + return Settings()
added M2M32brouve-ka/packages/database/__init__.py +10 −0
@@ -0,0 +1,10 @@
1 +# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents."""
6 +
7 +from .db import Database
8 +from .migrate import run_migrations
9 +
10 +__all__ = ["Database", "run_migrations"]
added M2M32brouve-ka/packages/database/db.py +522 −0
@@ -0,0 +1,522 @@
1 +# Trouve-KA — dépôts PostgreSQL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Dépôts PostgreSQL de Trouve-KA.
6 +
7 +Le frontier vit dans Postgres : les workers réclament des lots d'URLs via
8 +FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination
9 +externe (CLAUDE.md §13 : pas de complexité distribuée prématurée).
10 +"""
11 +
12 +from datetime import UTC, datetime, timedelta
13 +from typing import Any
14 +
15 +import asyncpg
16 +
17 +
18 +def _dsn(url: str) -> str:
19 + # asyncpg accepte postgresql:// mais pas postgresql+asyncpg://
20 + return url.replace("postgresql+asyncpg://", "postgresql://")
21 +
22 +
23 +class Database:
24 + def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10):
25 + self._url = _dsn(database_url)
26 + self._pool_min = pool_min
27 + self._pool_max = pool_max
28 + self.pool: asyncpg.Pool | None = None
29 +
30 + async def connect(self) -> None:
31 + self.pool = await asyncpg.create_pool(
32 + self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30
33 + )
34 +
35 + async def close(self) -> None:
36 + if self.pool:
37 + await self.pool.close()
38 + self.pool = None
39 +
40 + # ------------------------------------------------------------------ domaines
41 +
42 + async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int:
43 + row = await self.pool.fetchrow(
44 + """
45 + INSERT INTO domains (domain, is_seed) VALUES ($1, $2)
46 + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed
47 + RETURNING id
48 + """,
49 + domain,
50 + is_seed,
51 + )
52 + return row["id"]
53 +
54 + async def get_domain(self, domain: str) -> asyncpg.Record | None:
55 + return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain)
56 +
57 + async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None:
58 + return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id)
59 +
60 + async def save_robots(
61 + self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None
62 + ) -> None:
63 + await self.pool.execute(
64 + """
65 + UPDATE domains SET robots_body = $2, robots_status = $3,
66 + robots_fetched_at = now(), crawl_delay_ms = $4
67 + WHERE id = $1
68 + """,
69 + domain_id,
70 + body,
71 + status,
72 + crawl_delay_ms,
73 + )
74 +
75 + async def block_domain(self, domain: str) -> None:
76 + await self.pool.execute(
77 + "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain
78 + )
79 + await self.pool.execute(
80 + """
81 + UPDATE frontier_items SET status = 'blocked'
82 + WHERE status IN ('pending', 'in_progress')
83 + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id
84 + WHERE d.domain = $1)
85 + """,
86 + domain,
87 + )
88 +
89 + async def update_domain_after_page(
90 + self, domain_id: int, page_quebec_score: float, language: str | None
91 + ) -> None:
92 + """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue."""
93 + lang = language or "unknown"
94 + await self.pool.execute(
95 + """
96 + UPDATE domains SET
97 + page_count = page_count + 1,
98 + last_crawled_at = now(),
99 + quebec_score = (quebec_score * page_count + $2) / (page_count + 1),
100 + language_stats = jsonb_set(
101 + language_stats, ARRAY[$3],
102 + (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb)
103 + WHERE id = $1
104 + """,
105 + domain_id,
106 + page_quebec_score,
107 + lang,
108 + )
109 +
110 + async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None:
111 + if not to_domain_counts:
112 + return
113 + await self.pool.executemany(
114 + """
115 + INSERT INTO domain_links (from_domain_id, to_domain_id, link_count)
116 + VALUES ($1, $2, $3)
117 + ON CONFLICT (from_domain_id, to_domain_id)
118 + DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count,
119 + updated_at = now()
120 + """,
121 + [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id],
122 + )
123 +
124 + # ------------------------------------------------------------------ frontier
125 +
126 + async def enqueue_url(
127 + self,
128 + url: str,
129 + domain: str,
130 + *,
131 + priority: float = 0.5,
132 + depth: int = 0,
133 + source_url_id: int | None = None,
134 + is_seed: bool = False,
135 + max_urls_per_domain: int = 5000,
136 + ) -> int | None:
137 + """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon."""
138 + async with self.pool.acquire() as conn:
139 + async with conn.transaction():
140 + domain_id = await conn.fetchval(
141 + """
142 + INSERT INTO domains (domain, is_seed) VALUES ($1, $2)
143 + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed
144 + RETURNING id
145 + """,
146 + domain,
147 + is_seed,
148 + )
149 + dom = await conn.fetchrow(
150 + "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id
151 + )
152 + if dom["blocked"]:
153 + return None
154 + if not is_seed and dom["page_count"] >= max_urls_per_domain:
155 + return None
156 + url_id = await conn.fetchval(
157 + """
158 + INSERT INTO urls (url, domain_id) VALUES ($1, $2)
159 + ON CONFLICT (url) DO NOTHING RETURNING id
160 + """,
161 + url,
162 + domain_id,
163 + )
164 + if url_id is None:
165 + return None # URL déjà connue : pas de doublon dans le frontier
166 + await conn.execute(
167 + """
168 + INSERT INTO frontier_items (url_id, priority, depth, source_url_id)
169 + VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING
170 + """,
171 + url_id,
172 + priority,
173 + depth,
174 + source_url_id,
175 + )
176 + return url_id
177 +
178 + async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]:
179 + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr)."""
180 + return await self.pool.fetch(
181 + """
182 + WITH claimed AS (
183 + SELECT f.id FROM frontier_items f
184 + WHERE f.status = 'pending' AND f.next_crawl_at <= now()
185 + ORDER BY f.priority DESC, f.next_crawl_at
186 + LIMIT $2
187 + FOR UPDATE SKIP LOCKED
188 + )
189 + UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now()
190 + FROM claimed
191 + WHERE f.id = claimed.id
192 + RETURNING f.id, f.url_id, f.priority, f.depth, f.retries,
193 + f.last_crawled_at, f.next_crawl_at,
194 + (SELECT url FROM urls WHERE id = f.url_id) AS url,
195 + (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id
196 + """,
197 + worker_id,
198 + limit,
199 + )
200 +
201 + async def release_item(
202 + self,
203 + url_id: int,
204 + *,
205 + status: str,
206 + error_code: str | None = None,
207 + next_crawl_at: datetime | None = None,
208 + priority: float | None = None,
209 + increment_retries: bool = False,
210 + ) -> None:
211 + await self.pool.execute(
212 + """
213 + UPDATE frontier_items SET
214 + status = $2,
215 + error_code = $3,
216 + last_crawled_at = now(),
217 + next_crawl_at = COALESCE($4, next_crawl_at),
218 + priority = COALESCE($5, priority),
219 + retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END,
220 + locked_by = NULL, locked_at = NULL
221 + WHERE url_id = $1
222 + """,
223 + url_id,
224 + status,
225 + error_code,
226 + next_crawl_at,
227 + priority,
228 + increment_retries,
229 + )
230 +
231 + async def reset_stale_items(self, older_than_minutes: int = 30) -> int:
232 + """Relance les items in_progress abandonnés (worker mort). Retourne le nombre."""
233 + result = await self.pool.execute(
234 + """
235 + UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL
236 + WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval
237 + """,
238 + str(older_than_minutes),
239 + )
240 + return int(result.split()[-1])
241 +
242 + async def requeue_url(self, url: str) -> bool:
243 + result = await self.pool.execute(
244 + """
245 + UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0
246 + WHERE url_id = (SELECT id FROM urls WHERE url = $1)
247 + """,
248 + url,
249 + )
250 + return result.endswith("1")
251 +
252 + async def requeue_domain(self, domain: str) -> int:
253 + result = await self.pool.execute(
254 + """
255 + UPDATE frontier_items SET status = 'pending', next_crawl_at = now()
256 + WHERE status IN ('done', 'failed')
257 + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id
258 + WHERE d.domain = $1)
259 + """,
260 + domain,
261 + )
262 + return int(result.split()[-1])
263 +
264 + # ------------------------------------------------------------------ crawl / documents
265 +
266 + async def record_attempt(
267 + self,
268 + url_id: int,
269 + *,
270 + status_code: int | None,
271 + error_code: str,
272 + outcome: str,
273 + content_hash: str | None = None,
274 + num_bytes: int | None = None,
275 + duration_ms: int | None = None,
276 + redirect_url: str | None = None,
277 + title: str | None = None,
278 + quebec_score: float | None = None,
279 + ) -> None:
280 + await self.pool.execute(
281 + """
282 + INSERT INTO crawl_attempts
283 + (url_id, status_code, error_code, outcome, content_hash, bytes,
284 + duration_ms, redirect_url, title, quebec_score)
285 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
286 + """,
287 + url_id,
288 + status_code,
289 + error_code,
290 + outcome,
291 + content_hash,
292 + num_bytes,
293 + duration_ms,
294 + redirect_url,
295 + title,
296 + quebec_score,
297 + )
298 +
299 + async def get_document(self, url_id: int) -> asyncpg.Record | None:
300 + return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id)
301 +
302 + async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None:
303 + """Doublon exact : même hash de contenu sur une autre URL."""
304 + return await self.pool.fetchrow(
305 + "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1",
306 + chash,
307 + url_id,
308 + )
309 +
310 + async def upsert_document(
311 + self,
312 + url_id: int,
313 + *,
314 + content_hash: str,
315 + etag: str | None,
316 + last_modified: str | None,
317 + title: str,
318 + description: str,
319 + language: str | None,
320 + page_quebec_score: float,
321 + published_at: datetime | None,
322 + changed: bool,
323 + ) -> int:
324 + row = await self.pool.fetchrow(
325 + """
326 + INSERT INTO documents
327 + (url_id, content_hash, etag, last_modified, title, description, language,
328 + page_quebec_score, published_at, last_changed_at)
329 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
330 + ON CONFLICT (url_id) DO UPDATE SET
331 + content_hash = EXCLUDED.content_hash,
332 + etag = EXCLUDED.etag,
333 + last_modified = EXCLUDED.last_modified,
334 + title = EXCLUDED.title,
335 + description = EXCLUDED.description,
336 + language = EXCLUDED.language,
337 + page_quebec_score = EXCLUDED.page_quebec_score,
338 + published_at = EXCLUDED.published_at,
339 + last_indexed_at = now(),
340 + last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END
341 + RETURNING id
342 + """,
343 + url_id,
344 + content_hash,
345 + etag,
346 + last_modified,
347 + title,
348 + description,
349 + language,
350 + page_quebec_score,
351 + published_at,
352 + changed,
353 + )
354 + return row["id"]
355 +
356 + async def set_canonical(self, url_id: int, canonical_url: str) -> None:
357 + await self.pool.execute(
358 + "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url
359 + )
360 +
361 + # ------------------------------------------------------------------ analytics / statut
362 +
363 + async def record_search_query(
364 + self, query: str, language: str | None, total: int, took_ms: int
365 + ) -> None:
366 + await self.pool.execute(
367 + """
368 + INSERT INTO search_queries (query, language, results_total, took_ms, zero_result)
369 + VALUES ($1, $2, $3, $4, $3 = 0)
370 + """,
371 + query[:500],
372 + language,
373 + total,
374 + took_ms,
375 + )
376 +
377 + async def add_submission(self, url: str) -> None:
378 + await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url)
379 +
380 + async def status_snapshot(self) -> dict[str, Any]:
381 + since = datetime.now(UTC) - timedelta(hours=1)
382 + frontier = {
383 + r["status"]: r["n"]
384 + for r in await self.pool.fetch(
385 + "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status"
386 + )
387 + }
388 + row = await self.pool.fetchrow(
389 + """
390 + SELECT
391 + (SELECT count(*)::int FROM documents) AS docs,
392 + (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains,
393 + (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h,
394 + (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h,
395 + (SELECT count(*)::int FROM crawl_attempts
396 + WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h
397 + """,
398 + since,
399 + )
400 + return {
401 + "pages_indexed": row["docs"],
402 + "domains_count": row["domains"],
403 + "indexed_last_hour": row["indexed_1h"],
404 + "fetched_last_hour": row["fetched_1h"],
405 + "errors_last_hour": row["errors_1h"],
406 + "frontier_pending": frontier.get("pending", 0),
407 + "frontier_in_progress": frontier.get("in_progress", 0),
408 + "frontier": frontier,
409 + }
410 +
411 + async def admin_overview(self) -> dict[str, Any]:
412 + since = datetime.now(UTC) - timedelta(hours=1)
413 + base = await self.status_snapshot()
414 + http_status = {
415 + str(r["status_code"]): r["n"]
416 + for r in await self.pool.fetch(
417 + """
418 + SELECT status_code, count(*)::int AS n FROM crawl_attempts
419 + WHERE fetched_at >= $1 AND status_code IS NOT NULL
420 + GROUP BY status_code ORDER BY n DESC
421 + """,
422 + since,
423 + )
424 + }
425 + outcomes = {
426 + r["outcome"]: r["n"]
427 + for r in await self.pool.fetch(
428 + "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome",
429 + since,
430 + )
431 + }
432 + top_domains = [
433 + dict(r)
434 + for r in await self.pool.fetch(
435 + """
436 + SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score
437 + FROM domains WHERE page_count > 0
438 + ORDER BY page_count DESC LIMIT 20
439 + """
440 + )
441 + ]
442 + recent_errors = [
443 + {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()}
444 + for r in await self.pool.fetch(
445 + """
446 + SELECT u.url, a.error_code, a.fetched_at
447 + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id
448 + WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20
449 + """
450 + )
451 + ]
452 + latency = await self.pool.fetchrow(
453 + """
454 + SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50,
455 + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95
456 + FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL
457 + """,
458 + since,
459 + )
460 + return {
461 + "frontier": {
462 + "pending": base["frontier"].get("pending", 0),
463 + "in_progress": base["frontier"].get("in_progress", 0),
464 + "done": base["frontier"].get("done", 0),
465 + "failed": base["frontier"].get("failed", 0),
466 + "blocked": base["frontier"].get("blocked", 0),
467 + },
468 + "rates": {
469 + "fetched_1h": base["fetched_last_hour"],
470 + "indexed_1h": outcomes.get("indexed", 0),
471 + "errors_1h": outcomes.get("error", 0),
472 + "robots_blocked_1h": outcomes.get("robots_blocked", 0),
473 + "duplicates_1h": outcomes.get("duplicate", 0),
474 + "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0),
475 + },
476 + "http_status": http_status,
477 + "top_domains": top_domains,
478 + "recent_errors": recent_errors,
479 + "latency": {
480 + "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None,
481 + "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None,
482 + },
483 + }
484 +
485 + async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]:
486 + rows = await self.pool.fetch(
487 + """
488 + SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title
489 + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id
490 + ORDER BY a.fetched_at DESC LIMIT $1
491 + """,
492 + min(limit, 200),
493 + )
494 + return [
495 + {
496 + "at": r["fetched_at"].isoformat(),
497 + "url": r["url"],
498 + "status": r["status_code"],
499 + "outcome": r["outcome"],
500 + "quebec_score": r["quebec_score"],
501 + "title": r["title"],
502 + }
503 + for r in rows
504 + ]
505 +
506 + async def frontier_inspect(
507 + self, domain: str | None = None, status: str | None = None, limit: int = 100
508 + ) -> list[dict[str, Any]]:
509 + rows = await self.pool.fetch(
510 + """
511 + SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code
512 + FROM frontier_items f JOIN urls u ON u.id = f.url_id
513 + JOIN domains d ON d.id = u.domain_id
514 + WHERE ($1::text IS NULL OR d.domain = $1)
515 + AND ($2::text IS NULL OR f.status = $2)
516 + ORDER BY f.priority DESC LIMIT $3
517 + """,
518 + domain,
519 + status,
520 + min(limit, 500),
521 + )
522 + return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows]
added M2M32brouve-ka/packages/database/migrate.py +46 −0
@@ -0,0 +1,46 @@
1 +# Trouve-KA — exécution des migrations SQL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Applique les migrations SQL de infrastructure/migrations dans l'ordre."""
6 +
7 +import os
8 +import pathlib
9 +import re
10 +
11 +import asyncpg
12 +
13 +
14 +def _find_migrations_dir() -> pathlib.Path:
15 + """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable)."""
16 + candidates = [
17 + os.environ.get("TROUVEKA_MIGRATIONS_DIR"),
18 + pathlib.Path.cwd() / "infrastructure" / "migrations",
19 + pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations",
20 + ]
21 + for cand in candidates:
22 + if cand and pathlib.Path(cand).is_dir():
23 + return pathlib.Path(cand)
24 + raise FileNotFoundError("dossier infrastructure/migrations introuvable")
25 +
26 +
27 +async def run_migrations(database_url: str) -> list[str]:
28 + """Applique les migrations manquantes. Retourne la liste des fichiers appliqués."""
29 + migrations_dir = _find_migrations_dir()
30 + conn = await asyncpg.connect(database_url)
31 + applied: list[str] = []
32 + try:
33 + await conn.execute(
34 + "CREATE TABLE IF NOT EXISTS schema_migrations ("
35 + " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
36 + )
37 + done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")}
38 + for path in sorted(migrations_dir.glob("*.sql")):
39 + match = re.match(r"^(\d+)_", path.name)
40 + if not match or int(match.group(1)) in done:
41 + continue
42 + await conn.execute(path.read_text(encoding="utf-8"))
43 + applied.append(path.name)
44 + finally:
45 + await conn.close()
46 + return applied
added M2M32brouve-ka/packages/logging/__init__.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — logging structuré JSON
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id."""
6 +
7 +import json
8 +import logging
9 +import sys
10 +import time
11 +from typing import Any
12 +
13 +
14 +class JsonFormatter(logging.Formatter):
15 + def format(self, record: logging.LogRecord) -> str:
16 + payload: dict[str, Any] = {
17 + "ts": round(time.time(), 3),
18 + "level": record.levelname.lower(),
19 + "logger": record.name,
20 + "msg": record.getMessage(),
21 + }
22 + extra = getattr(record, "ctx", None)
23 + if isinstance(extra, dict):
24 + payload.update(extra)
25 + if record.exc_info and record.exc_info[0] is not None:
26 + payload["exc"] = self.formatException(record.exc_info)
27 + return json.dumps(payload, ensure_ascii=False, default=str)
28 +
29 +
30 +def get_logger(name: str) -> logging.Logger:
31 + """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`."""
32 + logger = logging.getLogger(name)
33 + if not logger.handlers:
34 + handler = logging.StreamHandler(sys.stdout)
35 + handler.setFormatter(JsonFormatter())
36 + logger.addHandler(handler)
37 + logger.setLevel(logging.INFO)
38 + logger.propagate = False
39 + return logger
added M2M32brouve-ka/packages/queue/__init__.py +71 −0
@@ -0,0 +1,71 @@
1 +# Trouve-KA — coordination Redis (politesse, pause, enrichissement)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Coordination inter-workers via Redis.
6 +
7 +- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre,
8 + quel que soit le nombre de workers.
9 +- Pause globale du crawler : simple clé drapeau.
10 +- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation).
11 +"""
12 +
13 +import json
14 +from typing import Any
15 +
16 +import redis.asyncio as aioredis
17 +
18 +PAUSE_KEY = "trouveka:crawler:paused"
19 +HOST_LOCK_PREFIX = "trouveka:host-lock:"
20 +ENRICH_STREAM = "trouveka:enrich"
21 +
22 +
23 +class Coordination:
24 + def __init__(self, redis_url: str):
25 + self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True)
26 +
27 + async def close(self) -> None:
28 + await self.redis.aclose()
29 +
30 + # ------------------------------------------------------------- politesse
31 + async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool:
32 + """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard."""
33 + px = max(int(delay_seconds * 1000), 100)
34 + return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px))
35 +
36 + # ------------------------------------------------------------- pause
37 + async def pause_crawler(self) -> None:
38 + await self.redis.set(PAUSE_KEY, "1")
39 +
40 + async def resume_crawler(self) -> None:
41 + await self.redis.delete(PAUSE_KEY)
42 +
43 + async def is_paused(self) -> bool:
44 + return await self.redis.exists(PAUSE_KEY) == 1
45 +
46 + # ------------------------------------------------------------- enrichissement
47 + async def enqueue_enrichment(self, payload: dict[str, Any]) -> None:
48 + await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000)
49 +
50 + async def read_enrichment(
51 + self, group: str, consumer: str, count: int = 10, block_ms: int = 5000
52 + ) -> list[tuple[str, dict[str, Any]]]:
53 + try:
54 + await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True)
55 + except aioredis.ResponseError as exc:
56 + if "BUSYGROUP" not in str(exc):
57 + raise
58 + entries = await self.redis.xreadgroup(
59 + group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms
60 + )
61 + out: list[tuple[str, dict[str, Any]]] = []
62 + for _stream, items in entries or []:
63 + for msg_id, fields in items:
64 + out.append((msg_id, json.loads(fields["data"])))
65 + return out
66 +
67 + async def ack_enrichment(self, group: str, msg_id: str) -> None:
68 + await self.redis.xack(ENRICH_STREAM, group, msg_id)
69 +
70 + async def enrich_backlog(self) -> int:
71 + return await self.redis.xlen(ENRICH_STREAM)
added M2M32brouve-ka/packages/search-core/__init__.py +10 −0
@@ -0,0 +1,10 @@
1 +# Trouve-KA — couche OpenSearch (client, mapping, indexation)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate."""
6 +
7 +from .client import SearchCore
8 +from .mapping import INDEX_SETTINGS
9 +
10 +__all__ = ["SearchCore", "INDEX_SETTINGS"]
added M2M32brouve-ka/packages/search-core/client.py +66 −0
@@ -0,0 +1,66 @@
1 +# Trouve-KA — client OpenSearch
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut."""
6 +
7 +import hashlib
8 +from typing import Any
9 +
10 +from opensearchpy import AsyncOpenSearch
11 +from opensearchpy.exceptions import NotFoundError
12 +
13 +from .mapping import INDEX_SETTINGS
14 +
15 +
16 +def doc_id_for_url(url: str) -> str:
17 + """ID de document stable dérivé de l'URL canonique."""
18 + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32]
19 +
20 +
21 +class SearchCore:
22 + def __init__(self, search_url: str, index: str):
23 + self.index = index
24 + self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True)
25 +
26 + async def close(self) -> None:
27 + await self.client.close()
28 +
29 + async def ensure_index(self) -> None:
30 + if not await self.client.indices.exists(index=self.index):
31 + await self.client.indices.create(index=self.index, body=INDEX_SETTINGS)
32 +
33 + async def index_document(self, doc: dict[str, Any]) -> str:
34 + """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s)."""
35 + _id = doc_id_for_url(doc["canonical_url"] or doc["url"])
36 + await self.client.index(index=self.index, id=_id, body=doc)
37 + return _id
38 +
39 + async def update_document(self, url: str, partial: dict[str, Any]) -> None:
40 + """Mise à jour partielle (enrichissement asynchrone, étapes 2-3)."""
41 + try:
42 + await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial})
43 + except NotFoundError:
44 + pass
45 +
46 + async def delete_document(self, url: str) -> None:
47 + try:
48 + await self.client.delete(index=self.index, id=doc_id_for_url(url))
49 + except NotFoundError:
50 + pass
51 +
52 + async def search(self, body: dict[str, Any]) -> dict[str, Any]:
53 + return await self.client.search(index=self.index, body=body)
54 +
55 + async def count(self) -> int:
56 + try:
57 + res = await self.client.count(index=self.index)
58 + return res["count"]
59 + except NotFoundError:
60 + return 0
61 +
62 + async def ping(self) -> bool:
63 + try:
64 + return await self.client.ping()
65 + except Exception:
66 + return False
added M2M32brouve-ka/packages/search-core/mapping.py +124 −0
@@ -0,0 +1,124 @@
1 +# Trouve-KA — mapping OpenSearch bilingue
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Mapping et settings de l'index documents.
6 +
7 +- Analyzers français ET anglais sur les champs texte (bilingue dès le départ).
8 +- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche
9 + seulement : pas de traduction à l'ingestion.
10 +- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal).
11 +"""
12 +
13 +# Synonymes bilingues de départ — extensibles sans réindexation (search-time)
14 +BILINGUAL_SYNONYMS = [
15 + "thermopompe, heat pump",
16 + "subvention, grant, subsidy",
17 + "emploi, job, employment",
18 + "entreprise, company, business",
19 + "université, university",
20 + "cégep, cegep, college",
21 + "garderie, daycare, cpe",
22 + "impôt, tax, taxes",
23 + "santé, health",
24 + "plombier, plumber",
25 + "électricien, electrician",
26 + "déneigement, snow removal",
27 + "assurance, insurance",
28 + "logement, housing, apartment",
29 + "ville, city, municipalité, municipality",
30 + "gouvernement, government",
31 + "permis, permit, license, licence",
32 +]
33 +
34 +INDEX_SETTINGS = {
35 + "settings": {
36 + "number_of_shards": 1,
37 + "number_of_replicas": 0,
38 + "refresh_interval": "1s",
39 + "analysis": {
40 + "filter": {
41 + "french_elision": {
42 + "type": "elision",
43 + "articles_case": True,
44 + "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"],
45 + },
46 + "french_stop": {"type": "stop", "stopwords": "_french_"},
47 + "french_stemmer": {"type": "stemmer", "language": "light_french"},
48 + "english_stop": {"type": "stop", "stopwords": "_english_"},
49 + "english_stemmer": {"type": "stemmer", "language": "light_english"},
50 + "bilingual_synonyms": {
51 + "type": "synonym_graph",
52 + "lenient": True,
53 + "synonyms": BILINGUAL_SYNONYMS,
54 + },
55 + },
56 + "analyzer": {
57 + "fr_text": {
58 + "tokenizer": "standard",
59 + "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"],
60 + },
61 + "fr_search": {
62 + "tokenizer": "standard",
63 + "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms",
64 + "french_stop", "french_stemmer"],
65 + },
66 + "en_text": {
67 + "tokenizer": "standard",
68 + "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"],
69 + },
70 + "en_search": {
71 + "tokenizer": "standard",
72 + "filter": ["lowercase", "asciifolding", "bilingual_synonyms",
73 + "english_stop", "english_stemmer"],
74 + },
75 + },
76 + },
77 + },
78 + "mappings": {
79 + "properties": {
80 + "url": {"type": "keyword"},
81 + "canonical_url": {"type": "keyword"},
82 + "domain": {"type": "keyword"},
83 + "title": {
84 + "type": "text",
85 + "analyzer": "fr_text",
86 + "search_analyzer": "fr_search",
87 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
88 + },
89 + "description": {
90 + "type": "text",
91 + "analyzer": "fr_text",
92 + "search_analyzer": "fr_search",
93 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
94 + },
95 + "body": {
96 + "type": "text",
97 + "analyzer": "fr_text",
98 + "search_analyzer": "fr_search",
99 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
100 + },
101 + "headings": {
102 + "type": "text",
103 + "analyzer": "fr_text",
104 + "search_analyzer": "fr_search",
105 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
106 + },
107 + "language": {"type": "keyword"},
108 + "page_quebec_score": {"type": "float"},
109 + "domain_quebec_score": {"type": "float"},
110 + "locations": {"type": "keyword"},
111 + "organizations": {"type": "keyword"},
112 + "people": {"type": "keyword"},
113 + "categories": {"type": "keyword"},
114 + "published_at": {"type": "date"},
115 + "crawled_at": {"type": "date"},
116 + "authority_score": {"type": "float"},
117 + "freshness_score": {"type": "float"},
118 + "quality_score": {"type": "float"},
119 + "spam_score": {"type": "float"},
120 + # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7),
121 + # le mapping est extensible sans réindexation pour un nouveau champ.
122 + }
123 + },
124 +}
added M2M32brouve-ka/packages/shared/__init__.py +20 −0
@@ -0,0 +1,20 @@
1 +# Trouve-KA — utilitaires partagés
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu."""
6 +
7 +from .urls import canonicalize_url, display_url, extract_domain, is_http_url
8 +from .ssrf import is_safe_url, is_safe_ip
9 +from .hashing import content_hash, text_fingerprint
10 +
11 +__all__ = [
12 + "canonicalize_url",
13 + "display_url",
14 + "extract_domain",
15 + "is_http_url",
16 + "is_safe_url",
17 + "is_safe_ip",
18 + "content_hash",
19 + "text_fingerprint",
20 +]
added M2M32brouve-ka/packages/shared/hashing.py +25 −0
@@ -0,0 +1,25 @@
1 +# Trouve-KA — hachage de contenu et détection de changement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Hachage de contenu pour la déduplication exacte et la détection de changement.
6 +
7 +Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour
8 +ignorer le bruit de balisage (nonces, timestamps de rendu, etc.).
9 +"""
10 +
11 +import hashlib
12 +import re
13 +
14 +_WHITESPACE = re.compile(r"\s+")
15 +
16 +
17 +def text_fingerprint(text: str) -> str:
18 + """Texte normalisé pour hachage : espaces réduits, minuscules."""
19 + return _WHITESPACE.sub(" ", text).strip().lower()
20 +
21 +
22 +def content_hash(title: str, body: str) -> str:
23 + """SHA-256 du contenu textuel normalisé (titre + corps)."""
24 + payload = text_fingerprint(title) + "\n" + text_fingerprint(body)
25 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
added M2M32brouve-ka/packages/shared/ssrf.py +75 −0
@@ -0,0 +1,75 @@
1 +# Trouve-KA — prévention SSRF
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Garde SSRF du crawler (CLAUDE.md §5.7).
6 +
7 +Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud,
8 +schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection
9 +par l'appelant (fetcher).
10 +"""
11 +
12 +import ipaddress
13 +import socket
14 +from urllib.parse import urlsplit
15 +
16 +BLOCKED_HOSTS = {
17 + "localhost",
18 + "metadata.google.internal",
19 + "metadata.gke.internal",
20 +}
21 +
22 +# Endpoint de métadonnées AWS/GCP/Azure/OpenStack
23 +METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"}
24 +
25 +
26 +def is_safe_ip(ip_str: str) -> bool:
27 + try:
28 + ip = ipaddress.ip_address(ip_str)
29 + except ValueError:
30 + return False
31 + if ip_str in METADATA_IPS:
32 + return False
33 + return not (
34 + ip.is_private
35 + or ip.is_loopback
36 + or ip.is_link_local
37 + or ip.is_multicast
38 + or ip.is_reserved
39 + or ip.is_unspecified
40 + )
41 +
42 +
43 +def resolve_host(host: str) -> list[str]:
44 + """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable."""
45 + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
46 + return list({info[4][0] for info in infos})
47 +
48 +
49 +def is_safe_url(url: str, *, resolve: bool = True) -> bool:
50 + """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues)."""
51 + try:
52 + parts = urlsplit(url)
53 + except ValueError:
54 + return False
55 + if parts.scheme.lower() not in ("http", "https"):
56 + return False
57 + host = parts.hostname
58 + if not host:
59 + return False
60 + host = host.lower().strip(".")
61 + if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"):
62 + return False
63 + # Hôte littéral IP
64 + try:
65 + ipaddress.ip_address(host)
66 + return is_safe_ip(host)
67 + except ValueError:
68 + pass
69 + if not resolve:
70 + return True
71 + try:
72 + ips = resolve_host(host)
73 + except (socket.gaierror, OSError):
74 + return False
75 + return bool(ips) and all(is_safe_ip(ip) for ip in ips)
added M2M32brouve-ka/packages/shared/urls.py +103 −0
@@ -0,0 +1,103 @@
1 +# Trouve-KA — canonicalisation d'URL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Canonicalisation d'URL prudente.
6 +
7 +Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources
8 +distinctes par accident. On normalise seulement ce qui est sûr :
9 +fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus,
10 +encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus.
11 +"""
12 +
13 +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote
14 +
15 +# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu)
16 +TRACKING_PARAMS = {
17 + "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id",
18 + "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid",
19 + "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid",
20 + "ref_src", "cmpid", "s_kwcid", "spm",
21 +}
22 +
23 +DEFAULT_PORTS = {"http": 80, "https": 443}
24 +
25 +
26 +def is_http_url(url: str) -> bool:
27 + try:
28 + scheme = urlsplit(url).scheme.lower()
29 + except ValueError:
30 + return False
31 + return scheme in ("http", "https")
32 +
33 +
34 +def canonicalize_url(url: str, base: str | None = None) -> str | None:
35 + """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http)."""
36 + url = url.strip()
37 + if not url:
38 + return None
39 + if base:
40 + from urllib.parse import urljoin
41 +
42 + url = urljoin(base, url)
43 + try:
44 + parts = urlsplit(url)
45 + except ValueError:
46 + return None
47 + scheme = parts.scheme.lower()
48 + if scheme not in ("http", "https"):
49 + return None
50 + host = parts.hostname
51 + if not host:
52 + return None
53 + host = host.strip(".").lower()
54 + try:
55 + host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host
56 + except UnicodeError:
57 + return None
58 +
59 + port = parts.port
60 + netloc = host
61 + if port and port != DEFAULT_PORTS.get(scheme):
62 + netloc = f"{host}:{port}"
63 +
64 + # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée)
65 + path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._")
66 + # Slashs multiples consécutifs → un seul (sûr pour HTTP)
67 + while "//" in path:
68 + path = path.replace("//", "/")
69 + if not path:
70 + path = "/"
71 +
72 + # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre
73 + query = ""
74 + if parts.query:
75 + kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
76 + if k.lower() not in TRACKING_PARAMS]
77 + query = urlencode(kept)
78 +
79 + # Fragment : toujours retiré (jamais envoyé au serveur)
80 + return urlunsplit((scheme, netloc, path, query, ""))
81 +
82 +
83 +def extract_domain(url: str) -> str | None:
84 + """Domaine enregistrable approximatif : hôte sans le préfixe www."""
85 + try:
86 + host = urlsplit(url).hostname
87 + except ValueError:
88 + return None
89 + if not host:
90 + return None
91 + host = host.lower().strip(".")
92 + return host[4:] if host.startswith("www.") else host
93 +
94 +
95 +def display_url(url: str, max_len: int = 80) -> str:
96 + """URL d'affichage façon breadcrumb : quebec.ca › services › permis."""
97 + parts = urlsplit(url)
98 + host = (parts.hostname or "").removeprefix("www.")
99 + segments = [unquote(s) for s in parts.path.split("/") if s]
100 + crumb = " › ".join([host, *segments[:3]])
101 + if len(crumb) > max_len:
102 + crumb = crumb[: max_len - 1] + "…"
103 + return crumb
added M2M32brouve-ka/packages/types/__init__.py +103 −0
@@ -0,0 +1,103 @@
1 +# Trouve-KA — types partagés du pipeline
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API."""
6 +
7 +from datetime import datetime
8 +from enum import StrEnum
9 +
10 +from pydantic import BaseModel, Field
11 +
12 +
13 +class ErrorCode(StrEnum):
14 + DNS = "dns"
15 + TIMEOUT = "timeout"
16 + TLS = "tls"
17 + HTTP_4XX = "http_4xx"
18 + HTTP_5XX = "http_5xx"
19 + ROBOTS_DENIED = "robots_denied"
20 + PARSE_FAILED = "parse_failed"
21 + UNSUPPORTED_CONTENT = "unsupported_content"
22 + TOO_LARGE = "too_large"
23 + DUPLICATE = "duplicate"
24 + SPAM = "spam"
25 + NOT_QUEBEC = "not_quebec"
26 + SSRF_BLOCKED = "ssrf_blocked"
27 + TOO_MANY_REDIRECTS = "too_many_redirects"
28 + CONNECTION = "connection"
29 + OK = "ok"
30 +
31 +
32 +class Outcome(StrEnum):
33 + INDEXED = "indexed"
34 + DUPLICATE = "duplicate"
35 + UNCHANGED = "unchanged"
36 + ERROR = "error"
37 + NOT_QUEBEC = "not_quebec"
38 + ROBOTS_BLOCKED = "robots_blocked"
39 + REDIRECT = "redirect"
40 +
41 +
42 +class FetchResult(BaseModel):
43 + url: str
44 + final_url: str
45 + status_code: int | None = None
46 + error_code: ErrorCode = ErrorCode.OK
47 + content_type: str | None = None
48 + charset: str | None = None
49 + body: bytes | None = None
50 + etag: str | None = None
51 + last_modified: str | None = None
52 + duration_ms: int = 0
53 + redirect_chain: list[str] = Field(default_factory=list)
54 +
55 +
56 +class ExtractedLink(BaseModel):
57 + url: str
58 + anchor: str = ""
59 + nofollow: bool = False
60 +
61 +
62 +class ParsedPage(BaseModel):
63 + url: str
64 + canonical_url: str | None = None
65 + title: str = ""
66 + description: str = ""
67 + body: str = ""
68 + headings: list[str] = Field(default_factory=list)
69 + language: str | None = None
70 + links: list[ExtractedLink] = Field(default_factory=list)
71 + published_at: datetime | None = None
72 + modified_at: datetime | None = None
73 + author: str | None = None
74 + noindex: bool = False
75 + nofollow_page: bool = False
76 + structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring
77 +
78 +
79 +class QuebecSignals(BaseModel):
80 + score: float = 0.0
81 + locations: list[str] = Field(default_factory=list)
82 + reasons: list[str] = Field(default_factory=list)
83 +
84 +
85 +class SearchResult(BaseModel):
86 + title: str
87 + url: str
88 + display_url: str
89 + snippet: str
90 + domain: str
91 + language: str | None = None
92 + quebec_score: float = 0.0
93 + badges: list[str] = Field(default_factory=list)
94 + published_at: datetime | None = None
95 +
96 +
97 +class SearchResponse(BaseModel):
98 + query: str
99 + total: int
100 + took_ms: int
101 + page: int
102 + limit: int
103 + results: list[SearchResult]
added M2M32brouve-ka/pnpm-lock.yaml +3967 −0
@@ -0,0 +1,3967 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .: {}
10 +
11 + apps/web:
12 + dependencies:
13 + class-variance-authority:
14 + specifier: ^0.7.1
15 + version: 0.7.1
16 + clsx:
17 + specifier: ^2.1.1
18 + version: 2.1.1
19 + next:
20 + specifier: ^15.3.5
21 + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
22 + react:
23 + specifier: ^19.1.0
24 + version: 19.2.8
25 + react-dom:
26 + specifier: ^19.1.0
27 + version: 19.2.8(react@19.2.8)
28 + tailwind-merge:
29 + specifier: ^2.6.0
30 + version: 2.6.1
31 + devDependencies:
32 + '@types/node':
33 + specifier: ^22.15.0
34 + version: 22.20.1
35 + '@types/react':
36 + specifier: ^19.1.0
37 + version: 19.2.18
38 + '@types/react-dom':
39 + specifier: ^19.1.0
40 + version: 19.2.4(@types/react@19.2.18)
41 + autoprefixer:
42 + specifier: ^10.4.21
43 + version: 10.5.4(postcss@8.5.26)
44 + eslint:
45 + specifier: ^8.57.1
46 + version: 8.57.1
47 + eslint-config-next:
48 + specifier: ^15.3.5
49 + version: 15.5.23(eslint@8.57.1)(typescript@5.9.3)
50 + postcss:
51 + specifier: ^8.5.3
52 + version: 8.5.26
53 + tailwindcss:
54 + specifier: ^3.4.17
55 + version: 3.4.19
56 + typescript:
57 + specifier: ^5.8.3
58 + version: 5.9.3
59 +
60 +packages:
61 +
62 + '@alloc/quick-lru@5.2.0':
63 + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
64 + engines: {node: '>=10'}
65 +
66 + '@emnapi/core@1.10.0':
67 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
68 +
69 + '@emnapi/runtime@1.10.0':
70 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
71 +
72 + '@emnapi/runtime@1.11.3':
73 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
74 +
75 + '@emnapi/wasi-threads@1.2.1':
76 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
77 +
78 + '@eslint-community/eslint-utils@4.10.1':
79 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
80 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
81 + peerDependencies:
82 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
83 +
84 + '@eslint-community/regexpp@4.12.2':
85 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
86 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
87 +
88 + '@eslint/eslintrc@2.1.4':
89 + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
90 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
91 +
92 + '@eslint/js@8.57.1':
93 + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
94 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
95 +
96 + '@humanwhocodes/config-array@0.13.0':
97 + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
98 + engines: {node: '>=10.10.0'}
99 + deprecated: Use @eslint/config-array instead
100 +
101 + '@humanwhocodes/module-importer@1.0.1':
102 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
103 + engines: {node: '>=12.22'}
104 +
105 + '@humanwhocodes/object-schema@2.0.3':
106 + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
107 + deprecated: Use @eslint/object-schema instead
108 +
109 + '@img/colour@1.1.0':
110 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
111 + engines: {node: '>=18'}
112 +
113 + '@img/sharp-darwin-arm64@0.34.5':
114 + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
115 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
116 + cpu: [arm64]
117 + os: [darwin]
118 +
119 + '@img/sharp-darwin-x64@0.34.5':
120 + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
121 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
122 + cpu: [x64]
123 + os: [darwin]
124 +
125 + '@img/sharp-libvips-darwin-arm64@1.2.4':
126 + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
127 + cpu: [arm64]
128 + os: [darwin]
129 +
130 + '@img/sharp-libvips-darwin-x64@1.2.4':
131 + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
132 + cpu: [x64]
133 + os: [darwin]
134 +
135 + '@img/sharp-libvips-linux-arm64@1.2.4':
136 + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
137 + cpu: [arm64]
138 + os: [linux]
139 + libc: [glibc]
140 +
141 + '@img/sharp-libvips-linux-arm@1.2.4':
142 + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
143 + cpu: [arm]
144 + os: [linux]
145 + libc: [glibc]
146 +
147 + '@img/sharp-libvips-linux-ppc64@1.2.4':
148 + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
149 + cpu: [ppc64]
150 + os: [linux]
151 + libc: [glibc]
152 +
153 + '@img/sharp-libvips-linux-riscv64@1.2.4':
154 + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
155 + cpu: [riscv64]
156 + os: [linux]
157 + libc: [glibc]
158 +
159 + '@img/sharp-libvips-linux-s390x@1.2.4':
160 + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
161 + cpu: [s390x]
162 + os: [linux]
163 + libc: [glibc]
164 +
165 + '@img/sharp-libvips-linux-x64@1.2.4':
166 + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
167 + cpu: [x64]
168 + os: [linux]
169 + libc: [glibc]
170 +
171 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
172 + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
173 + cpu: [arm64]
174 + os: [linux]
175 + libc: [musl]
176 +
177 + '@img/sharp-libvips-linuxmusl-x64@1.2.4':
178 + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
179 + cpu: [x64]
180 + os: [linux]
181 + libc: [musl]
182 +
183 + '@img/sharp-linux-arm64@0.34.5':
184 + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
185 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
186 + cpu: [arm64]
187 + os: [linux]
188 + libc: [glibc]
189 +
190 + '@img/sharp-linux-arm@0.34.5':
191 + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
192 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
193 + cpu: [arm]
194 + os: [linux]
195 + libc: [glibc]
196 +
197 + '@img/sharp-linux-ppc64@0.34.5':
198 + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
199 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
200 + cpu: [ppc64]
201 + os: [linux]
202 + libc: [glibc]
203 +
204 + '@img/sharp-linux-riscv64@0.34.5':
205 + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
206 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
207 + cpu: [riscv64]
208 + os: [linux]
209 + libc: [glibc]
210 +
211 + '@img/sharp-linux-s390x@0.34.5':
212 + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
213 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
214 + cpu: [s390x]
215 + os: [linux]
216 + libc: [glibc]
217 +
218 + '@img/sharp-linux-x64@0.34.5':
219 + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
220 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
221 + cpu: [x64]
222 + os: [linux]
223 + libc: [glibc]
224 +
225 + '@img/sharp-linuxmusl-arm64@0.34.5':
226 + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
227 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
228 + cpu: [arm64]
229 + os: [linux]
230 + libc: [musl]
231 +
232 + '@img/sharp-linuxmusl-x64@0.34.5':
233 + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
234 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
235 + cpu: [x64]
236 + os: [linux]
237 + libc: [musl]
238 +
239 + '@img/sharp-wasm32@0.34.5':
240 + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
241 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
242 + cpu: [wasm32]
243 +
244 + '@img/sharp-win32-arm64@0.34.5':
245 + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
246 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
247 + cpu: [arm64]
248 + os: [win32]
249 +
250 + '@img/sharp-win32-ia32@0.34.5':
251 + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
252 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
253 + cpu: [ia32]
254 + os: [win32]
255 +
256 + '@img/sharp-win32-x64@0.34.5':
257 + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
258 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
259 + cpu: [x64]
260 + os: [win32]
261 +
262 + '@jridgewell/gen-mapping@0.3.13':
263 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
264 +
265 + '@jridgewell/resolve-uri@3.1.2':
266 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
267 + engines: {node: '>=6.0.0'}
268 +
269 + '@jridgewell/sourcemap-codec@1.5.5':
270 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
271 +
272 + '@jridgewell/trace-mapping@0.3.31':
273 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
274 +
275 + '@napi-rs/wasm-runtime@1.2.3':
276 + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
277 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
278 + peerDependencies:
279 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
280 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
281 +
282 + '@next/env@15.5.23':
283 + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==}
284 +
285 + '@next/eslint-plugin-next@15.5.23':
286 + resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==}
287 +
288 + '@next/swc-darwin-arm64@15.5.23':
289 + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==}
290 + engines: {node: '>= 10'}
291 + cpu: [arm64]
292 + os: [darwin]
293 +
294 + '@next/swc-darwin-x64@15.5.23':
295 + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==}
296 + engines: {node: '>= 10'}
297 + cpu: [x64]
298 + os: [darwin]
299 +
300 + '@next/swc-linux-arm64-gnu@15.5.23':
301 + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==}
302 + engines: {node: '>= 10'}
303 + cpu: [arm64]
304 + os: [linux]
305 + libc: [glibc]
306 +
307 + '@next/swc-linux-arm64-musl@15.5.23':
308 + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==}
309 + engines: {node: '>= 10'}
310 + cpu: [arm64]
311 + os: [linux]
312 + libc: [musl]
313 +
314 + '@next/swc-linux-x64-gnu@15.5.23':
315 + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==}
316 + engines: {node: '>= 10'}
317 + cpu: [x64]
318 + os: [linux]
319 + libc: [glibc]
320 +
321 + '@next/swc-linux-x64-musl@15.5.23':
322 + resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==}
323 + engines: {node: '>= 10'}
324 + cpu: [x64]
325 + os: [linux]
326 + libc: [musl]
327 +
328 + '@next/swc-win32-arm64-msvc@15.5.23':
329 + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==}
330 + engines: {node: '>= 10'}
331 + cpu: [arm64]
332 + os: [win32]
333 +
334 + '@next/swc-win32-x64-msvc@15.5.23':
335 + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==}
336 + engines: {node: '>= 10'}
337 + cpu: [x64]
338 + os: [win32]
339 +
340 + '@nodelib/fs.scandir@2.1.5':
341 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
342 + engines: {node: '>= 8'}
343 +
344 + '@nodelib/fs.stat@2.0.5':
345 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
346 + engines: {node: '>= 8'}
347 +
348 + '@nodelib/fs.walk@1.2.8':
349 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
350 + engines: {node: '>= 8'}
351 +
352 + '@nolyfill/is-core-module@1.0.39':
353 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
354 + engines: {node: '>=12.4.0'}
355 +
356 + '@rtsao/scc@1.1.0':
357 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
358 +
359 + '@rushstack/eslint-patch@1.16.1':
360 + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
361 +
362 + '@swc/helpers@0.5.15':
363 + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
364 +
365 + '@tybys/wasm-util@0.10.3':
366 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
367 +
368 + '@types/json5@0.0.29':
369 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
370 +
371 + '@types/node@22.20.1':
372 + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
373 +
374 + '@types/react-dom@19.2.4':
375 + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
376 + peerDependencies:
377 + '@types/react': ^19.2.0
378 +
379 + '@types/react@19.2.18':
380 + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
381 +
382 + '@typescript-eslint/eslint-plugin@8.67.0':
383 + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
384 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
385 + peerDependencies:
386 + '@typescript-eslint/parser': ^8.67.0
387 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
388 + typescript: '>=4.8.4 <6.1.0'
389 +
390 + '@typescript-eslint/parser@8.67.0':
391 + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
392 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
393 + peerDependencies:
394 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
395 + typescript: '>=4.8.4 <6.1.0'
396 +
397 + '@typescript-eslint/project-service@8.67.0':
398 + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
399 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
400 + peerDependencies:
401 + typescript: '>=4.8.4 <6.1.0'
402 +
403 + '@typescript-eslint/scope-manager@8.67.0':
404 + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
405 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
406 +
407 + '@typescript-eslint/tsconfig-utils@8.67.0':
408 + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
409 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
410 + peerDependencies:
411 + typescript: '>=4.8.4 <6.1.0'
412 +
413 + '@typescript-eslint/type-utils@8.67.0':
414 + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
415 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
416 + peerDependencies:
417 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
418 + typescript: '>=4.8.4 <6.1.0'
419 +
420 + '@typescript-eslint/types@8.67.0':
421 + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
422 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
423 +
424 + '@typescript-eslint/typescript-estree@8.67.0':
425 + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
426 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
427 + peerDependencies:
428 + typescript: '>=4.8.4 <6.1.0'
429 +
430 + '@typescript-eslint/utils@8.67.0':
431 + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
432 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
433 + peerDependencies:
434 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
435 + typescript: '>=4.8.4 <6.1.0'
436 +
437 + '@typescript-eslint/visitor-keys@8.67.0':
438 + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
439 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
440 +
441 + '@ungap/structured-clone@1.3.3':
442 + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
443 +
444 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
445 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
446 + cpu: [arm]
447 + os: [android]
448 +
449 + '@unrs/resolver-binding-android-arm64@1.12.2':
450 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
451 + cpu: [arm64]
452 + os: [android]
453 +
454 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
455 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
456 + cpu: [arm64]
457 + os: [darwin]
458 +
459 + '@unrs/resolver-binding-darwin-x64@1.12.2':
460 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
461 + cpu: [x64]
462 + os: [darwin]
463 +
464 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
465 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
466 + cpu: [x64]
467 + os: [freebsd]
468 +
469 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
470 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
471 + cpu: [arm]
472 + os: [linux]
473 +
474 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
475 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
476 + cpu: [arm]
477 + os: [linux]
478 +
479 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
480 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
481 + cpu: [arm64]
482 + os: [linux]
483 + libc: [glibc]
484 +
485 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
486 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
487 + cpu: [arm64]
488 + os: [linux]
489 + libc: [musl]
490 +
491 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
492 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
493 + cpu: [loong64]
494 + os: [linux]
495 + libc: [glibc]
496 +
497 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
498 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
499 + cpu: [loong64]
500 + os: [linux]
501 + libc: [musl]
502 +
503 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
504 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
505 + cpu: [ppc64]
506 + os: [linux]
507 + libc: [glibc]
508 +
509 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
510 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
511 + cpu: [riscv64]
512 + os: [linux]
513 + libc: [glibc]
514 +
515 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
516 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
517 + cpu: [riscv64]
518 + os: [linux]
519 + libc: [musl]
520 +
521 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
522 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
523 + cpu: [s390x]
524 + os: [linux]
525 + libc: [glibc]
526 +
527 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
528 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
529 + cpu: [x64]
530 + os: [linux]
531 + libc: [glibc]
532 +
533 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
534 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
535 + cpu: [x64]
536 + os: [linux]
537 + libc: [musl]
538 +
539 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
540 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
541 + cpu: [arm64]
542 + os: [openharmony]
543 +
544 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
545 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
546 + engines: {node: '>=14.0.0'}
547 + cpu: [wasm32]
548 +
549 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
550 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
551 + cpu: [arm64]
552 + os: [win32]
553 +
554 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
555 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
556 + cpu: [ia32]
557 + os: [win32]
558 +
559 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
560 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
561 + cpu: [x64]
562 + os: [win32]
563 +
564 + acorn-jsx@5.3.2:
565 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
566 + peerDependencies:
567 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
568 +
569 + acorn@8.18.0:
570 + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
571 + engines: {node: '>=0.4.0'}
572 + hasBin: true
573 +
574 + ajv@6.15.0:
575 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
576 +
577 + ansi-regex@5.0.1:
578 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
579 + engines: {node: '>=8'}
580 +
581 + ansi-styles@4.3.0:
582 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
583 + engines: {node: '>=8'}
584 +
585 + any-promise@1.3.0:
586 + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
587 +
588 + anymatch@3.1.3:
589 + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
590 + engines: {node: '>= 8'}
591 +
592 + arg@5.0.2:
593 + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
594 +
595 + argparse@2.0.1:
596 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
597 +
598 + aria-query@5.3.2:
599 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
600 + engines: {node: '>= 0.4'}
601 +
602 + array-buffer-byte-length@1.0.2:
603 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
604 + engines: {node: '>= 0.4'}
605 +
606 + array-includes@3.1.9:
607 + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
608 + engines: {node: '>= 0.4'}
609 +
610 + array.prototype.findlast@1.2.5:
611 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
612 + engines: {node: '>= 0.4'}
613 +
614 + array.prototype.findlastindex@1.2.6:
615 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
616 + engines: {node: '>= 0.4'}
617 +
618 + array.prototype.flat@1.3.3:
619 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
620 + engines: {node: '>= 0.4'}
621 +
622 + array.prototype.flatmap@1.3.3:
623 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
624 + engines: {node: '>= 0.4'}
625 +
626 + array.prototype.tosorted@1.1.4:
627 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
628 + engines: {node: '>= 0.4'}
629 +
630 + arraybuffer.prototype.slice@1.0.4:
631 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
632 + engines: {node: '>= 0.4'}
633 +
634 + ast-types-flow@0.0.8:
635 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
636 +
637 + async-function@1.0.0:
638 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
639 + engines: {node: '>= 0.4'}
640 +
641 + autoprefixer@10.5.4:
642 + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==}
643 + engines: {node: ^10 || ^12 || >=14}
644 + hasBin: true
645 + peerDependencies:
646 + postcss: ^8.1.0
647 +
648 + available-typed-arrays@1.0.7:
649 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
650 + engines: {node: '>= 0.4'}
651 +
652 + axe-core@4.13.0:
653 + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
654 + engines: {node: '>=4'}
655 +
656 + axobject-query@4.1.0:
657 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
658 + engines: {node: '>= 0.4'}
659 +
660 + balanced-match@1.0.2:
661 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
662 +
663 + balanced-match@4.0.4:
664 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
665 + engines: {node: 18 || 20 || >=22}
666 +
667 + baseline-browser-mapping@2.11.13:
668 + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
669 + engines: {node: '>=6.0.0'}
670 + hasBin: true
671 +
672 + binary-extensions@2.3.0:
673 + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
674 + engines: {node: '>=8'}
675 +
676 + brace-expansion@1.1.18:
677 + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
678 +
679 + brace-expansion@5.0.9:
680 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
681 + engines: {node: 20 || >=22}
682 +
683 + braces@3.0.3:
684 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
685 + engines: {node: '>=8'}
686 +
687 + browserslist@4.28.8:
688 + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
689 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
690 + hasBin: true
691 +
692 + call-bind-apply-helpers@1.0.2:
693 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
694 + engines: {node: '>= 0.4'}
695 +
696 + call-bind@1.0.9:
697 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
698 + engines: {node: '>= 0.4'}
699 +
700 + call-bound@1.0.4:
701 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
702 + engines: {node: '>= 0.4'}
703 +
704 + callsites@3.1.0:
705 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
706 + engines: {node: '>=6'}
707 +
708 + camelcase-css@2.0.1:
709 + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
710 + engines: {node: '>= 6'}
711 +
712 + caniuse-lite@1.0.30001809:
713 + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
714 +
715 + chalk@4.1.2:
716 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
717 + engines: {node: '>=10'}
718 +
719 + chokidar@3.6.0:
720 + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
721 + engines: {node: '>= 8.10.0'}
722 +
723 + class-variance-authority@0.7.1:
724 + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
725 +
726 + client-only@0.0.1:
727 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
728 +
729 + clsx@2.1.1:
730 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
731 + engines: {node: '>=6'}
732 +
733 + color-convert@2.0.1:
734 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
735 + engines: {node: '>=7.0.0'}
736 +
737 + color-name@1.1.4:
738 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
739 +
740 + commander@4.1.1:
741 + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
742 + engines: {node: '>= 6'}
743 +
744 + concat-map@0.0.1:
745 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
746 +
747 + cross-spawn@7.0.6:
748 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
749 + engines: {node: '>= 8'}
750 +
751 + cssesc@3.0.0:
752 + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
753 + engines: {node: '>=4'}
754 + hasBin: true
755 +
756 + csstype@3.2.3:
757 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
758 +
759 + damerau-levenshtein@1.0.8:
760 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
761 +
762 + data-view-buffer@1.0.2:
763 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
764 + engines: {node: '>= 0.4'}
765 +
766 + data-view-byte-length@1.0.2:
767 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
768 + engines: {node: '>= 0.4'}
769 +
770 + data-view-byte-offset@1.0.1:
771 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
772 + engines: {node: '>= 0.4'}
773 +
774 + debug@3.2.7:
775 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
776 + peerDependencies:
777 + supports-color: '*'
778 + peerDependenciesMeta:
779 + supports-color:
780 + optional: true
781 +
782 + debug@4.4.3:
783 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
784 + engines: {node: '>=6.0'}
785 + peerDependencies:
786 + supports-color: '*'
787 + peerDependenciesMeta:
788 + supports-color:
789 + optional: true
790 +
791 + deep-is@0.1.4:
792 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
793 +
794 + define-data-property@1.1.4:
795 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
796 + engines: {node: '>= 0.4'}
797 +
798 + define-properties@1.2.1:
799 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
800 + engines: {node: '>= 0.4'}
801 +
802 + detect-libc@2.1.2:
803 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
804 + engines: {node: '>=8'}
805 +
806 + didyoumean@1.2.2:
807 + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
808 +
809 + dlv@1.1.3:
810 + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
811 +
812 + doctrine@2.1.0:
813 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
814 + engines: {node: '>=0.10.0'}
815 +
816 + doctrine@3.0.0:
817 + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
818 + engines: {node: '>=6.0.0'}
819 +
820 + dunder-proto@1.0.1:
821 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
822 + engines: {node: '>= 0.4'}
823 +
824 + electron-to-chromium@1.5.405:
825 + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
826 +
827 + emoji-regex@9.2.2:
828 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
829 +
830 + es-abstract-get@1.0.0:
831 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
832 + engines: {node: '>= 0.4'}
833 +
834 + es-abstract@1.24.2:
835 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
836 + engines: {node: '>= 0.4'}
837 +
838 + es-define-property@1.0.1:
839 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
840 + engines: {node: '>= 0.4'}
841 +
842 + es-errors@1.3.0:
843 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
844 + engines: {node: '>= 0.4'}
845 +
846 + es-iterator-helpers@1.4.0:
847 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
848 + engines: {node: '>= 0.4'}
849 +
850 + es-object-atoms@1.1.2:
851 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
852 + engines: {node: '>= 0.4'}
853 +
854 + es-set-tostringtag@2.1.0:
855 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
856 + engines: {node: '>= 0.4'}
857 +
858 + es-shim-unscopables@1.1.0:
859 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
860 + engines: {node: '>= 0.4'}
861 +
862 + es-to-primitive@1.3.4:
863 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
864 + engines: {node: '>= 0.4'}
865 +
866 + escalade@3.2.0:
867 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
868 + engines: {node: '>=6'}
869 +
870 + escape-string-regexp@4.0.0:
871 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
872 + engines: {node: '>=10'}
873 +
874 + eslint-config-next@15.5.23:
875 + resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==}
876 + peerDependencies:
877 + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
878 + typescript: '>=3.3.1'
879 + peerDependenciesMeta:
880 + typescript:
881 + optional: true
882 +
883 + eslint-import-resolver-node@0.3.10:
884 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
885 +
886 + eslint-import-resolver-typescript@3.10.1:
887 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
888 + engines: {node: ^14.18.0 || >=16.0.0}
889 + peerDependencies:
890 + eslint: '*'
891 + eslint-plugin-import: '*'
892 + eslint-plugin-import-x: '*'
893 + peerDependenciesMeta:
894 + eslint-plugin-import:
895 + optional: true
896 + eslint-plugin-import-x:
897 + optional: true
898 +
899 + eslint-module-utils@2.14.0:
900 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
901 + engines: {node: '>=4'}
902 + peerDependencies:
903 + '@typescript-eslint/parser': '*'
904 + eslint: '*'
905 + eslint-import-resolver-node: '*'
906 + eslint-import-resolver-typescript: '*'
907 + eslint-import-resolver-webpack: '*'
908 + peerDependenciesMeta:
909 + '@typescript-eslint/parser':
910 + optional: true
911 + eslint:
912 + optional: true
913 + eslint-import-resolver-node:
914 + optional: true
915 + eslint-import-resolver-typescript:
916 + optional: true
917 + eslint-import-resolver-webpack:
918 + optional: true
919 +
920 + eslint-plugin-import@2.32.0:
921 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
922 + engines: {node: '>=4'}
923 + peerDependencies:
924 + '@typescript-eslint/parser': '*'
925 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
926 + peerDependenciesMeta:
927 + '@typescript-eslint/parser':
928 + optional: true
929 +
930 + eslint-plugin-jsx-a11y@6.10.2:
931 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
932 + engines: {node: '>=4.0'}
933 + peerDependencies:
934 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
935 +
936 + eslint-plugin-react-hooks@5.2.0:
937 + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
938 + engines: {node: '>=10'}
939 + peerDependencies:
940 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
941 +
942 + eslint-plugin-react@7.37.5:
943 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
944 + engines: {node: '>=4'}
945 + peerDependencies:
946 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
947 +
948 + eslint-scope@7.2.2:
949 + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
950 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
951 +
952 + eslint-visitor-keys@3.4.3:
953 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
954 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
955 +
956 + eslint-visitor-keys@5.0.1:
957 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
958 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
959 +
960 + eslint@8.57.1:
961 + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
962 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
963 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
964 + hasBin: true
965 +
966 + espree@9.6.1:
967 + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
968 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
969 +
970 + esquery@1.7.0:
971 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
972 + engines: {node: '>=0.10'}
973 +
974 + esrecurse@4.3.0:
975 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
976 + engines: {node: '>=4.0'}
977 +
978 + estraverse@5.3.0:
979 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
980 + engines: {node: '>=4.0'}
981 +
982 + esutils@2.0.3:
983 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
984 + engines: {node: '>=0.10.0'}
985 +
986 + fast-deep-equal@3.1.3:
987 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
988 +
989 + fast-glob@3.3.1:
990 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
991 + engines: {node: '>=8.6.0'}
992 +
993 + fast-glob@3.3.3:
994 + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
995 + engines: {node: '>=8.6.0'}
996 +
997 + fast-json-stable-stringify@2.1.0:
998 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
999 +
1000 + fast-levenshtein@2.0.6:
1001 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1002 +
1003 + fastq@1.20.1:
1004 + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
1005 +
1006 + fdir@6.5.0:
1007 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1008 + engines: {node: '>=12.0.0'}
1009 + peerDependencies:
1010 + picomatch: ^3 || ^4
1011 + peerDependenciesMeta:
1012 + picomatch:
1013 + optional: true
1014 +
1015 + file-entry-cache@6.0.1:
1016 + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
1017 + engines: {node: ^10.12.0 || >=12.0.0}
1018 +
1019 + fill-range@7.1.1:
1020 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1021 + engines: {node: '>=8'}
1022 +
1023 + find-up@5.0.0:
1024 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1025 + engines: {node: '>=10'}
1026 +
1027 + flat-cache@3.2.0:
1028 + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
1029 + engines: {node: ^10.12.0 || >=12.0.0}
1030 +
1031 + flatted@3.4.4:
1032 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
1033 +
1034 + for-each@0.3.5:
1035 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1036 + engines: {node: '>= 0.4'}
1037 +
1038 + fraction.js@5.3.4:
1039 + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
1040 +
1041 + fs.realpath@1.0.0:
1042 + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
1043 +
1044 + fsevents@2.3.3:
1045 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1046 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1047 + os: [darwin]
1048 +
1049 + function-bind@1.1.2:
1050 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1051 +
1052 + function.prototype.name@1.2.0:
1053 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
1054 + engines: {node: '>= 0.4'}
1055 +
1056 + functions-have-names@1.2.3:
1057 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
1058 +
1059 + generator-function@2.0.1:
1060 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
1061 + engines: {node: '>= 0.4'}
1062 +
1063 + get-intrinsic@1.3.0:
1064 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1065 + engines: {node: '>= 0.4'}
1066 +
1067 + get-proto@1.0.1:
1068 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1069 + engines: {node: '>= 0.4'}
1070 +
1071 + get-symbol-description@1.1.0:
1072 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
1073 + engines: {node: '>= 0.4'}
1074 +
1075 + get-tsconfig@4.14.2:
1076 + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==}
1077 +
1078 + glob-parent@5.1.2:
1079 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1080 + engines: {node: '>= 6'}
1081 +
1082 + glob-parent@6.0.2:
1083 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1084 + engines: {node: '>=10.13.0'}
1085 +
1086 + glob@7.2.3:
1087 + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
1088 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
1089 +
1090 + globals@13.24.0:
1091 + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
1092 + engines: {node: '>=8'}
1093 +
1094 + globalthis@1.0.4:
1095 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
1096 + engines: {node: '>= 0.4'}
1097 +
1098 + gopd@1.2.0:
1099 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1100 + engines: {node: '>= 0.4'}
1101 +
1102 + graphemer@1.4.0:
1103 + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
1104 +
1105 + has-bigints@1.1.0:
1106 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
1107 + engines: {node: '>= 0.4'}
1108 +
1109 + has-flag@4.0.0:
1110 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1111 + engines: {node: '>=8'}
1112 +
1113 + has-property-descriptors@1.0.2:
1114 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
1115 +
1116 + has-proto@1.2.0:
1117 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
1118 + engines: {node: '>= 0.4'}
1119 +
1120 + has-symbols@1.1.0:
1121 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1122 + engines: {node: '>= 0.4'}
1123 +
1124 + has-tostringtag@1.0.2:
1125 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1126 + engines: {node: '>= 0.4'}
1127 +
1128 + hasown@2.0.4:
1129 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1130 + engines: {node: '>= 0.4'}
1131 +
1132 + ignore@5.3.2:
1133 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1134 + engines: {node: '>= 4'}
1135 +
1136 + ignore@7.0.6:
1137 + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
1138 + engines: {node: '>= 4'}
1139 +
1140 + import-fresh@3.3.1:
1141 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1142 + engines: {node: '>=6'}
1143 +
1144 + imurmurhash@0.1.4:
1145 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1146 + engines: {node: '>=0.8.19'}
1147 +
1148 + inflight@1.0.6:
1149 + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
1150 + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
1151 +
1152 + inherits@2.0.4:
1153 + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
1154 +
1155 + internal-slot@1.1.0:
1156 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1157 + engines: {node: '>= 0.4'}
1158 +
1159 + is-array-buffer@3.0.5:
1160 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1161 + engines: {node: '>= 0.4'}
1162 +
1163 + is-async-function@2.1.1:
1164 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
1165 + engines: {node: '>= 0.4'}
1166 +
1167 + is-bigint@1.1.0:
1168 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
1169 + engines: {node: '>= 0.4'}
1170 +
1171 + is-binary-path@2.1.0:
1172 + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
1173 + engines: {node: '>=8'}
1174 +
1175 + is-boolean-object@1.2.2:
1176 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
1177 + engines: {node: '>= 0.4'}
1178 +
1179 + is-bun-module@2.0.0:
1180 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
1181 +
1182 + is-callable@1.2.7:
1183 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
1184 + engines: {node: '>= 0.4'}
1185 +
1186 + is-core-module@2.16.2:
1187 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
1188 + engines: {node: '>= 0.4'}
1189 +
1190 + is-data-view@1.0.2:
1191 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
1192 + engines: {node: '>= 0.4'}
1193 +
1194 + is-date-object@1.1.0:
1195 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1196 + engines: {node: '>= 0.4'}
1197 +
1198 + is-document.all@1.0.0:
1199 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1200 + engines: {node: '>= 0.4'}
1201 +
1202 + is-extglob@2.1.1:
1203 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1204 + engines: {node: '>=0.10.0'}
1205 +
1206 + is-finalizationregistry@1.1.1:
1207 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
1208 + engines: {node: '>= 0.4'}
1209 +
1210 + is-generator-function@1.1.2:
1211 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
1212 + engines: {node: '>= 0.4'}
1213 +
1214 + is-glob@4.0.3:
1215 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1216 + engines: {node: '>=0.10.0'}
1217 +
1218 + is-map@2.0.3:
1219 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
1220 + engines: {node: '>= 0.4'}
1221 +
1222 + is-negative-zero@2.0.3:
1223 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
1224 + engines: {node: '>= 0.4'}
1225 +
1226 + is-number-object@1.1.1:
1227 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
1228 + engines: {node: '>= 0.4'}
1229 +
1230 + is-number@7.0.0:
1231 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1232 + engines: {node: '>=0.12.0'}
1233 +
1234 + is-path-inside@3.0.3:
1235 + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
1236 + engines: {node: '>=8'}
1237 +
1238 + is-regex@1.2.1:
1239 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1240 + engines: {node: '>= 0.4'}
1241 +
1242 + is-set@2.0.3:
1243 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
1244 + engines: {node: '>= 0.4'}
1245 +
1246 + is-shared-array-buffer@1.0.4:
1247 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
1248 + engines: {node: '>= 0.4'}
1249 +
1250 + is-string@1.1.1:
1251 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
1252 + engines: {node: '>= 0.4'}
1253 +
1254 + is-symbol@1.1.1:
1255 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
1256 + engines: {node: '>= 0.4'}
1257 +
1258 + is-typed-array@1.1.15:
1259 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
1260 + engines: {node: '>= 0.4'}
1261 +
1262 + is-weakmap@2.0.2:
1263 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
1264 + engines: {node: '>= 0.4'}
1265 +
1266 + is-weakref@1.1.1:
1267 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
1268 + engines: {node: '>= 0.4'}
1269 +
1270 + is-weakset@2.0.4:
1271 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
1272 + engines: {node: '>= 0.4'}
1273 +
1274 + isarray@2.0.5:
1275 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
1276 +
1277 + isexe@2.0.0:
1278 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1279 +
1280 + iterator.prototype@1.1.5:
1281 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
1282 + engines: {node: '>= 0.4'}
1283 +
1284 + jiti@1.21.7:
1285 + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
1286 + hasBin: true
1287 +
1288 + js-tokens@4.0.0:
1289 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1290 +
1291 + js-yaml@4.3.1:
1292 + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
1293 + hasBin: true
1294 +
1295 + json-buffer@3.0.1:
1296 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
1297 +
1298 + json-schema-traverse@0.4.1:
1299 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
1300 +
1301 + json-stable-stringify-without-jsonify@1.0.1:
1302 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
1303 +
1304 + json5@1.0.2:
1305 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
1306 + hasBin: true
1307 +
1308 + jsx-ast-utils@3.3.5:
1309 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
1310 + engines: {node: '>=4.0'}
1311 +
1312 + keyv@4.5.4:
1313 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
1314 +
1315 + language-subtag-registry@0.3.23:
1316 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
1317 +
1318 + language-tags@1.0.9:
1319 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
1320 + engines: {node: '>=0.10'}
1321 +
1322 + levn@0.4.1:
1323 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
1324 + engines: {node: '>= 0.8.0'}
1325 +
1326 + lilconfig@3.1.3:
1327 + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
1328 + engines: {node: '>=14'}
1329 +
1330 + lines-and-columns@1.2.4:
1331 + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
1332 +
1333 + locate-path@6.0.0:
1334 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
1335 + engines: {node: '>=10'}
1336 +
1337 + lodash.merge@4.6.2:
1338 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
1339 +
1340 + loose-envify@1.4.0:
1341 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1342 + hasBin: true
1343 +
1344 + math-intrinsics@1.1.0:
1345 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
1346 + engines: {node: '>= 0.4'}
1347 +
1348 + merge2@1.4.1:
1349 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
1350 + engines: {node: '>= 8'}
1351 +
1352 + micromatch@4.0.8:
1353 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1354 + engines: {node: '>=8.6'}
1355 +
1356 + minimatch@10.2.6:
1357 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1358 + engines: {node: 18 || 20 || >=22}
1359 +
1360 + minimatch@3.1.5:
1361 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
1362 +
1363 + minimist@1.2.8:
1364 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
1365 +
1366 + ms@2.1.3:
1367 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1368 +
1369 + mz@2.7.0:
1370 + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
1371 +
1372 + nanoid@3.3.18:
1373 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
1374 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1375 + hasBin: true
1376 +
1377 + napi-postinstall@0.3.4:
1378 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
1379 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
1380 + hasBin: true
1381 +
1382 + natural-compare@1.4.0:
1383 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
1384 +
1385 + next@15.5.23:
1386 + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==}
1387 + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
1388 + hasBin: true
1389 + peerDependencies:
1390 + '@opentelemetry/api': ^1.1.0
1391 + '@playwright/test': ^1.51.1
1392 + babel-plugin-react-compiler: '*'
1393 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1394 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1395 + sass: ^1.3.0
1396 + peerDependenciesMeta:
1397 + '@opentelemetry/api':
1398 + optional: true
1399 + '@playwright/test':
1400 + optional: true
1401 + babel-plugin-react-compiler:
1402 + optional: true
1403 + sass:
1404 + optional: true
1405 +
1406 + node-exports-info@1.6.2:
1407 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
1408 + engines: {node: '>= 0.4'}
1409 +
1410 + node-releases@2.0.53:
1411 + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
1412 + engines: {node: '>=18'}
1413 +
1414 + normalize-path@3.0.0:
1415 + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
1416 + engines: {node: '>=0.10.0'}
1417 +
1418 + object-assign@4.1.1:
1419 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1420 + engines: {node: '>=0.10.0'}
1421 +
1422 + object-hash@3.0.0:
1423 + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
1424 + engines: {node: '>= 6'}
1425 +
1426 + object-inspect@1.13.4:
1427 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
1428 + engines: {node: '>= 0.4'}
1429 +
1430 + object-keys@1.1.1:
1431 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
1432 + engines: {node: '>= 0.4'}
1433 +
1434 + object.assign@4.1.7:
1435 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
1436 + engines: {node: '>= 0.4'}
1437 +
1438 + object.entries@1.1.9:
1439 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
1440 + engines: {node: '>= 0.4'}
1441 +
1442 + object.fromentries@2.0.8:
1443 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
1444 + engines: {node: '>= 0.4'}
1445 +
1446 + object.groupby@1.0.3:
1447 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
1448 + engines: {node: '>= 0.4'}
1449 +
1450 + object.values@1.2.1:
1451 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1452 + engines: {node: '>= 0.4'}
1453 +
1454 + once@1.4.0:
1455 + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
1456 +
1457 + optionator@0.9.4:
1458 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1459 + engines: {node: '>= 0.8.0'}
1460 +
1461 + own-keys@1.0.2:
1462 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
1463 + engines: {node: '>= 0.4'}
1464 +
1465 + p-limit@3.1.0:
1466 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
1467 + engines: {node: '>=10'}
1468 +
1469 + p-locate@5.0.0:
1470 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
1471 + engines: {node: '>=10'}
1472 +
1473 + parent-module@1.0.1:
1474 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1475 + engines: {node: '>=6'}
1476 +
1477 + path-exists@4.0.0:
1478 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1479 + engines: {node: '>=8'}
1480 +
1481 + path-is-absolute@1.0.1:
1482 + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
1483 + engines: {node: '>=0.10.0'}
1484 +
1485 + path-key@3.1.1:
1486 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
1487 + engines: {node: '>=8'}
1488 +
1489 + path-parse@1.0.7:
1490 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
1491 +
1492 + picocolors@1.1.1:
1493 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1494 +
1495 + picomatch@2.3.2:
1496 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
1497 + engines: {node: '>=8.6'}
1498 +
1499 + picomatch@4.0.5:
1500 + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
1501 + engines: {node: '>=12'}
1502 +
1503 + pify@2.3.0:
1504 + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
1505 + engines: {node: '>=0.10.0'}
1506 +
1507 + pirates@4.0.7:
1508 + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
1509 + engines: {node: '>= 6'}
1510 +
1511 + possible-typed-array-names@1.1.0:
1512 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1513 + engines: {node: '>= 0.4'}
1514 +
1515 + postcss-import@15.1.0:
1516 + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
1517 + engines: {node: '>=14.0.0'}
1518 + peerDependencies:
1519 + postcss: ^8.0.0
1520 +
1521 + postcss-js@4.1.0:
1522 + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
1523 + engines: {node: ^12 || ^14 || >= 16}
1524 + peerDependencies:
1525 + postcss: ^8.4.21
1526 +
1527 + postcss-load-config@6.0.1:
1528 + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
1529 + engines: {node: '>= 18'}
1530 + peerDependencies:
1531 + jiti: '>=1.21.0'
1532 + postcss: '>=8.0.9'
1533 + tsx: ^4.8.1
1534 + yaml: ^2.4.2
1535 + peerDependenciesMeta:
1536 + jiti:
1537 + optional: true
1538 + postcss:
1539 + optional: true
1540 + tsx:
1541 + optional: true
1542 + yaml:
1543 + optional: true
1544 +
1545 + postcss-nested@6.2.0:
1546 + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
1547 + engines: {node: '>=12.0'}
1548 + peerDependencies:
1549 + postcss: ^8.2.14
1550 +
1551 + postcss-selector-parser@6.1.4:
1552 + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
1553 + engines: {node: '>=4'}
1554 +
1555 + postcss-value-parser@4.2.0:
1556 + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
1557 +
1558 + postcss@8.4.31:
1559 + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
1560 + engines: {node: ^10 || ^12 || >=14}
1561 +
1562 + postcss@8.5.26:
1563 + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
1564 + engines: {node: ^10 || ^12 || >=14}
1565 +
1566 + prelude-ls@1.2.1:
1567 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1568 + engines: {node: '>= 0.8.0'}
1569 +
1570 + prop-types@15.8.1:
1571 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1572 +
1573 + punycode@2.3.1:
1574 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1575 + engines: {node: '>=6'}
1576 +
1577 + queue-microtask@1.2.3:
1578 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1579 +
1580 + react-dom@19.2.8:
1581 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1582 + peerDependencies:
1583 + react: ^19.2.8
1584 +
1585 + react-is@16.13.1:
1586 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1587 +
1588 + react@19.2.8:
1589 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1590 + engines: {node: '>=0.10.0'}
1591 +
1592 + read-cache@1.0.0:
1593 + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
1594 +
1595 + readdirp@3.6.0:
1596 + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
1597 + engines: {node: '>=8.10.0'}
1598 +
1599 + reflect.getprototypeof@1.0.10:
1600 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1601 + engines: {node: '>= 0.4'}
1602 +
1603 + regexp.prototype.flags@1.5.4:
1604 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
1605 + engines: {node: '>= 0.4'}
1606 +
1607 + resolve-from@4.0.0:
1608 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
1609 + engines: {node: '>=4'}
1610 +
1611 + resolve-pkg-maps@1.0.0:
1612 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1613 +
1614 + resolve@1.22.12:
1615 + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
1616 + engines: {node: '>= 0.4'}
1617 + hasBin: true
1618 +
1619 + resolve@2.0.0-next.7:
1620 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
1621 + engines: {node: '>= 0.4'}
1622 + hasBin: true
1623 +
1624 + reusify@1.1.0:
1625 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1626 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1627 +
1628 + rimraf@3.0.2:
1629 + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
1630 + deprecated: Rimraf versions prior to v4 are no longer supported
1631 + hasBin: true
1632 +
1633 + run-parallel@1.2.0:
1634 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1635 +
1636 + safe-array-concat@1.1.4:
1637 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
1638 + engines: {node: '>=0.4'}
1639 +
1640 + safe-push-apply@1.0.0:
1641 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
1642 + engines: {node: '>= 0.4'}
1643 +
1644 + safe-regex-test@1.1.0:
1645 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1646 + engines: {node: '>= 0.4'}
1647 +
1648 + scheduler@0.27.0:
1649 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1650 +
1651 + semver@6.3.1:
1652 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
1653 + hasBin: true
1654 +
1655 + semver@7.8.5:
1656 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1657 + engines: {node: '>=10'}
1658 + hasBin: true
1659 +
1660 + set-function-length@1.2.2:
1661 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
1662 + engines: {node: '>= 0.4'}
1663 +
1664 + set-function-name@2.0.2:
1665 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
1666 + engines: {node: '>= 0.4'}
1667 +
1668 + set-proto@1.0.0:
1669 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
1670 + engines: {node: '>= 0.4'}
1671 +
1672 + sharp@0.34.5:
1673 + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
1674 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
1675 +
1676 + shebang-command@2.0.0:
1677 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
1678 + engines: {node: '>=8'}
1679 +
1680 + shebang-regex@3.0.0:
1681 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1682 + engines: {node: '>=8'}
1683 +
1684 + side-channel-list@1.0.1:
1685 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
1686 + engines: {node: '>= 0.4'}
1687 +
1688 + side-channel-map@1.0.1:
1689 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
1690 + engines: {node: '>= 0.4'}
1691 +
1692 + side-channel-weakmap@1.0.2:
1693 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
1694 + engines: {node: '>= 0.4'}
1695 +
1696 + side-channel@1.1.1:
1697 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
1698 + engines: {node: '>= 0.4'}
1699 +
1700 + source-map-js@1.2.1:
1701 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1702 + engines: {node: '>=0.10.0'}
1703 +
1704 + stable-hash@0.0.5:
1705 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
1706 +
1707 + stop-iteration-iterator@1.1.0:
1708 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1709 + engines: {node: '>= 0.4'}
1710 +
1711 + string.prototype.includes@2.0.1:
1712 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
1713 + engines: {node: '>= 0.4'}
1714 +
1715 + string.prototype.matchall@4.0.12:
1716 + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
1717 + engines: {node: '>= 0.4'}
1718 +
1719 + string.prototype.repeat@1.0.0:
1720 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
1721 +
1722 + string.prototype.trim@1.2.11:
1723 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
1724 + engines: {node: '>= 0.4'}
1725 +
1726 + string.prototype.trimend@1.0.10:
1727 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
1728 + engines: {node: '>= 0.4'}
1729 +
1730 + string.prototype.trimstart@1.0.8:
1731 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
1732 + engines: {node: '>= 0.4'}
1733 +
1734 + strip-ansi@6.0.1:
1735 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
1736 + engines: {node: '>=8'}
1737 +
1738 + strip-bom@3.0.0:
1739 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1740 + engines: {node: '>=4'}
1741 +
1742 + strip-json-comments@3.1.1:
1743 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1744 + engines: {node: '>=8'}
1745 +
1746 + styled-jsx@5.1.6:
1747 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
1748 + engines: {node: '>= 12.0.0'}
1749 + peerDependencies:
1750 + '@babel/core': '*'
1751 + babel-plugin-macros: '*'
1752 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
1753 + peerDependenciesMeta:
1754 + '@babel/core':
1755 + optional: true
1756 + babel-plugin-macros:
1757 + optional: true
1758 +
1759 + sucrase@3.35.1:
1760 + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
1761 + engines: {node: '>=16 || 14 >=14.17'}
1762 + hasBin: true
1763 +
1764 + supports-color@7.2.0:
1765 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
1766 + engines: {node: '>=8'}
1767 +
1768 + supports-preserve-symlinks-flag@1.0.0:
1769 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
1770 + engines: {node: '>= 0.4'}
1771 +
1772 + tailwind-merge@2.6.1:
1773 + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
1774 +
1775 + tailwindcss@3.4.19:
1776 + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
1777 + engines: {node: '>=14.0.0'}
1778 + hasBin: true
1779 +
1780 + text-table@0.2.0:
1781 + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
1782 +
1783 + thenify-all@1.6.0:
1784 + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
1785 + engines: {node: '>=0.8'}
1786 +
1787 + thenify@3.3.1:
1788 + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
1789 +
1790 + tinyglobby@0.2.17:
1791 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1792 + engines: {node: '>=12.0.0'}
1793 +
1794 + to-regex-range@5.0.1:
1795 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1796 + engines: {node: '>=8.0'}
1797 +
1798 + ts-api-utils@2.5.0:
1799 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
1800 + engines: {node: '>=18.12'}
1801 + peerDependencies:
1802 + typescript: '>=4.8.4'
1803 +
1804 + ts-interface-checker@0.1.13:
1805 + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
1806 +
1807 + tsconfig-paths@3.15.0:
1808 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
1809 +
1810 + tslib@2.8.1:
1811 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
1812 +
1813 + type-check@0.4.0:
1814 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
1815 + engines: {node: '>= 0.8.0'}
1816 +
1817 + type-fest@0.20.2:
1818 + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
1819 + engines: {node: '>=10'}
1820 +
1821 + typed-array-buffer@1.0.3:
1822 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
1823 + engines: {node: '>= 0.4'}
1824 +
1825 + typed-array-byte-length@1.0.3:
1826 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
1827 + engines: {node: '>= 0.4'}
1828 +
1829 + typed-array-byte-offset@1.0.4:
1830 + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
1831 + engines: {node: '>= 0.4'}
1832 +
1833 + typed-array-length@1.0.8:
1834 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
1835 + engines: {node: '>= 0.4'}
1836 +
1837 + typescript@5.9.3:
1838 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
1839 + engines: {node: '>=14.17'}
1840 + hasBin: true
1841 +
1842 + unbox-primitive@1.1.0:
1843 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
1844 + engines: {node: '>= 0.4'}
1845 +
1846 + undici-types@6.21.0:
1847 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
1848 +
1849 + unrs-resolver@1.12.2:
1850 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
1851 +
1852 + update-browserslist-db@1.3.1:
1853 + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
1854 + hasBin: true
1855 + peerDependencies:
1856 + browserslist: '>= 4.21.0'
1857 +
1858 + uri-js@4.4.1:
1859 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
1860 +
1861 + util-deprecate@1.0.2:
1862 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
1863 +
1864 + which-boxed-primitive@1.1.1:
1865 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
1866 + engines: {node: '>= 0.4'}
1867 +
1868 + which-builtin-type@1.2.1:
1869 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
1870 + engines: {node: '>= 0.4'}
1871 +
1872 + which-collection@1.0.2:
1873 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
1874 + engines: {node: '>= 0.4'}
1875 +
1876 + which-typed-array@1.1.22:
1877 + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
1878 + engines: {node: '>= 0.4'}
1879 +
1880 + which@2.0.2:
1881 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
1882 + engines: {node: '>= 8'}
1883 + hasBin: true
1884 +
1885 + word-wrap@1.2.5:
1886 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
1887 + engines: {node: '>=0.10.0'}
1888 +
1889 + wrappy@1.0.2:
1890 + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
1891 +
1892 + yocto-queue@0.1.0:
1893 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
1894 + engines: {node: '>=10'}
1895 +
1896 +snapshots:
1897 +
1898 + '@alloc/quick-lru@5.2.0': {}
1899 +
1900 + '@emnapi/core@1.10.0':
1901 + dependencies:
1902 + '@emnapi/wasi-threads': 1.2.1
1903 + tslib: 2.8.1
1904 + optional: true
1905 +
1906 + '@emnapi/runtime@1.10.0':
1907 + dependencies:
1908 + tslib: 2.8.1
1909 + optional: true
1910 +
1911 + '@emnapi/runtime@1.11.3':
1912 + dependencies:
1913 + tslib: 2.8.1
1914 + optional: true
1915 +
1916 + '@emnapi/wasi-threads@1.2.1':
1917 + dependencies:
1918 + tslib: 2.8.1
1919 + optional: true
1920 +
1921 + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)':
1922 + dependencies:
1923 + eslint: 8.57.1
1924 + eslint-visitor-keys: 3.4.3
1925 +
1926 + '@eslint-community/regexpp@4.12.2': {}
1927 +
1928 + '@eslint/eslintrc@2.1.4':
1929 + dependencies:
1930 + ajv: 6.15.0
1931 + debug: 4.4.3
1932 + espree: 9.6.1
1933 + globals: 13.24.0
1934 + ignore: 5.3.2
1935 + import-fresh: 3.3.1
1936 + js-yaml: 4.3.1
1937 + minimatch: 3.1.5
1938 + strip-json-comments: 3.1.1
1939 + transitivePeerDependencies:
1940 + - supports-color
1941 +
1942 + '@eslint/js@8.57.1': {}
1943 +
1944 + '@humanwhocodes/config-array@0.13.0':
1945 + dependencies:
1946 + '@humanwhocodes/object-schema': 2.0.3
1947 + debug: 4.4.3
1948 + minimatch: 3.1.5
1949 + transitivePeerDependencies:
1950 + - supports-color
1951 +
1952 + '@humanwhocodes/module-importer@1.0.1': {}
1953 +
1954 + '@humanwhocodes/object-schema@2.0.3': {}
1955 +
1956 + '@img/colour@1.1.0':
1957 + optional: true
1958 +
1959 + '@img/sharp-darwin-arm64@0.34.5':
1960 + optionalDependencies:
1961 + '@img/sharp-libvips-darwin-arm64': 1.2.4
1962 + optional: true
1963 +
1964 + '@img/sharp-darwin-x64@0.34.5':
1965 + optionalDependencies:
1966 + '@img/sharp-libvips-darwin-x64': 1.2.4
1967 + optional: true
1968 +
1969 + '@img/sharp-libvips-darwin-arm64@1.2.4':
1970 + optional: true
1971 +
1972 + '@img/sharp-libvips-darwin-x64@1.2.4':
1973 + optional: true
1974 +
1975 + '@img/sharp-libvips-linux-arm64@1.2.4':
1976 + optional: true
1977 +
1978 + '@img/sharp-libvips-linux-arm@1.2.4':
1979 + optional: true
1980 +
1981 + '@img/sharp-libvips-linux-ppc64@1.2.4':
1982 + optional: true
1983 +
1984 + '@img/sharp-libvips-linux-riscv64@1.2.4':
1985 + optional: true
1986 +
1987 + '@img/sharp-libvips-linux-s390x@1.2.4':
1988 + optional: true
1989 +
1990 + '@img/sharp-libvips-linux-x64@1.2.4':
1991 + optional: true
1992 +
1993 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
1994 + optional: true
1995 +
1996 + '@img/sharp-libvips-linuxmusl-x64@1.2.4':
1997 + optional: true
1998 +
1999 + '@img/sharp-linux-arm64@0.34.5':
2000 + optionalDependencies:
2001 + '@img/sharp-libvips-linux-arm64': 1.2.4
2002 + optional: true
2003 +
2004 + '@img/sharp-linux-arm@0.34.5':
2005 + optionalDependencies:
2006 + '@img/sharp-libvips-linux-arm': 1.2.4
2007 + optional: true
2008 +
2009 + '@img/sharp-linux-ppc64@0.34.5':
2010 + optionalDependencies:
2011 + '@img/sharp-libvips-linux-ppc64': 1.2.4
2012 + optional: true
2013 +
2014 + '@img/sharp-linux-riscv64@0.34.5':
2015 + optionalDependencies:
2016 + '@img/sharp-libvips-linux-riscv64': 1.2.4
2017 + optional: true
2018 +
2019 + '@img/sharp-linux-s390x@0.34.5':
2020 + optionalDependencies:
2021 + '@img/sharp-libvips-linux-s390x': 1.2.4
2022 + optional: true
2023 +
2024 + '@img/sharp-linux-x64@0.34.5':
2025 + optionalDependencies:
2026 + '@img/sharp-libvips-linux-x64': 1.2.4
2027 + optional: true
2028 +
2029 + '@img/sharp-linuxmusl-arm64@0.34.5':
2030 + optionalDependencies:
2031 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
2032 + optional: true
2033 +
2034 + '@img/sharp-linuxmusl-x64@0.34.5':
2035 + optionalDependencies:
2036 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4
2037 + optional: true
2038 +
2039 + '@img/sharp-wasm32@0.34.5':
2040 + dependencies:
2041 + '@emnapi/runtime': 1.11.3
2042 + optional: true
2043 +
2044 + '@img/sharp-win32-arm64@0.34.5':
2045 + optional: true
2046 +
2047 + '@img/sharp-win32-ia32@0.34.5':
2048 + optional: true
2049 +
2050 + '@img/sharp-win32-x64@0.34.5':
2051 + optional: true
2052 +
2053 + '@jridgewell/gen-mapping@0.3.13':
2054 + dependencies:
2055 + '@jridgewell/sourcemap-codec': 1.5.5
2056 + '@jridgewell/trace-mapping': 0.3.31
2057 +
2058 + '@jridgewell/resolve-uri@3.1.2': {}
2059 +
2060 + '@jridgewell/sourcemap-codec@1.5.5': {}
2061 +
2062 + '@jridgewell/trace-mapping@0.3.31':
2063 + dependencies:
2064 + '@jridgewell/resolve-uri': 3.1.2
2065 + '@jridgewell/sourcemap-codec': 1.5.5
2066 +
2067 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
2068 + dependencies:
2069 + '@emnapi/core': 1.10.0
2070 + '@emnapi/runtime': 1.10.0
2071 + '@tybys/wasm-util': 0.10.3
2072 + optional: true
2073 +
2074 + '@next/env@15.5.23': {}
2075 +
2076 + '@next/eslint-plugin-next@15.5.23':
2077 + dependencies:
2078 + fast-glob: 3.3.1
2079 +
2080 + '@next/swc-darwin-arm64@15.5.23':
2081 + optional: true
2082 +
2083 + '@next/swc-darwin-x64@15.5.23':
2084 + optional: true
2085 +
2086 + '@next/swc-linux-arm64-gnu@15.5.23':
2087 + optional: true
2088 +
2089 + '@next/swc-linux-arm64-musl@15.5.23':
2090 + optional: true
2091 +
2092 + '@next/swc-linux-x64-gnu@15.5.23':
2093 + optional: true
2094 +
2095 + '@next/swc-linux-x64-musl@15.5.23':
2096 + optional: true
2097 +
2098 + '@next/swc-win32-arm64-msvc@15.5.23':
2099 + optional: true
2100 +
2101 + '@next/swc-win32-x64-msvc@15.5.23':
2102 + optional: true
2103 +
2104 + '@nodelib/fs.scandir@2.1.5':
2105 + dependencies:
2106 + '@nodelib/fs.stat': 2.0.5
2107 + run-parallel: 1.2.0
2108 +
2109 + '@nodelib/fs.stat@2.0.5': {}
2110 +
2111 + '@nodelib/fs.walk@1.2.8':
2112 + dependencies:
2113 + '@nodelib/fs.scandir': 2.1.5
2114 + fastq: 1.20.1
2115 +
2116 + '@nolyfill/is-core-module@1.0.39': {}
2117 +
2118 + '@rtsao/scc@1.1.0': {}
2119 +
2120 + '@rushstack/eslint-patch@1.16.1': {}
2121 +
2122 + '@swc/helpers@0.5.15':
2123 + dependencies:
2124 + tslib: 2.8.1
2125 +
2126 + '@tybys/wasm-util@0.10.3':
2127 + dependencies:
2128 + tslib: 2.8.1
2129 + optional: true
2130 +
2131 + '@types/json5@0.0.29': {}
2132 +
2133 + '@types/node@22.20.1':
2134 + dependencies:
2135 + undici-types: 6.21.0
2136 +
2137 + '@types/react-dom@19.2.4(@types/react@19.2.18)':
2138 + dependencies:
2139 + '@types/react': 19.2.18
2140 +
2141 + '@types/react@19.2.18':
2142 + dependencies:
2143 + csstype: 3.2.3
2144 +
2145 + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
2146 + dependencies:
2147 + '@eslint-community/regexpp': 4.12.2
2148 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2149 + '@typescript-eslint/scope-manager': 8.67.0
2150 + '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2151 + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2152 + '@typescript-eslint/visitor-keys': 8.67.0
2153 + eslint: 8.57.1
2154 + ignore: 7.0.6
2155 + natural-compare: 1.4.0
2156 + ts-api-utils: 2.5.0(typescript@5.9.3)
2157 + typescript: 5.9.3
2158 + transitivePeerDependencies:
2159 + - supports-color
2160 +
2161 + '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2162 + dependencies:
2163 + '@typescript-eslint/scope-manager': 8.67.0
2164 + '@typescript-eslint/types': 8.67.0
2165 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2166 + '@typescript-eslint/visitor-keys': 8.67.0
2167 + debug: 4.4.3
2168 + eslint: 8.57.1
2169 + typescript: 5.9.3
2170 + transitivePeerDependencies:
2171 + - supports-color
2172 +
2173 + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)':
2174 + dependencies:
2175 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
2176 + '@typescript-eslint/types': 8.67.0
2177 + debug: 4.4.3
2178 + typescript: 5.9.3
2179 + transitivePeerDependencies:
2180 + - supports-color
2181 +
2182 + '@typescript-eslint/scope-manager@8.67.0':
2183 + dependencies:
2184 + '@typescript-eslint/types': 8.67.0
2185 + '@typescript-eslint/visitor-keys': 8.67.0
2186 +
2187 + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)':
2188 + dependencies:
2189 + typescript: 5.9.3
2190 +
2191 + '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2192 + dependencies:
2193 + '@typescript-eslint/types': 8.67.0
2194 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2195 + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2196 + debug: 4.4.3
2197 + eslint: 8.57.1
2198 + ts-api-utils: 2.5.0(typescript@5.9.3)
2199 + typescript: 5.9.3
2200 + transitivePeerDependencies:
2201 + - supports-color
2202 +
2203 + '@typescript-eslint/types@8.67.0': {}
2204 +
2205 + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)':
2206 + dependencies:
2207 + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3)
2208 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
2209 + '@typescript-eslint/types': 8.67.0
2210 + '@typescript-eslint/visitor-keys': 8.67.0
2211 + debug: 4.4.3
2212 + minimatch: 10.2.6
2213 + semver: 7.8.5
2214 + tinyglobby: 0.2.17
2215 + ts-api-utils: 2.5.0(typescript@5.9.3)
2216 + typescript: 5.9.3
2217 + transitivePeerDependencies:
2218 + - supports-color
2219 +
2220 + '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2221 + dependencies:
2222 + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
2223 + '@typescript-eslint/scope-manager': 8.67.0
2224 + '@typescript-eslint/types': 8.67.0
2225 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2226 + eslint: 8.57.1
2227 + typescript: 5.9.3
2228 + transitivePeerDependencies:
2229 + - supports-color
2230 +
2231 + '@typescript-eslint/visitor-keys@8.67.0':
2232 + dependencies:
2233 + '@typescript-eslint/types': 8.67.0
2234 + eslint-visitor-keys: 5.0.1
2235 +
2236 + '@ungap/structured-clone@1.3.3': {}
2237 +
2238 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
2239 + optional: true
2240 +
2241 + '@unrs/resolver-binding-android-arm64@1.12.2':
2242 + optional: true
2243 +
2244 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
2245 + optional: true
2246 +
2247 + '@unrs/resolver-binding-darwin-x64@1.12.2':
2248 + optional: true
2249 +
2250 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
2251 + optional: true
2252 +
2253 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
2254 + optional: true
2255 +
2256 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
2257 + optional: true
2258 +
2259 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
2260 + optional: true
2261 +
2262 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
2263 + optional: true
2264 +
2265 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
2266 + optional: true
2267 +
2268 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
2269 + optional: true
2270 +
2271 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
2272 + optional: true
2273 +
2274 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
2275 + optional: true
2276 +
2277 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
2278 + optional: true
2279 +
2280 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
2281 + optional: true
2282 +
2283 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
2284 + optional: true
2285 +
2286 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
2287 + optional: true
2288 +
2289 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
2290 + optional: true
2291 +
2292 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
2293 + dependencies:
2294 + '@emnapi/core': 1.10.0
2295 + '@emnapi/runtime': 1.10.0
2296 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
2297 + optional: true
2298 +
2299 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
2300 + optional: true
2301 +
2302 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
2303 + optional: true
2304 +
2305 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
2306 + optional: true
2307 +
2308 + acorn-jsx@5.3.2(acorn@8.18.0):
2309 + dependencies:
2310 + acorn: 8.18.0
2311 +
2312 + acorn@8.18.0: {}
2313 +
2314 + ajv@6.15.0:
2315 + dependencies:
2316 + fast-deep-equal: 3.1.3
2317 + fast-json-stable-stringify: 2.1.0
2318 + json-schema-traverse: 0.4.1
2319 + uri-js: 4.4.1
2320 +
2321 + ansi-regex@5.0.1: {}
2322 +
2323 + ansi-styles@4.3.0:
2324 + dependencies:
2325 + color-convert: 2.0.1
2326 +
2327 + any-promise@1.3.0: {}
2328 +
2329 + anymatch@3.1.3:
2330 + dependencies:
2331 + normalize-path: 3.0.0
2332 + picomatch: 2.3.2
2333 +
2334 + arg@5.0.2: {}
2335 +
2336 + argparse@2.0.1: {}
2337 +
2338 + aria-query@5.3.2: {}
2339 +
2340 + array-buffer-byte-length@1.0.2:
2341 + dependencies:
2342 + call-bound: 1.0.4
2343 + is-array-buffer: 3.0.5
2344 +
2345 + array-includes@3.1.9:
2346 + dependencies:
2347 + call-bind: 1.0.9
2348 + call-bound: 1.0.4
2349 + define-properties: 1.2.1
2350 + es-abstract: 1.24.2
2351 + es-object-atoms: 1.1.2
2352 + get-intrinsic: 1.3.0
2353 + is-string: 1.1.1
2354 + math-intrinsics: 1.1.0
2355 +
2356 + array.prototype.findlast@1.2.5:
2357 + dependencies:
2358 + call-bind: 1.0.9
2359 + define-properties: 1.2.1
2360 + es-abstract: 1.24.2
2361 + es-errors: 1.3.0
2362 + es-object-atoms: 1.1.2
2363 + es-shim-unscopables: 1.1.0
2364 +
2365 + array.prototype.findlastindex@1.2.6:
2366 + dependencies:
2367 + call-bind: 1.0.9
2368 + call-bound: 1.0.4
2369 + define-properties: 1.2.1
2370 + es-abstract: 1.24.2
2371 + es-errors: 1.3.0
2372 + es-object-atoms: 1.1.2
2373 + es-shim-unscopables: 1.1.0
2374 +
2375 + array.prototype.flat@1.3.3:
2376 + dependencies:
2377 + call-bind: 1.0.9
2378 + define-properties: 1.2.1
2379 + es-abstract: 1.24.2
2380 + es-shim-unscopables: 1.1.0
2381 +
2382 + array.prototype.flatmap@1.3.3:
2383 + dependencies:
2384 + call-bind: 1.0.9
2385 + define-properties: 1.2.1
2386 + es-abstract: 1.24.2
2387 + es-shim-unscopables: 1.1.0
2388 +
2389 + array.prototype.tosorted@1.1.4:
2390 + dependencies:
2391 + call-bind: 1.0.9
2392 + define-properties: 1.2.1
2393 + es-abstract: 1.24.2
2394 + es-errors: 1.3.0
2395 + es-shim-unscopables: 1.1.0
2396 +
2397 + arraybuffer.prototype.slice@1.0.4:
2398 + dependencies:
2399 + array-buffer-byte-length: 1.0.2
2400 + call-bind: 1.0.9
2401 + define-properties: 1.2.1
2402 + es-abstract: 1.24.2
2403 + es-errors: 1.3.0
2404 + get-intrinsic: 1.3.0
2405 + is-array-buffer: 3.0.5
2406 +
2407 + ast-types-flow@0.0.8: {}
2408 +
2409 + async-function@1.0.0: {}
2410 +
2411 + autoprefixer@10.5.4(postcss@8.5.26):
2412 + dependencies:
2413 + browserslist: 4.28.8
2414 + caniuse-lite: 1.0.30001809
2415 + fraction.js: 5.3.4
2416 + picocolors: 1.1.1
2417 + postcss: 8.5.26
2418 + postcss-value-parser: 4.2.0
2419 +
2420 + available-typed-arrays@1.0.7:
2421 + dependencies:
2422 + possible-typed-array-names: 1.1.0
2423 +
2424 + axe-core@4.13.0: {}
2425 +
2426 + axobject-query@4.1.0: {}
2427 +
2428 + balanced-match@1.0.2: {}
2429 +
2430 + balanced-match@4.0.4: {}
2431 +
2432 + baseline-browser-mapping@2.11.13: {}
2433 +
2434 + binary-extensions@2.3.0: {}
2435 +
2436 + brace-expansion@1.1.18:
2437 + dependencies:
2438 + balanced-match: 1.0.2
2439 + concat-map: 0.0.1
2440 +
2441 + brace-expansion@5.0.9:
2442 + dependencies:
2443 + balanced-match: 4.0.4
2444 +
2445 + braces@3.0.3:
2446 + dependencies:
2447 + fill-range: 7.1.1
2448 +
2449 + browserslist@4.28.8:
2450 + dependencies:
2451 + baseline-browser-mapping: 2.11.13
2452 + caniuse-lite: 1.0.30001809
2453 + electron-to-chromium: 1.5.405
2454 + node-releases: 2.0.53
2455 + update-browserslist-db: 1.3.1(browserslist@4.28.8)
2456 +
2457 + call-bind-apply-helpers@1.0.2:
2458 + dependencies:
2459 + es-errors: 1.3.0
2460 + function-bind: 1.1.2
2461 +
2462 + call-bind@1.0.9:
2463 + dependencies:
2464 + call-bind-apply-helpers: 1.0.2
2465 + es-define-property: 1.0.1
2466 + get-intrinsic: 1.3.0
2467 + set-function-length: 1.2.2
2468 +
2469 + call-bound@1.0.4:
2470 + dependencies:
2471 + call-bind-apply-helpers: 1.0.2
2472 + get-intrinsic: 1.3.0
2473 +
2474 + callsites@3.1.0: {}
2475 +
2476 + camelcase-css@2.0.1: {}
2477 +
2478 + caniuse-lite@1.0.30001809: {}
2479 +
2480 + chalk@4.1.2:
2481 + dependencies:
2482 + ansi-styles: 4.3.0
2483 + supports-color: 7.2.0
2484 +
2485 + chokidar@3.6.0:
2486 + dependencies:
2487 + anymatch: 3.1.3
2488 + braces: 3.0.3
2489 + glob-parent: 5.1.2
2490 + is-binary-path: 2.1.0
2491 + is-glob: 4.0.3
2492 + normalize-path: 3.0.0
2493 + readdirp: 3.6.0
2494 + optionalDependencies:
2495 + fsevents: 2.3.3
2496 +
2497 + class-variance-authority@0.7.1:
2498 + dependencies:
2499 + clsx: 2.1.1
2500 +
2501 + client-only@0.0.1: {}
2502 +
2503 + clsx@2.1.1: {}
2504 +
2505 + color-convert@2.0.1:
2506 + dependencies:
2507 + color-name: 1.1.4
2508 +
2509 + color-name@1.1.4: {}
2510 +
2511 + commander@4.1.1: {}
2512 +
2513 + concat-map@0.0.1: {}
2514 +
2515 + cross-spawn@7.0.6:
2516 + dependencies:
2517 + path-key: 3.1.1
2518 + shebang-command: 2.0.0
2519 + which: 2.0.2
2520 +
2521 + cssesc@3.0.0: {}
2522 +
2523 + csstype@3.2.3: {}
2524 +
2525 + damerau-levenshtein@1.0.8: {}
2526 +
2527 + data-view-buffer@1.0.2:
2528 + dependencies:
2529 + call-bound: 1.0.4
2530 + es-errors: 1.3.0
2531 + is-data-view: 1.0.2
2532 +
2533 + data-view-byte-length@1.0.2:
2534 + dependencies:
2535 + call-bound: 1.0.4
2536 + es-errors: 1.3.0
2537 + is-data-view: 1.0.2
2538 +
2539 + data-view-byte-offset@1.0.1:
2540 + dependencies:
2541 + call-bound: 1.0.4
2542 + es-errors: 1.3.0
2543 + is-data-view: 1.0.2
2544 +
2545 + debug@3.2.7:
2546 + dependencies:
2547 + ms: 2.1.3
2548 +
2549 + debug@4.4.3:
2550 + dependencies:
2551 + ms: 2.1.3
2552 +
2553 + deep-is@0.1.4: {}
2554 +
2555 + define-data-property@1.1.4:
2556 + dependencies:
2557 + es-define-property: 1.0.1
2558 + es-errors: 1.3.0
2559 + gopd: 1.2.0
2560 +
2561 + define-properties@1.2.1:
2562 + dependencies:
2563 + define-data-property: 1.1.4
2564 + has-property-descriptors: 1.0.2
2565 + object-keys: 1.1.1
2566 +
2567 + detect-libc@2.1.2:
2568 + optional: true
2569 +
2570 + didyoumean@1.2.2: {}
2571 +
2572 + dlv@1.1.3: {}
2573 +
2574 + doctrine@2.1.0:
2575 + dependencies:
2576 + esutils: 2.0.3
2577 +
2578 + doctrine@3.0.0:
2579 + dependencies:
2580 + esutils: 2.0.3
2581 +
2582 + dunder-proto@1.0.1:
2583 + dependencies:
2584 + call-bind-apply-helpers: 1.0.2
2585 + es-errors: 1.3.0
2586 + gopd: 1.2.0
2587 +
2588 + electron-to-chromium@1.5.405: {}
2589 +
2590 + emoji-regex@9.2.2: {}
2591 +
2592 + es-abstract-get@1.0.0:
2593 + dependencies:
2594 + es-errors: 1.3.0
2595 + es-object-atoms: 1.1.2
2596 + is-callable: 1.2.7
2597 + object-inspect: 1.13.4
2598 +
2599 + es-abstract@1.24.2:
2600 + dependencies:
2601 + array-buffer-byte-length: 1.0.2
2602 + arraybuffer.prototype.slice: 1.0.4
2603 + available-typed-arrays: 1.0.7
2604 + call-bind: 1.0.9
2605 + call-bound: 1.0.4
2606 + data-view-buffer: 1.0.2
2607 + data-view-byte-length: 1.0.2
2608 + data-view-byte-offset: 1.0.1
2609 + es-define-property: 1.0.1
2610 + es-errors: 1.3.0
2611 + es-object-atoms: 1.1.2
2612 + es-set-tostringtag: 2.1.0
2613 + es-to-primitive: 1.3.4
2614 + function.prototype.name: 1.2.0
2615 + get-intrinsic: 1.3.0
2616 + get-proto: 1.0.1
2617 + get-symbol-description: 1.1.0
2618 + globalthis: 1.0.4
2619 + gopd: 1.2.0
2620 + has-property-descriptors: 1.0.2
2621 + has-proto: 1.2.0
2622 + has-symbols: 1.1.0
2623 + hasown: 2.0.4
2624 + internal-slot: 1.1.0
2625 + is-array-buffer: 3.0.5
2626 + is-callable: 1.2.7
2627 + is-data-view: 1.0.2
2628 + is-negative-zero: 2.0.3
2629 + is-regex: 1.2.1
2630 + is-set: 2.0.3
2631 + is-shared-array-buffer: 1.0.4
2632 + is-string: 1.1.1
2633 + is-typed-array: 1.1.15
2634 + is-weakref: 1.1.1
2635 + math-intrinsics: 1.1.0
2636 + object-inspect: 1.13.4
2637 + object-keys: 1.1.1
2638 + object.assign: 4.1.7
2639 + own-keys: 1.0.2
2640 + regexp.prototype.flags: 1.5.4
2641 + safe-array-concat: 1.1.4
2642 + safe-push-apply: 1.0.0
2643 + safe-regex-test: 1.1.0
2644 + set-proto: 1.0.0
2645 + stop-iteration-iterator: 1.1.0
2646 + string.prototype.trim: 1.2.11
2647 + string.prototype.trimend: 1.0.10
2648 + string.prototype.trimstart: 1.0.8
2649 + typed-array-buffer: 1.0.3
2650 + typed-array-byte-length: 1.0.3
2651 + typed-array-byte-offset: 1.0.4
2652 + typed-array-length: 1.0.8
2653 + unbox-primitive: 1.1.0
2654 + which-typed-array: 1.1.22
2655 +
2656 + es-define-property@1.0.1: {}
2657 +
2658 + es-errors@1.3.0: {}
2659 +
2660 + es-iterator-helpers@1.4.0:
2661 + dependencies:
2662 + call-bind: 1.0.9
2663 + call-bound: 1.0.4
2664 + define-properties: 1.2.1
2665 + es-abstract: 1.24.2
2666 + es-errors: 1.3.0
2667 + es-set-tostringtag: 2.1.0
2668 + function-bind: 1.1.2
2669 + get-intrinsic: 1.3.0
2670 + globalthis: 1.0.4
2671 + gopd: 1.2.0
2672 + has-property-descriptors: 1.0.2
2673 + has-proto: 1.2.0
2674 + has-symbols: 1.1.0
2675 + internal-slot: 1.1.0
2676 + iterator.prototype: 1.1.5
2677 + math-intrinsics: 1.1.0
2678 +
2679 + es-object-atoms@1.1.2:
2680 + dependencies:
2681 + es-errors: 1.3.0
2682 +
2683 + es-set-tostringtag@2.1.0:
2684 + dependencies:
2685 + es-errors: 1.3.0
2686 + get-intrinsic: 1.3.0
2687 + has-tostringtag: 1.0.2
2688 + hasown: 2.0.4
2689 +
2690 + es-shim-unscopables@1.1.0:
2691 + dependencies:
2692 + hasown: 2.0.4
2693 +
2694 + es-to-primitive@1.3.4:
2695 + dependencies:
2696 + es-abstract-get: 1.0.0
2697 + es-define-property: 1.0.1
2698 + es-errors: 1.3.0
2699 + is-callable: 1.2.7
2700 + is-date-object: 1.1.0
2701 + is-symbol: 1.1.1
2702 +
2703 + escalade@3.2.0: {}
2704 +
2705 + escape-string-regexp@4.0.0: {}
2706 +
2707 + eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3):
2708 + dependencies:
2709 + '@next/eslint-plugin-next': 15.5.23
2710 + '@rushstack/eslint-patch': 1.16.1
2711 + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
2712 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2713 + eslint: 8.57.1
2714 + eslint-import-resolver-node: 0.3.10
2715 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
2716 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2717 + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
2718 + eslint-plugin-react: 7.37.5(eslint@8.57.1)
2719 + eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
2720 + optionalDependencies:
2721 + typescript: 5.9.3
2722 + transitivePeerDependencies:
2723 + - eslint-import-resolver-webpack
2724 + - eslint-plugin-import-x
2725 + - supports-color
2726 +
2727 + eslint-import-resolver-node@0.3.10:
2728 + dependencies:
2729 + debug: 3.2.7
2730 + is-core-module: 2.16.2
2731 + resolve: 2.0.0-next.7
2732 + transitivePeerDependencies:
2733 + - supports-color
2734 +
2735 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
2736 + dependencies:
2737 + '@nolyfill/is-core-module': 1.0.39
2738 + debug: 4.4.3
2739 + eslint: 8.57.1
2740 + get-tsconfig: 4.14.2
2741 + is-bun-module: 2.0.0
2742 + stable-hash: 0.0.5
2743 + tinyglobby: 0.2.17
2744 + unrs-resolver: 1.12.2
2745 + optionalDependencies:
2746 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2747 + transitivePeerDependencies:
2748 + - supports-color
2749 +
2750 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
2751 + dependencies:
2752 + debug: 3.2.7
2753 + optionalDependencies:
2754 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2755 + eslint: 8.57.1
2756 + eslint-import-resolver-node: 0.3.10
2757 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
2758 + transitivePeerDependencies:
2759 + - supports-color
2760 +
2761 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
2762 + dependencies:
2763 + '@rtsao/scc': 1.1.0
2764 + array-includes: 3.1.9
2765 + array.prototype.findlastindex: 1.2.6
2766 + array.prototype.flat: 1.3.3
2767 + array.prototype.flatmap: 1.3.3
2768 + debug: 3.2.7
2769 + doctrine: 2.1.0
2770 + eslint: 8.57.1
2771 + eslint-import-resolver-node: 0.3.10
2772 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2773 + hasown: 2.0.4
2774 + is-core-module: 2.16.2
2775 + is-glob: 4.0.3
2776 + minimatch: 3.1.5
2777 + object.fromentries: 2.0.8
2778 + object.groupby: 1.0.3
2779 + object.values: 1.2.1
2780 + semver: 6.3.1
2781 + string.prototype.trimend: 1.0.10
2782 + tsconfig-paths: 3.15.0
2783 + optionalDependencies:
2784 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2785 + transitivePeerDependencies:
2786 + - eslint-import-resolver-typescript
2787 + - eslint-import-resolver-webpack
2788 + - supports-color
2789 +
2790 + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1):
2791 + dependencies:
2792 + aria-query: 5.3.2
2793 + array-includes: 3.1.9
2794 + array.prototype.flatmap: 1.3.3
2795 + ast-types-flow: 0.0.8
2796 + axe-core: 4.13.0
2797 + axobject-query: 4.1.0
2798 + damerau-levenshtein: 1.0.8
2799 + emoji-regex: 9.2.2
2800 + eslint: 8.57.1
2801 + hasown: 2.0.4
2802 + jsx-ast-utils: 3.3.5
2803 + language-tags: 1.0.9
2804 + minimatch: 3.1.5
2805 + object.fromentries: 2.0.8
2806 + safe-regex-test: 1.1.0
2807 + string.prototype.includes: 2.0.1
2808 +
2809 + eslint-plugin-react-hooks@5.2.0(eslint@8.57.1):
2810 + dependencies:
2811 + eslint: 8.57.1
2812 +
2813 + eslint-plugin-react@7.37.5(eslint@8.57.1):
2814 + dependencies:
2815 + array-includes: 3.1.9
2816 + array.prototype.findlast: 1.2.5
2817 + array.prototype.flatmap: 1.3.3
2818 + array.prototype.tosorted: 1.1.4
2819 + doctrine: 2.1.0
2820 + es-iterator-helpers: 1.4.0
2821 + eslint: 8.57.1
2822 + estraverse: 5.3.0
2823 + hasown: 2.0.4
2824 + jsx-ast-utils: 3.3.5
2825 + minimatch: 3.1.5
2826 + object.entries: 1.1.9
2827 + object.fromentries: 2.0.8
2828 + object.values: 1.2.1
2829 + prop-types: 15.8.1
2830 + resolve: 2.0.0-next.7
2831 + semver: 6.3.1
2832 + string.prototype.matchall: 4.0.12
2833 + string.prototype.repeat: 1.0.0
2834 +
2835 + eslint-scope@7.2.2:
2836 + dependencies:
2837 + esrecurse: 4.3.0
2838 + estraverse: 5.3.0
2839 +
2840 + eslint-visitor-keys@3.4.3: {}
2841 +
2842 + eslint-visitor-keys@5.0.1: {}
2843 +
2844 + eslint@8.57.1:
2845 + dependencies:
2846 + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
2847 + '@eslint-community/regexpp': 4.12.2
2848 + '@eslint/eslintrc': 2.1.4
2849 + '@eslint/js': 8.57.1
2850 + '@humanwhocodes/config-array': 0.13.0
2851 + '@humanwhocodes/module-importer': 1.0.1
2852 + '@nodelib/fs.walk': 1.2.8
2853 + '@ungap/structured-clone': 1.3.3
2854 + ajv: 6.15.0
2855 + chalk: 4.1.2
2856 + cross-spawn: 7.0.6
2857 + debug: 4.4.3
2858 + doctrine: 3.0.0
2859 + escape-string-regexp: 4.0.0
2860 + eslint-scope: 7.2.2
2861 + eslint-visitor-keys: 3.4.3
2862 + espree: 9.6.1
2863 + esquery: 1.7.0
2864 + esutils: 2.0.3
2865 + fast-deep-equal: 3.1.3
2866 + file-entry-cache: 6.0.1
2867 + find-up: 5.0.0
2868 + glob-parent: 6.0.2
2869 + globals: 13.24.0
2870 + graphemer: 1.4.0
2871 + ignore: 5.3.2
2872 + imurmurhash: 0.1.4
2873 + is-glob: 4.0.3
2874 + is-path-inside: 3.0.3
2875 + js-yaml: 4.3.1
2876 + json-stable-stringify-without-jsonify: 1.0.1
2877 + levn: 0.4.1
2878 + lodash.merge: 4.6.2
2879 + minimatch: 3.1.5
2880 + natural-compare: 1.4.0
2881 + optionator: 0.9.4
2882 + strip-ansi: 6.0.1
2883 + text-table: 0.2.0
2884 + transitivePeerDependencies:
2885 + - supports-color
2886 +
2887 + espree@9.6.1:
2888 + dependencies:
2889 + acorn: 8.18.0
2890 + acorn-jsx: 5.3.2(acorn@8.18.0)
2891 + eslint-visitor-keys: 3.4.3
2892 +
2893 + esquery@1.7.0:
2894 + dependencies:
2895 + estraverse: 5.3.0
2896 +
2897 + esrecurse@4.3.0:
2898 + dependencies:
2899 + estraverse: 5.3.0
2900 +
2901 + estraverse@5.3.0: {}
2902 +
2903 + esutils@2.0.3: {}
2904 +
2905 + fast-deep-equal@3.1.3: {}
2906 +
2907 + fast-glob@3.3.1:
2908 + dependencies:
2909 + '@nodelib/fs.stat': 2.0.5
2910 + '@nodelib/fs.walk': 1.2.8
2911 + glob-parent: 5.1.2
2912 + merge2: 1.4.1
2913 + micromatch: 4.0.8
2914 +
2915 + fast-glob@3.3.3:
2916 + dependencies:
2917 + '@nodelib/fs.stat': 2.0.5
2918 + '@nodelib/fs.walk': 1.2.8
2919 + glob-parent: 5.1.2
2920 + merge2: 1.4.1
2921 + micromatch: 4.0.8
2922 +
2923 + fast-json-stable-stringify@2.1.0: {}
2924 +
2925 + fast-levenshtein@2.0.6: {}
2926 +
2927 + fastq@1.20.1:
2928 + dependencies:
2929 + reusify: 1.1.0
2930 +
2931 + fdir@6.5.0(picomatch@4.0.5):
2932 + optionalDependencies:
2933 + picomatch: 4.0.5
2934 +
2935 + file-entry-cache@6.0.1:
2936 + dependencies:
2937 + flat-cache: 3.2.0
2938 +
2939 + fill-range@7.1.1:
2940 + dependencies:
2941 + to-regex-range: 5.0.1
2942 +
2943 + find-up@5.0.0:
2944 + dependencies:
2945 + locate-path: 6.0.0
2946 + path-exists: 4.0.0
2947 +
2948 + flat-cache@3.2.0:
2949 + dependencies:
2950 + flatted: 3.4.4
2951 + keyv: 4.5.4
2952 + rimraf: 3.0.2
2953 +
2954 + flatted@3.4.4: {}
2955 +
2956 + for-each@0.3.5:
2957 + dependencies:
2958 + is-callable: 1.2.7
2959 +
2960 + fraction.js@5.3.4: {}
2961 +
2962 + fs.realpath@1.0.0: {}
2963 +
2964 + fsevents@2.3.3:
2965 + optional: true
2966 +
2967 + function-bind@1.1.2: {}
2968 +
2969 + function.prototype.name@1.2.0:
2970 + dependencies:
2971 + call-bind: 1.0.9
2972 + call-bound: 1.0.4
2973 + es-define-property: 1.0.1
2974 + es-errors: 1.3.0
2975 + functions-have-names: 1.2.3
2976 + has-property-descriptors: 1.0.2
2977 + hasown: 2.0.4
2978 + is-callable: 1.2.7
2979 + is-document.all: 1.0.0
2980 +
2981 + functions-have-names@1.2.3: {}
2982 +
2983 + generator-function@2.0.1: {}
2984 +
2985 + get-intrinsic@1.3.0:
2986 + dependencies:
2987 + call-bind-apply-helpers: 1.0.2
2988 + es-define-property: 1.0.1
2989 + es-errors: 1.3.0
2990 + es-object-atoms: 1.1.2
2991 + function-bind: 1.1.2
2992 + get-proto: 1.0.1
2993 + gopd: 1.2.0
2994 + has-symbols: 1.1.0
2995 + hasown: 2.0.4
2996 + math-intrinsics: 1.1.0
2997 +
2998 + get-proto@1.0.1:
2999 + dependencies:
3000 + dunder-proto: 1.0.1
3001 + es-object-atoms: 1.1.2
3002 +
3003 + get-symbol-description@1.1.0:
3004 + dependencies:
3005 + call-bound: 1.0.4
3006 + es-errors: 1.3.0
3007 + get-intrinsic: 1.3.0
3008 +
3009 + get-tsconfig@4.14.2:
3010 + dependencies:
3011 + resolve-pkg-maps: 1.0.0
3012 +
3013 + glob-parent@5.1.2:
3014 + dependencies:
3015 + is-glob: 4.0.3
3016 +
3017 + glob-parent@6.0.2:
3018 + dependencies:
3019 + is-glob: 4.0.3
3020 +
3021 + glob@7.2.3:
3022 + dependencies:
3023 + fs.realpath: 1.0.0
3024 + inflight: 1.0.6
3025 + inherits: 2.0.4
3026 + minimatch: 3.1.5
3027 + once: 1.4.0
3028 + path-is-absolute: 1.0.1
3029 +
3030 + globals@13.24.0:
3031 + dependencies:
3032 + type-fest: 0.20.2
3033 +
3034 + globalthis@1.0.4:
3035 + dependencies:
3036 + define-properties: 1.2.1
3037 + gopd: 1.2.0
3038 +
3039 + gopd@1.2.0: {}
3040 +
3041 + graphemer@1.4.0: {}
3042 +
3043 + has-bigints@1.1.0: {}
3044 +
3045 + has-flag@4.0.0: {}
3046 +
3047 + has-property-descriptors@1.0.2:
3048 + dependencies:
3049 + es-define-property: 1.0.1
3050 +
3051 + has-proto@1.2.0:
3052 + dependencies:
3053 + dunder-proto: 1.0.1
3054 +
3055 + has-symbols@1.1.0: {}
3056 +
3057 + has-tostringtag@1.0.2:
3058 + dependencies:
3059 + has-symbols: 1.1.0
3060 +
3061 + hasown@2.0.4:
3062 + dependencies:
3063 + function-bind: 1.1.2
3064 +
3065 + ignore@5.3.2: {}
3066 +
3067 + ignore@7.0.6: {}
3068 +
3069 + import-fresh@3.3.1:
3070 + dependencies:
3071 + parent-module: 1.0.1
3072 + resolve-from: 4.0.0
3073 +
3074 + imurmurhash@0.1.4: {}
3075 +
3076 + inflight@1.0.6:
3077 + dependencies:
3078 + once: 1.4.0
3079 + wrappy: 1.0.2
3080 +
3081 + inherits@2.0.4: {}
3082 +
3083 + internal-slot@1.1.0:
3084 + dependencies:
3085 + es-errors: 1.3.0
3086 + hasown: 2.0.4
3087 + side-channel: 1.1.1
3088 +
3089 + is-array-buffer@3.0.5:
3090 + dependencies:
3091 + call-bind: 1.0.9
3092 + call-bound: 1.0.4
3093 + get-intrinsic: 1.3.0
3094 +
3095 + is-async-function@2.1.1:
3096 + dependencies:
3097 + async-function: 1.0.0
3098 + call-bound: 1.0.4
3099 + get-proto: 1.0.1
3100 + has-tostringtag: 1.0.2
3101 + safe-regex-test: 1.1.0
3102 +
3103 + is-bigint@1.1.0:
3104 + dependencies:
3105 + has-bigints: 1.1.0
3106 +
3107 + is-binary-path@2.1.0:
3108 + dependencies:
3109 + binary-extensions: 2.3.0
3110 +
3111 + is-boolean-object@1.2.2:
3112 + dependencies:
3113 + call-bound: 1.0.4
3114 + has-tostringtag: 1.0.2
3115 +
3116 + is-bun-module@2.0.0:
3117 + dependencies:
3118 + semver: 7.8.5
3119 +
3120 + is-callable@1.2.7: {}
3121 +
3122 + is-core-module@2.16.2:
3123 + dependencies:
3124 + hasown: 2.0.4
3125 +
3126 + is-data-view@1.0.2:
3127 + dependencies:
3128 + call-bound: 1.0.4
3129 + get-intrinsic: 1.3.0
3130 + is-typed-array: 1.1.15
3131 +
3132 + is-date-object@1.1.0:
3133 + dependencies:
3134 + call-bound: 1.0.4
3135 + has-tostringtag: 1.0.2
3136 +
3137 + is-document.all@1.0.0:
3138 + dependencies:
3139 + call-bound: 1.0.4
3140 +
3141 + is-extglob@2.1.1: {}
3142 +
3143 + is-finalizationregistry@1.1.1:
3144 + dependencies:
3145 + call-bound: 1.0.4
3146 +
3147 + is-generator-function@1.1.2:
3148 + dependencies:
3149 + call-bound: 1.0.4
3150 + generator-function: 2.0.1
3151 + get-proto: 1.0.1
3152 + has-tostringtag: 1.0.2
3153 + safe-regex-test: 1.1.0
3154 +
3155 + is-glob@4.0.3:
3156 + dependencies:
3157 + is-extglob: 2.1.1
3158 +
3159 + is-map@2.0.3: {}
3160 +
3161 + is-negative-zero@2.0.3: {}
3162 +
3163 + is-number-object@1.1.1:
3164 + dependencies:
3165 + call-bound: 1.0.4
3166 + has-tostringtag: 1.0.2
3167 +
3168 + is-number@7.0.0: {}
3169 +
3170 + is-path-inside@3.0.3: {}
3171 +
3172 + is-regex@1.2.1:
3173 + dependencies:
3174 + call-bound: 1.0.4
3175 + gopd: 1.2.0
3176 + has-tostringtag: 1.0.2
3177 + hasown: 2.0.4
3178 +
3179 + is-set@2.0.3: {}
3180 +
3181 + is-shared-array-buffer@1.0.4:
3182 + dependencies:
3183 + call-bound: 1.0.4
3184 +
3185 + is-string@1.1.1:
3186 + dependencies:
3187 + call-bound: 1.0.4
3188 + has-tostringtag: 1.0.2
3189 +
3190 + is-symbol@1.1.1:
3191 + dependencies:
3192 + call-bound: 1.0.4
3193 + has-symbols: 1.1.0
3194 + safe-regex-test: 1.1.0
3195 +
3196 + is-typed-array@1.1.15:
3197 + dependencies:
3198 + which-typed-array: 1.1.22
3199 +
3200 + is-weakmap@2.0.2: {}
3201 +
3202 + is-weakref@1.1.1:
3203 + dependencies:
3204 + call-bound: 1.0.4
3205 +
3206 + is-weakset@2.0.4:
3207 + dependencies:
3208 + call-bound: 1.0.4
3209 + get-intrinsic: 1.3.0
3210 +
3211 + isarray@2.0.5: {}
3212 +
3213 + isexe@2.0.0: {}
3214 +
3215 + iterator.prototype@1.1.5:
3216 + dependencies:
3217 + define-data-property: 1.1.4
3218 + es-object-atoms: 1.1.2
3219 + get-intrinsic: 1.3.0
3220 + get-proto: 1.0.1
3221 + has-symbols: 1.1.0
3222 + set-function-name: 2.0.2
3223 +
3224 + jiti@1.21.7: {}
3225 +
3226 + js-tokens@4.0.0: {}
3227 +
3228 + js-yaml@4.3.1:
3229 + dependencies:
3230 + argparse: 2.0.1
3231 +
3232 + json-buffer@3.0.1: {}
3233 +
3234 + json-schema-traverse@0.4.1: {}
3235 +
3236 + json-stable-stringify-without-jsonify@1.0.1: {}
3237 +
3238 + json5@1.0.2:
3239 + dependencies:
3240 + minimist: 1.2.8
3241 +
3242 + jsx-ast-utils@3.3.5:
3243 + dependencies:
3244 + array-includes: 3.1.9
3245 + array.prototype.flat: 1.3.3
3246 + object.assign: 4.1.7
3247 + object.values: 1.2.1
3248 +
3249 + keyv@4.5.4:
3250 + dependencies:
3251 + json-buffer: 3.0.1
3252 +
3253 + language-subtag-registry@0.3.23: {}
3254 +
3255 + language-tags@1.0.9:
3256 + dependencies:
3257 + language-subtag-registry: 0.3.23
3258 +
3259 + levn@0.4.1:
3260 + dependencies:
3261 + prelude-ls: 1.2.1
3262 + type-check: 0.4.0
3263 +
3264 + lilconfig@3.1.3: {}
3265 +
3266 + lines-and-columns@1.2.4: {}
3267 +
3268 + locate-path@6.0.0:
3269 + dependencies:
3270 + p-locate: 5.0.0
3271 +
3272 + lodash.merge@4.6.2: {}
3273 +
3274 + loose-envify@1.4.0:
3275 + dependencies:
3276 + js-tokens: 4.0.0
3277 +
3278 + math-intrinsics@1.1.0: {}
3279 +
3280 + merge2@1.4.1: {}
3281 +
3282 + micromatch@4.0.8:
3283 + dependencies:
3284 + braces: 3.0.3
3285 + picomatch: 2.3.2
3286 +
3287 + minimatch@10.2.6:
3288 + dependencies:
3289 + brace-expansion: 5.0.9
3290 +
3291 + minimatch@3.1.5:
3292 + dependencies:
3293 + brace-expansion: 1.1.18
3294 +
3295 + minimist@1.2.8: {}
3296 +
3297 + ms@2.1.3: {}
3298 +
3299 + mz@2.7.0:
3300 + dependencies:
3301 + any-promise: 1.3.0
3302 + object-assign: 4.1.1
3303 + thenify-all: 1.6.0
3304 +
3305 + nanoid@3.3.18: {}
3306 +
3307 + napi-postinstall@0.3.4: {}
3308 +
3309 + natural-compare@1.4.0: {}
3310 +
3311 + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
3312 + dependencies:
3313 + '@next/env': 15.5.23
3314 + '@swc/helpers': 0.5.15
3315 + caniuse-lite: 1.0.30001809
3316 + postcss: 8.4.31
3317 + react: 19.2.8
3318 + react-dom: 19.2.8(react@19.2.8)
3319 + styled-jsx: 5.1.6(react@19.2.8)
3320 + optionalDependencies:
3321 + '@next/swc-darwin-arm64': 15.5.23
3322 + '@next/swc-darwin-x64': 15.5.23
3323 + '@next/swc-linux-arm64-gnu': 15.5.23
3324 + '@next/swc-linux-arm64-musl': 15.5.23
3325 + '@next/swc-linux-x64-gnu': 15.5.23
3326 + '@next/swc-linux-x64-musl': 15.5.23
3327 + '@next/swc-win32-arm64-msvc': 15.5.23
3328 + '@next/swc-win32-x64-msvc': 15.5.23
3329 + sharp: 0.34.5
3330 + transitivePeerDependencies:
3331 + - '@babel/core'
3332 + - babel-plugin-macros
3333 +
3334 + node-exports-info@1.6.2:
3335 + dependencies:
3336 + array.prototype.flatmap: 1.3.3
3337 + es-errors: 1.3.0
3338 + object.entries: 1.1.9
3339 + semver: 6.3.1
3340 +
3341 + node-releases@2.0.53: {}
3342 +
3343 + normalize-path@3.0.0: {}
3344 +
3345 + object-assign@4.1.1: {}
3346 +
3347 + object-hash@3.0.0: {}
3348 +
3349 + object-inspect@1.13.4: {}
3350 +
3351 + object-keys@1.1.1: {}
3352 +
3353 + object.assign@4.1.7:
3354 + dependencies:
3355 + call-bind: 1.0.9
3356 + call-bound: 1.0.4
3357 + define-properties: 1.2.1
3358 + es-object-atoms: 1.1.2
3359 + has-symbols: 1.1.0
3360 + object-keys: 1.1.1
3361 +
3362 + object.entries@1.1.9:
3363 + dependencies:
3364 + call-bind: 1.0.9
3365 + call-bound: 1.0.4
3366 + define-properties: 1.2.1
3367 + es-object-atoms: 1.1.2
3368 +
3369 + object.fromentries@2.0.8:
3370 + dependencies:
3371 + call-bind: 1.0.9
3372 + define-properties: 1.2.1
3373 + es-abstract: 1.24.2
3374 + es-object-atoms: 1.1.2
3375 +
3376 + object.groupby@1.0.3:
3377 + dependencies:
3378 + call-bind: 1.0.9
3379 + define-properties: 1.2.1
3380 + es-abstract: 1.24.2
3381 +
3382 + object.values@1.2.1:
3383 + dependencies:
3384 + call-bind: 1.0.9
3385 + call-bound: 1.0.4
3386 + define-properties: 1.2.1
3387 + es-object-atoms: 1.1.2
3388 +
3389 + once@1.4.0:
3390 + dependencies:
3391 + wrappy: 1.0.2
3392 +
3393 + optionator@0.9.4:
3394 + dependencies:
3395 + deep-is: 0.1.4
3396 + fast-levenshtein: 2.0.6
3397 + levn: 0.4.1
3398 + prelude-ls: 1.2.1
3399 + type-check: 0.4.0
3400 + word-wrap: 1.2.5
3401 +
3402 + own-keys@1.0.2:
3403 + dependencies:
3404 + call-bound: 1.0.4
3405 + get-intrinsic: 1.3.0
3406 + object-keys: 1.1.1
3407 + safe-push-apply: 1.0.0
3408 +
3409 + p-limit@3.1.0:
3410 + dependencies:
3411 + yocto-queue: 0.1.0
3412 +
3413 + p-locate@5.0.0:
3414 + dependencies:
3415 + p-limit: 3.1.0
3416 +
3417 + parent-module@1.0.1:
3418 + dependencies:
3419 + callsites: 3.1.0
3420 +
3421 + path-exists@4.0.0: {}
3422 +
3423 + path-is-absolute@1.0.1: {}
3424 +
3425 + path-key@3.1.1: {}
3426 +
3427 + path-parse@1.0.7: {}
3428 +
3429 + picocolors@1.1.1: {}
3430 +
3431 + picomatch@2.3.2: {}
3432 +
3433 + picomatch@4.0.5: {}
3434 +
3435 + pify@2.3.0: {}
3436 +
3437 + pirates@4.0.7: {}
3438 +
3439 + possible-typed-array-names@1.1.0: {}
3440 +
3441 + postcss-import@15.1.0(postcss@8.5.26):
3442 + dependencies:
3443 + postcss: 8.5.26
3444 + postcss-value-parser: 4.2.0
3445 + read-cache: 1.0.0
3446 + resolve: 1.22.12
3447 +
3448 + postcss-js@4.1.0(postcss@8.5.26):
3449 + dependencies:
3450 + camelcase-css: 2.0.1
3451 + postcss: 8.5.26
3452 +
3453 + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26):
3454 + dependencies:
3455 + lilconfig: 3.1.3
3456 + optionalDependencies:
3457 + jiti: 1.21.7
3458 + postcss: 8.5.26
3459 +
3460 + postcss-nested@6.2.0(postcss@8.5.26):
3461 + dependencies:
3462 + postcss: 8.5.26
3463 + postcss-selector-parser: 6.1.4
3464 +
3465 + postcss-selector-parser@6.1.4:
3466 + dependencies:
3467 + cssesc: 3.0.0
3468 + util-deprecate: 1.0.2
3469 +
3470 + postcss-value-parser@4.2.0: {}
3471 +
3472 + postcss@8.4.31:
3473 + dependencies:
3474 + nanoid: 3.3.18
3475 + picocolors: 1.1.1
3476 + source-map-js: 1.2.1
3477 +
3478 + postcss@8.5.26:
3479 + dependencies:
3480 + nanoid: 3.3.18
3481 + picocolors: 1.1.1
3482 + source-map-js: 1.2.1
3483 +
3484 + prelude-ls@1.2.1: {}
3485 +
3486 + prop-types@15.8.1:
3487 + dependencies:
3488 + loose-envify: 1.4.0
3489 + object-assign: 4.1.1
3490 + react-is: 16.13.1
3491 +
3492 + punycode@2.3.1: {}
3493 +
3494 + queue-microtask@1.2.3: {}
3495 +
3496 + react-dom@19.2.8(react@19.2.8):
3497 + dependencies:
3498 + react: 19.2.8
3499 + scheduler: 0.27.0
3500 +
3501 + react-is@16.13.1: {}
3502 +
3503 + react@19.2.8: {}
3504 +
3505 + read-cache@1.0.0:
3506 + dependencies:
3507 + pify: 2.3.0
3508 +
3509 + readdirp@3.6.0:
3510 + dependencies:
3511 + picomatch: 2.3.2
3512 +
3513 + reflect.getprototypeof@1.0.10:
3514 + dependencies:
3515 + call-bind: 1.0.9
3516 + define-properties: 1.2.1
3517 + es-abstract: 1.24.2
3518 + es-errors: 1.3.0
3519 + es-object-atoms: 1.1.2
3520 + get-intrinsic: 1.3.0
3521 + get-proto: 1.0.1
3522 + which-builtin-type: 1.2.1
3523 +
3524 + regexp.prototype.flags@1.5.4:
3525 + dependencies:
3526 + call-bind: 1.0.9
3527 + define-properties: 1.2.1
3528 + es-errors: 1.3.0
3529 + get-proto: 1.0.1
3530 + gopd: 1.2.0
3531 + set-function-name: 2.0.2
3532 +
3533 + resolve-from@4.0.0: {}
3534 +
3535 + resolve-pkg-maps@1.0.0: {}
3536 +
3537 + resolve@1.22.12:
3538 + dependencies:
3539 + es-errors: 1.3.0
3540 + is-core-module: 2.16.2
3541 + path-parse: 1.0.7
3542 + supports-preserve-symlinks-flag: 1.0.0
3543 +
3544 + resolve@2.0.0-next.7:
3545 + dependencies:
3546 + es-errors: 1.3.0
3547 + is-core-module: 2.16.2
3548 + node-exports-info: 1.6.2
3549 + object-keys: 1.1.1
3550 + path-parse: 1.0.7
3551 + supports-preserve-symlinks-flag: 1.0.0
3552 +
3553 + reusify@1.1.0: {}
3554 +
3555 + rimraf@3.0.2:
3556 + dependencies:
3557 + glob: 7.2.3
3558 +
3559 + run-parallel@1.2.0:
3560 + dependencies:
3561 + queue-microtask: 1.2.3
3562 +
3563 + safe-array-concat@1.1.4:
3564 + dependencies:
3565 + call-bind: 1.0.9
3566 + call-bound: 1.0.4
3567 + get-intrinsic: 1.3.0
3568 + has-symbols: 1.1.0
3569 + isarray: 2.0.5
3570 +
3571 + safe-push-apply@1.0.0:
3572 + dependencies:
3573 + es-errors: 1.3.0
3574 + isarray: 2.0.5
3575 +
3576 + safe-regex-test@1.1.0:
3577 + dependencies:
3578 + call-bound: 1.0.4
3579 + es-errors: 1.3.0
3580 + is-regex: 1.2.1
3581 +
3582 + scheduler@0.27.0: {}
3583 +
3584 + semver@6.3.1: {}
3585 +
3586 + semver@7.8.5: {}
3587 +
3588 + set-function-length@1.2.2:
3589 + dependencies:
3590 + define-data-property: 1.1.4
3591 + es-errors: 1.3.0
3592 + function-bind: 1.1.2
3593 + get-intrinsic: 1.3.0
3594 + gopd: 1.2.0
3595 + has-property-descriptors: 1.0.2
3596 +
3597 + set-function-name@2.0.2:
3598 + dependencies:
3599 + define-data-property: 1.1.4
3600 + es-errors: 1.3.0
3601 + functions-have-names: 1.2.3
3602 + has-property-descriptors: 1.0.2
3603 +
3604 + set-proto@1.0.0:
3605 + dependencies:
3606 + dunder-proto: 1.0.1
3607 + es-errors: 1.3.0
3608 + es-object-atoms: 1.1.2
3609 +
3610 + sharp@0.34.5:
3611 + dependencies:
3612 + '@img/colour': 1.1.0
3613 + detect-libc: 2.1.2
3614 + semver: 7.8.5
3615 + optionalDependencies:
3616 + '@img/sharp-darwin-arm64': 0.34.5
3617 + '@img/sharp-darwin-x64': 0.34.5
3618 + '@img/sharp-libvips-darwin-arm64': 1.2.4
3619 + '@img/sharp-libvips-darwin-x64': 1.2.4
3620 + '@img/sharp-libvips-linux-arm': 1.2.4
3621 + '@img/sharp-libvips-linux-arm64': 1.2.4
3622 + '@img/sharp-libvips-linux-ppc64': 1.2.4
3623 + '@img/sharp-libvips-linux-riscv64': 1.2.4
3624 + '@img/sharp-libvips-linux-s390x': 1.2.4
3625 + '@img/sharp-libvips-linux-x64': 1.2.4
3626 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
3627 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4
3628 + '@img/sharp-linux-arm': 0.34.5
3629 + '@img/sharp-linux-arm64': 0.34.5
3630 + '@img/sharp-linux-ppc64': 0.34.5
3631 + '@img/sharp-linux-riscv64': 0.34.5
3632 + '@img/sharp-linux-s390x': 0.34.5
3633 + '@img/sharp-linux-x64': 0.34.5
3634 + '@img/sharp-linuxmusl-arm64': 0.34.5
3635 + '@img/sharp-linuxmusl-x64': 0.34.5
3636 + '@img/sharp-wasm32': 0.34.5
3637 + '@img/sharp-win32-arm64': 0.34.5
3638 + '@img/sharp-win32-ia32': 0.34.5
3639 + '@img/sharp-win32-x64': 0.34.5
3640 + optional: true
3641 +
3642 + shebang-command@2.0.0:
3643 + dependencies:
3644 + shebang-regex: 3.0.0
3645 +
3646 + shebang-regex@3.0.0: {}
3647 +
3648 + side-channel-list@1.0.1:
3649 + dependencies:
3650 + es-errors: 1.3.0
3651 + object-inspect: 1.13.4
3652 +
3653 + side-channel-map@1.0.1:
3654 + dependencies:
3655 + call-bound: 1.0.4
3656 + es-errors: 1.3.0
3657 + get-intrinsic: 1.3.0
3658 + object-inspect: 1.13.4
3659 +
3660 + side-channel-weakmap@1.0.2:
3661 + dependencies:
3662 + call-bound: 1.0.4
3663 + es-errors: 1.3.0
3664 + get-intrinsic: 1.3.0
3665 + object-inspect: 1.13.4
3666 + side-channel-map: 1.0.1
3667 +
3668 + side-channel@1.1.1:
3669 + dependencies:
3670 + es-errors: 1.3.0
3671 + object-inspect: 1.13.4
3672 + side-channel-list: 1.0.1
3673 + side-channel-map: 1.0.1
3674 + side-channel-weakmap: 1.0.2
3675 +
3676 + source-map-js@1.2.1: {}
3677 +
3678 + stable-hash@0.0.5: {}
3679 +
3680 + stop-iteration-iterator@1.1.0:
3681 + dependencies:
3682 + es-errors: 1.3.0
3683 + internal-slot: 1.1.0
3684 +
3685 + string.prototype.includes@2.0.1:
3686 + dependencies:
3687 + call-bind: 1.0.9
3688 + define-properties: 1.2.1
3689 + es-abstract: 1.24.2
3690 +
3691 + string.prototype.matchall@4.0.12:
3692 + dependencies:
3693 + call-bind: 1.0.9
3694 + call-bound: 1.0.4
3695 + define-properties: 1.2.1
3696 + es-abstract: 1.24.2
3697 + es-errors: 1.3.0
3698 + es-object-atoms: 1.1.2
3699 + get-intrinsic: 1.3.0
3700 + gopd: 1.2.0
3701 + has-symbols: 1.1.0
3702 + internal-slot: 1.1.0
3703 + regexp.prototype.flags: 1.5.4
3704 + set-function-name: 2.0.2
3705 + side-channel: 1.1.1
3706 +
3707 + string.prototype.repeat@1.0.0:
3708 + dependencies:
3709 + define-properties: 1.2.1
3710 + es-abstract: 1.24.2
3711 +
3712 + string.prototype.trim@1.2.11:
3713 + dependencies:
3714 + call-bind: 1.0.9
3715 + call-bound: 1.0.4
3716 + define-data-property: 1.1.4
3717 + define-properties: 1.2.1
3718 + es-abstract: 1.24.2
3719 + es-object-atoms: 1.1.2
3720 + has-property-descriptors: 1.0.2
3721 + safe-regex-test: 1.1.0
3722 +
3723 + string.prototype.trimend@1.0.10:
3724 + dependencies:
3725 + call-bind: 1.0.9
3726 + call-bound: 1.0.4
3727 + define-properties: 1.2.1
3728 + es-object-atoms: 1.1.2
3729 +
3730 + string.prototype.trimstart@1.0.8:
3731 + dependencies:
3732 + call-bind: 1.0.9
3733 + define-properties: 1.2.1
3734 + es-object-atoms: 1.1.2
3735 +
3736 + strip-ansi@6.0.1:
3737 + dependencies:
3738 + ansi-regex: 5.0.1
3739 +
3740 + strip-bom@3.0.0: {}
3741 +
3742 + strip-json-comments@3.1.1: {}
3743 +
3744 + styled-jsx@5.1.6(react@19.2.8):
3745 + dependencies:
3746 + client-only: 0.0.1
3747 + react: 19.2.8
3748 +
3749 + sucrase@3.35.1:
3750 + dependencies:
3751 + '@jridgewell/gen-mapping': 0.3.13
3752 + commander: 4.1.1
3753 + lines-and-columns: 1.2.4
3754 + mz: 2.7.0
3755 + pirates: 4.0.7
3756 + tinyglobby: 0.2.17
3757 + ts-interface-checker: 0.1.13
3758 +
3759 + supports-color@7.2.0:
3760 + dependencies:
3761 + has-flag: 4.0.0
3762 +
3763 + supports-preserve-symlinks-flag@1.0.0: {}
3764 +
3765 + tailwind-merge@2.6.1: {}
3766 +
3767 + tailwindcss@3.4.19:
3768 + dependencies:
3769 + '@alloc/quick-lru': 5.2.0
3770 + arg: 5.0.2
3771 + chokidar: 3.6.0
3772 + didyoumean: 1.2.2
3773 + dlv: 1.1.3
3774 + fast-glob: 3.3.3
3775 + glob-parent: 6.0.2
3776 + is-glob: 4.0.3
3777 + jiti: 1.21.7
3778 + lilconfig: 3.1.3
3779 + micromatch: 4.0.8
3780 + normalize-path: 3.0.0
3781 + object-hash: 3.0.0
3782 + picocolors: 1.1.1
3783 + postcss: 8.5.26
3784 + postcss-import: 15.1.0(postcss@8.5.26)
3785 + postcss-js: 4.1.0(postcss@8.5.26)
3786 + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)
3787 + postcss-nested: 6.2.0(postcss@8.5.26)
3788 + postcss-selector-parser: 6.1.4
3789 + resolve: 1.22.12
3790 + sucrase: 3.35.1
3791 + transitivePeerDependencies:
3792 + - tsx
3793 + - yaml
3794 +
3795 + text-table@0.2.0: {}
3796 +
3797 + thenify-all@1.6.0:
3798 + dependencies:
3799 + thenify: 3.3.1
3800 +
3801 + thenify@3.3.1:
3802 + dependencies:
3803 + any-promise: 1.3.0
3804 +
3805 + tinyglobby@0.2.17:
3806 + dependencies:
3807 + fdir: 6.5.0(picomatch@4.0.5)
3808 + picomatch: 4.0.5
3809 +
3810 + to-regex-range@5.0.1:
3811 + dependencies:
3812 + is-number: 7.0.0
3813 +
3814 + ts-api-utils@2.5.0(typescript@5.9.3):
3815 + dependencies:
3816 + typescript: 5.9.3
3817 +
3818 + ts-interface-checker@0.1.13: {}
3819 +
3820 + tsconfig-paths@3.15.0:
3821 + dependencies:
3822 + '@types/json5': 0.0.29
3823 + json5: 1.0.2
3824 + minimist: 1.2.8
3825 + strip-bom: 3.0.0
3826 +
3827 + tslib@2.8.1: {}
3828 +
3829 + type-check@0.4.0:
3830 + dependencies:
3831 + prelude-ls: 1.2.1
3832 +
3833 + type-fest@0.20.2: {}
3834 +
3835 + typed-array-buffer@1.0.3:
3836 + dependencies:
3837 + call-bound: 1.0.4
3838 + es-errors: 1.3.0
3839 + is-typed-array: 1.1.15
3840 +
3841 + typed-array-byte-length@1.0.3:
3842 + dependencies:
3843 + call-bind: 1.0.9
3844 + for-each: 0.3.5
3845 + gopd: 1.2.0
3846 + has-proto: 1.2.0
3847 + is-typed-array: 1.1.15
3848 +
3849 + typed-array-byte-offset@1.0.4:
3850 + dependencies:
3851 + available-typed-arrays: 1.0.7
3852 + call-bind: 1.0.9
3853 + for-each: 0.3.5
3854 + gopd: 1.2.0
3855 + has-proto: 1.2.0
3856 + is-typed-array: 1.1.15
3857 + reflect.getprototypeof: 1.0.10
3858 +
3859 + typed-array-length@1.0.8:
3860 + dependencies:
3861 + call-bind: 1.0.9
3862 + for-each: 0.3.5
3863 + gopd: 1.2.0
3864 + is-typed-array: 1.1.15
3865 + possible-typed-array-names: 1.1.0
3866 + reflect.getprototypeof: 1.0.10
3867 +
3868 + typescript@5.9.3: {}
3869 +
3870 + unbox-primitive@1.1.0:
3871 + dependencies:
3872 + call-bound: 1.0.4
3873 + has-bigints: 1.1.0
3874 + has-symbols: 1.1.0
3875 + which-boxed-primitive: 1.1.1
3876 +
3877 + undici-types@6.21.0: {}
3878 +
3879 + unrs-resolver@1.12.2:
3880 + dependencies:
3881 + napi-postinstall: 0.3.4
3882 + optionalDependencies:
3883 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
3884 + '@unrs/resolver-binding-android-arm64': 1.12.2
3885 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
3886 + '@unrs/resolver-binding-darwin-x64': 1.12.2
3887 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
3888 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
3889 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
3890 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
3891 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
3892 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
3893 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
3894 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
3895 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
3896 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
3897 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
3898 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
3899 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
3900 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
3901 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
3902 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
3903 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
3904 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
3905 +
3906 + update-browserslist-db@1.3.1(browserslist@4.28.8):
3907 + dependencies:
3908 + browserslist: 4.28.8
3909 + escalade: 3.2.0
3910 + picocolors: 1.1.1
3911 +
3912 + uri-js@4.4.1:
3913 + dependencies:
3914 + punycode: 2.3.1
3915 +
3916 + util-deprecate@1.0.2: {}
3917 +
3918 + which-boxed-primitive@1.1.1:
3919 + dependencies:
3920 + is-bigint: 1.1.0
3921 + is-boolean-object: 1.2.2
3922 + is-number-object: 1.1.1
3923 + is-string: 1.1.1
3924 + is-symbol: 1.1.1
3925 +
3926 + which-builtin-type@1.2.1:
3927 + dependencies:
3928 + call-bound: 1.0.4
3929 + function.prototype.name: 1.2.0
3930 + has-tostringtag: 1.0.2
3931 + is-async-function: 2.1.1
3932 + is-date-object: 1.1.0
3933 + is-finalizationregistry: 1.1.1
3934 + is-generator-function: 1.1.2
3935 + is-regex: 1.2.1
3936 + is-weakref: 1.1.1
3937 + isarray: 2.0.5
3938 + which-boxed-primitive: 1.1.1
3939 + which-collection: 1.0.2
3940 + which-typed-array: 1.1.22
3941 +
3942 + which-collection@1.0.2:
3943 + dependencies:
3944 + is-map: 2.0.3
3945 + is-set: 2.0.3
3946 + is-weakmap: 2.0.2
3947 + is-weakset: 2.0.4
3948 +
3949 + which-typed-array@1.1.22:
3950 + dependencies:
3951 + available-typed-arrays: 1.0.7
3952 + call-bind: 1.0.9
3953 + call-bound: 1.0.4
3954 + for-each: 0.3.5
3955 + get-proto: 1.0.1
3956 + gopd: 1.2.0
3957 + has-tostringtag: 1.0.2
3958 +
3959 + which@2.0.2:
3960 + dependencies:
3961 + isexe: 2.0.0
3962 +
3963 + word-wrap@1.2.5: {}
3964 +
3965 + wrappy@1.0.2: {}
3966 +
3967 + yocto-queue@0.1.0: {}
added M2M32brouve-ka/pnpm-workspace.yaml +8 −0
@@ -0,0 +1,8 @@
1 +# Trouve-KA — pnpm workspace
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +packages:
5 + - "apps/web"
6 +onlyBuiltDependencies:
7 + - sharp
8 + - unrs-resolver
added M2M32brouve-ka/pyproject.toml +85 −0
@@ -0,0 +1,85 @@
1 +# Trouve-KA — Python monorepo build configuration
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +[project]
6 +name = "trouveka"
7 +version = "0.1.0"
8 +description = "Trouve-KA — moteur de recherche web indépendant, Québec-first"
9 +requires-python = ">=3.12"
10 +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }]
11 +dependencies = [
12 + "fastapi>=0.115",
13 + "uvicorn[standard]>=0.30",
14 + "httpx>=0.27",
15 + "brotli>=1.1",
16 + "selectolax>=0.3.21",
17 + "protego>=0.3.1",
18 + "asyncpg>=0.29",
19 + "redis>=5.0",
20 + "opensearch-py[async]>=2.6",
21 + "pydantic>=2.7",
22 + "pydantic-settings>=2.3",
23 + "langdetect>=1.0.9",
24 + "python-dateutil>=2.9",
25 +]
26 +
27 +[project.optional-dependencies]
28 +dev = [
29 + "pytest>=8",
30 + "pytest-asyncio>=0.23",
31 + "ruff>=0.5",
32 +]
33 +
34 +[build-system]
35 +requires = ["setuptools>=69"]
36 +build-backend = "setuptools.build_meta"
37 +
38 +# The "trouveka" namespace package is assembled from the monorepo layout:
39 +# packages/* hold shared libraries, services/* hold pipeline services,
40 +# apps/api holds the FastAPI application (see CLAUDE.md §2).
41 +[tool.setuptools]
42 +packages = [
43 + "trouveka.config",
44 + "trouveka.logging",
45 + "trouveka.types",
46 + "trouveka.shared",
47 + "trouveka.database",
48 + "trouveka.queue",
49 + "trouveka.search_core",
50 + "trouveka.crawler",
51 + "trouveka.frontier",
52 + "trouveka.parser",
53 + "trouveka.classifier",
54 + "trouveka.indexer",
55 + "trouveka.ranking",
56 + "trouveka.scheduler",
57 + "trouveka.enrichment",
58 + "trouveka.api",
59 +]
60 +
61 +[tool.setuptools.package-dir]
62 +"trouveka.config" = "packages/config"
63 +"trouveka.logging" = "packages/logging"
64 +"trouveka.types" = "packages/types"
65 +"trouveka.shared" = "packages/shared"
66 +"trouveka.database" = "packages/database"
67 +"trouveka.queue" = "packages/queue"
68 +"trouveka.search_core" = "packages/search-core"
69 +"trouveka.crawler" = "services/crawler"
70 +"trouveka.frontier" = "services/frontier"
71 +"trouveka.parser" = "services/parser"
72 +"trouveka.classifier" = "services/classifier"
73 +"trouveka.indexer" = "services/indexer"
74 +"trouveka.ranking" = "services/ranking"
75 +"trouveka.scheduler" = "services/scheduler"
76 +"trouveka.enrichment" = "services/enrichment"
77 +"trouveka.api" = "apps/api"
78 +
79 +[tool.pytest.ini_options]
80 +asyncio_mode = "auto"
81 +testpaths = ["tests"]
82 +
83 +[tool.ruff]
84 +line-length = 110
85 +target-version = "py312"
added M2M32brouve-ka/scripts/bootstrap-seeds/seed.sh +14 −0
@@ -0,0 +1,14 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — chargement des seeds dans le frontier
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -euo pipefail
6 +cd "$(dirname "$0")/../.."
7 +
8 +if [ -x .venv/bin/python ]; then
9 + PY=.venv/bin/python
10 +else
11 + PY=python3
12 +fi
13 +
14 +exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}"
added M2M32brouve-ka/scripts/bootstrap-seeds/seeds.txt +80 −0
@@ -0,0 +1,80 @@
1 +# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire.
5 +
6 +# --- Gouvernement du Québec ---
7 +https://www.quebec.ca/
8 +https://www.assnat.qc.ca/
9 +https://www.revenuquebec.ca/
10 +https://www.ramq.gouv.qc.ca/
11 +https://saaq.gouv.qc.ca/
12 +https://www.cnesst.gouv.qc.ca/
13 +https://www.hydroquebec.com/
14 +https://www.investquebec.com/
15 +https://www.transitionenergetique.gouv.qc.ca/
16 +
17 +# --- Municipalités ---
18 +https://montreal.ca/
19 +https://www.ville.quebec.qc.ca/
20 +https://www.laval.ca/
21 +https://www.gatineau.ca/
22 +https://www.sherbrooke.ca/
23 +https://www.longueuil.quebec/
24 +https://www.trois-rivieres.ca/
25 +https://ville.saguenay.ca/
26 +https://www.levis.ca/
27 +https://www.terrebonne.ca/
28 +https://www.drummondville.ca/
29 +https://www.rimouski.ca/
30 +
31 +# --- Universités et cégeps ---
32 +https://www.ulaval.ca/
33 +https://www.umontreal.ca/
34 +https://www.mcgill.ca/
35 +https://uqam.ca/
36 +https://www.usherbrooke.ca/
37 +https://www.concordia.ca/
38 +https://www.polymtl.ca/
39 +https://www.etsmtl.ca/
40 +https://www.hec.ca/
41 +https://www.uqac.ca/
42 +https://www.uqtr.ca/
43 +https://www.uqar.ca/
44 +https://uqo.ca/
45 +https://www.inrs.ca/
46 +https://www.cegepsquebec.ca/
47 +
48 +# --- Médias ---
49 +https://www.lapresse.ca/
50 +https://www.ledevoir.com/
51 +https://www.journaldemontreal.com/
52 +https://www.journaldequebec.com/
53 +https://ici.radio-canada.ca/
54 +https://www.tvanouvelles.ca/
55 +https://www.lesoleil.com/
56 +https://www.ledroit.com/
57 +https://www.latribune.ca/
58 +https://www.lenouvelliste.ca/
59 +https://www.noovo.info/
60 +https://www.lactualite.com/
61 +
62 +# --- Affaires, annuaires, associations ---
63 +https://www.registreentreprises.gouv.qc.ca/
64 +https://www.desjardins.com/
65 +https://www.fccq.ca/
66 +https://www.ccmm.ca/
67 +https://www.pagesjaunes.ca/
68 +https://quebec.craigslist.org/
69 +https://www.cqcd.org/
70 +https://www.manufacturiersquebec.ca/
71 +
72 +# --- Tourisme et culture ---
73 +https://www.bonjourquebec.com/
74 +https://www.mtl.org/
75 +https://www.quebec-cite.com/
76 +https://www.tourisme-charlevoix.com/
77 +https://www.tourismegaspesie.com/
78 +https://www.sepaq.com/
79 +https://www.lavitrine.com/
80 +https://www.banq.qc.ca/
added M2M32brouve-ka/scripts/check-headers.py +38 −0
@@ -0,0 +1,38 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1)
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Vérifie que chaque fichier source porte le header auteur obligatoire."""
7 +
8 +import pathlib
9 +import sys
10 +
11 +ROOT = pathlib.Path(__file__).resolve().parents[1]
12 +EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"}
13 +SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"}
14 +SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"}
15 +REQUIRED = "Author: Simon-Pierre Boucher"
16 +
17 +
18 +def main() -> int:
19 + missing: list[pathlib.Path] = []
20 + for path in ROOT.rglob("*"):
21 + if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES:
22 + continue
23 + if any(part in SKIP_DIRS for part in path.parts):
24 + continue
25 + head = path.read_text(encoding="utf-8", errors="replace")[:600]
26 + if REQUIRED not in head:
27 + missing.append(path.relative_to(ROOT))
28 + if missing:
29 + print(f"✗ {len(missing)} fichier(s) sans header auteur :")
30 + for p in missing:
31 + print(f" - {p}")
32 + return 1
33 + print("✓ Tous les fichiers source portent le header auteur.")
34 + return 0
35 +
36 +
37 +if __name__ == "__main__":
38 + sys.exit(main())
added M2M32brouve-ka/scripts/cleanup-garbage.py +63 −0
@@ -0,0 +1,63 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — purge des documents mal décodés de l'index
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé)
7 +et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement).
8 +
9 +Usage : python -m scripts... non — exécuter depuis la racine :
10 + .venv/bin/python scripts/cleanup-garbage.py # dev local
11 + (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py)
12 +"""
13 +
14 +import asyncio
15 +
16 +from trouveka.config import get_settings
17 +from trouveka.database import Database
18 +from trouveka.parser import looks_like_garbage
19 +from trouveka.search_core import SearchCore
20 +
21 +
22 +async def main() -> None:
23 + s = get_settings()
24 + db = Database(s.database_url, pool_min=1, pool_max=3)
25 + await db.connect()
26 + search = SearchCore(s.search_url, s.search_index)
27 +
28 + removed = 0
29 + scanned = 0
30 + # Scroll de tout l'index par pages de 200
31 + body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]}
32 + resp = await search.client.search(index=s.search_index, body=body, scroll="2m")
33 + scroll_id = resp.get("_scroll_id")
34 + try:
35 + while True:
36 + hits = resp["hits"]["hits"]
37 + if not hits:
38 + break
39 + for hit in hits:
40 + scanned += 1
41 + src = hit["_source"]
42 + text = " ".join([src.get("title") or "", src.get("description") or "",
43 + (src.get("body") or "")[:20_000]])
44 + if looks_like_garbage(text, threshold=0.02):
45 + await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404])
46 + await db.requeue_url(src["url"])
47 + await db.pool.execute(
48 + "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)",
49 + src["url"],
50 + )
51 + removed += 1
52 + print(f"purgé : {src['url']}")
53 + resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m")
54 + finally:
55 + if scroll_id:
56 + await search.client.clear_scroll(scroll_id=scroll_id)
57 + await search.close()
58 + await db.close()
59 + print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.")
60 +
61 +
62 +if __name__ == "__main__":
63 + asyncio.run(main())
added M2M32brouve-ka/scripts/eval/ranking-eval.yaml +29 −0
@@ -0,0 +1,29 @@
1 +# Trouve-KA — dataset d'évaluation du ranking (§16)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible.
5 +cases:
6 + - query: "université québec"
7 + expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca]
8 + top_k: 5
9 + - query: "plombier gatineau"
10 + expect_any_of: [gatineau.ca, pagesjaunes.ca]
11 + top_k: 10
12 + - query: "subvention thermopompe"
13 + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca]
14 + top_k: 10
15 + - query: "heat pump rebate"
16 + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca]
17 + top_k: 10
18 + - query: "impôt revenu"
19 + expect_any_of: [revenuquebec.ca, quebec.ca]
20 + top_k: 5
21 + - query: "tourisme gaspésie"
22 + expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com]
23 + top_k: 5
24 + - query: "nouvelles montréal"
25 + expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org]
26 + top_k: 10
27 + - query: "permis de conduire"
28 + expect_any_of: [saaq.gouv.qc.ca, quebec.ca]
29 + top_k: 5
added M2M32brouve-ka/scripts/eval/run-eval.py +65 −0
@@ -0,0 +1,65 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — évaluation du ranking contre le dataset (§16)
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Exécute le dataset d'évaluation contre l'API de recherche.
7 +
8 +Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080]
9 +Un cas passe si au moins un domaine attendu apparaît dans le top-k.
10 +L'index grandit en continu : les scores s'améliorent avec la couverture.
11 +"""
12 +
13 +import argparse
14 +import json
15 +import pathlib
16 +import re
17 +import urllib.parse
18 +import urllib.request
19 +
20 +HERE = pathlib.Path(__file__).parent
21 +
22 +
23 +def load_cases() -> list[dict]:
24 + """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML)."""
25 + cases, current = [], None
26 + for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines():
27 + if m := re.match(r'\s*- query: "(.*)"', line):
28 + current = {"query": m.group(1)}
29 + cases.append(current)
30 + elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line):
31 + current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")]
32 + elif m := re.match(r"\s*top_k: (\d+)", line):
33 + current["top_k"] = int(m.group(1))
34 + return cases
35 +
36 +
37 +def main() -> int:
38 + ap = argparse.ArgumentParser()
39 + ap.add_argument("--api", default="http://localhost:8080")
40 + args = ap.parse_args()
41 +
42 + passed = 0
43 + cases = load_cases()
44 + for case in cases:
45 + q = urllib.parse.quote(case["query"])
46 + url = f"{args.api}/api/search?q={q}&limit={case['top_k']}"
47 + try:
48 + with urllib.request.urlopen(url, timeout=10) as resp:
49 + data = json.load(resp)
50 + except Exception as exc:
51 + print(f"✗ {case['query']!r} — API injoignable : {exc}")
52 + continue
53 + domains = [r["domain"] for r in data["results"]]
54 + hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None)
55 + if hit:
56 + passed += 1
57 + print(f"✓ {case['query']!r}{hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)")
58 + else:
59 + print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}")
60 + print(f"\n{passed}/{len(cases)} cas réussis")
61 + return 0 if passed == len(cases) else 1
62 +
63 +
64 +if __name__ == "__main__":
65 + raise SystemExit(main())
added M2M32brouve-ka/scripts/health-check/check.sh +27 −0
@@ -0,0 +1,27 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — vérification de santé du stack
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -uo pipefail
6 +
7 +API_URL="${API_URL:-http://localhost:8080}"
8 +SEARCH_URL="${SEARCH_URL:-http://localhost:9200}"
9 +WEB_URL="${WEB_URL:-http://localhost:3000}"
10 +FAIL=0
11 +
12 +check() {
13 + local name="$1" url="$2"
14 + if curl -sf --max-time 5 "$url" > /dev/null; then
15 + echo "✓ $name ($url)"
16 + else
17 + echo "✗ $name ($url)"
18 + FAIL=1
19 + fi
20 +}
21 +
22 +check "API santé" "$API_URL/api/health"
23 +check "API statut" "$API_URL/api/status"
24 +check "OpenSearch" "$SEARCH_URL/_cluster/health"
25 +check "Web" "$WEB_URL/"
26 +
27 +exit $FAIL
added M2M32brouve-ka/scripts/start-crawler/start.sh +14 −0
@@ -0,0 +1,14 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — démarrage local d'un worker de crawl
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -euo pipefail
6 +cd "$(dirname "$0")/../.."
7 +
8 +if [ -x .venv/bin/python ]; then
9 + PY=.venv/bin/python
10 +else
11 + PY=python3
12 +fi
13 +
14 +exec "$PY" -m trouveka.crawler.worker
added M2M32brouve-ka/services/classifier/__init__.py +15 −0
@@ -0,0 +1,15 @@
1 +# Trouve-KA — classification Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Détection de pertinence québécoise (CLAUDE.md §7).
6 +
7 +Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score
8 +(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être
9 +pertinent sans que le domaine le soit.
10 +"""
11 +
12 +from .scorer import score_page
13 +from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS
14 +
15 +__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"]
added M2M32brouve-ka/services/classifier/gazetteer.py +75 −0
@@ -0,0 +1,75 @@
1 +# Trouve-KA — gazetteer québécois
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Toponymes, régions et signaux institutionnels du Québec.
6 +
7 +Liste de départ volontairement conservatrice : des noms non ambigus d'abord.
8 +Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent
9 +un poids réduit dans le scorer.
10 +"""
11 +
12 +# Municipalités et lieux à fort signal (poids plein)
13 +QUEBEC_TOPONYMS: set[str] = {
14 + "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke",
15 + "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis",
16 + "longueuil", "terrebonne", "brossard", "repentigny", "drummondville",
17 + "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan",
18 + "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe",
19 + "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay",
20 + "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or",
21 + "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière",
22 + "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe",
23 + "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel",
24 + "beloeil", "mascouche", "varennes", "chambly", "saint-eustache",
25 + "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele",
26 + "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois",
27 + "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière",
28 + "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue",
29 + "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec",
30 + "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches",
31 + "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean",
32 + "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine",
33 + "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount",
34 + "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval",
35 + "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges",
36 +}
37 +
38 +# Toponymes ambigus (existent ailleurs) — poids réduit
39 +AMBIGUOUS_TOPONYMS: set[str] = {
40 + "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton",
41 + "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey",
42 +}
43 +
44 +QUEBEC_REGIONS: set[str] = {
45 + "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides",
46 + "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie",
47 + "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik",
48 +}
49 +
50 +# Institutions et organisations québécoises fortement identifiantes
51 +QUEBEC_ORGS: set[str] = {
52 + "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec",
53 + "desjardins", "gouvernement du québec", "gouvernement du quebec",
54 + "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec",
55 + "saq", "sqdc", "caq", "société de transport de montréal", "stm",
56 + "réseau de transport de la capitale", "rtc", "exo", "via rail",
57 + "université laval", "universite laval", "université de montréal",
58 + "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat",
59 + "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal",
60 + "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets",
61 + "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec",
62 + "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec",
63 + "surete du quebec", "loto-québec", "loto-quebec", "investissement québec",
64 + "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq",
65 + "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir",
66 + "journal de montréal", "journal de montreal", "journal de québec",
67 + "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec",
68 + "tourisme québec", "tourisme quebec",
69 +}
70 +
71 +# Indicatifs téléphoniques du Québec (signal faible)
72 +QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"}
73 +
74 +# TLD / suffixes de domaine à signal fort
75 +STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca")
added M2M32brouve-ka/services/classifier/scorer.py +115 −0
@@ -0,0 +1,115 @@
1 +# Trouve-KA — scoreur Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Calcul de page_quebec_score ∈ [0, 1].
6 +
7 +Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes
8 +postaux QC, indicatifs téléphoniques, organisations connues, langue française,
9 +mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul
10 +ne prouve rien); le score sature progressivement via une somme amortie.
11 +"""
12 +
13 +import re
14 +
15 +from trouveka.types import ParsedPage, QuebecSignals
16 +
17 +from .gazetteer import (
18 + AMBIGUOUS_TOPONYMS,
19 + QUEBEC_AREA_CODES,
20 + QUEBEC_ORGS,
21 + QUEBEC_TOPONYMS,
22 + STRONG_DOMAIN_SUFFIXES,
23 +)
24 +
25 +# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1)
26 +_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE)
27 +_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b")
28 +_PROVINCE_RE = re.compile(
29 + r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)",
30 + re.IGNORECASE,
31 +)
32 +_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])"
33 +
34 +
35 +def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]:
36 + found: list[str] = []
37 + total = 0
38 + for term in terms:
39 + pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE)
40 + n = len(pattern.findall(text))
41 + if n:
42 + found.append(term)
43 + total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise
44 + if total >= cap:
45 + break
46 + return min(total, cap), found
47 +
48 +
49 +def score_page(page: ParsedPage, domain: str) -> QuebecSignals:
50 + """Score Québec d'une page. Déterministe, sans LLM, économique (§12)."""
51 + signals = QuebecSignals()
52 + reasons: list[str] = []
53 + points = 0.0
54 +
55 + text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]])
56 + text_with_hints = text + " " + " ".join(page.structured_hints)
57 + lower = text_with_hints.lower()
58 +
59 + # 1. Domaine (signal fort mais pas suffisant seul)
60 + host = domain.lower()
61 + if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES):
62 + points += 4.0
63 + reasons.append("tld_quebec")
64 +
65 + # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps)
66 + head_text = " ".join([page.title, page.description, " ".join(page.headings)])
67 + head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6)
68 + body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8)
69 + if head_hits:
70 + points += 1.2 * head_hits
71 + reasons.append("toponymes_titre")
72 + if body_hits:
73 + points += 0.4 * body_hits
74 + reasons.append("toponymes_corps")
75 + signals.locations = sorted({*head_names, *body_names})[:12]
76 +
77 + # 3. Toponymes ambigus — poids réduit
78 + amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3)
79 + if amb_hits:
80 + points += 0.15 * amb_hits
81 + reasons.append("toponymes_ambigus")
82 + signals.locations = sorted({*signals.locations, *amb_names})[:12]
83 +
84 + # 4. Codes postaux QC (signal fort : preuve d'adresse physique)
85 + postal_hits = len(set(_POSTAL_RE.findall(text_with_hints)))
86 + if postal_hits:
87 + points += min(postal_hits, 3) * 1.5
88 + reasons.append("code_postal_qc")
89 +
90 + # 5. Mention structurée de la province (adresses, footers)
91 + if _PROVINCE_RE.search(text_with_hints):
92 + points += 1.5
93 + reasons.append("province_quebec")
94 +
95 + # 6. Indicatifs téléphoniques (signal faible)
96 + area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES}
97 + if area_codes:
98 + points += min(len(area_codes), 2) * 0.6
99 + reasons.append("indicatif_qc")
100 +
101 + # 7. Organisations québécoises connues
102 + org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6)
103 + if org_hits:
104 + points += 0.8 * org_hits
105 + reasons.append("organisations_qc")
106 +
107 + # 8. Langue : le français augmente la probabilité sans la prouver
108 + if page.language == "fr":
109 + points += 0.8
110 + reasons.append("francais")
111 +
112 + # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95
113 + signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0
114 + signals.reasons = reasons
115 + return signals
added M2M32brouve-ka/services/crawler/__init__.py +12 −0
@@ -0,0 +1,12 @@
1 +# Trouve-KA — crawler
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt,
6 +fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index."""
7 +
8 +from .robots import RobotsCache
9 +from .fetcher import Fetcher
10 +from .traps import looks_like_trap
11 +
12 +__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"]
added M2M32brouve-ka/services/crawler/fetcher.py +148 −0
@@ -0,0 +1,148 @@
1 +# Trouve-KA — fetcher HTTP sécurisé
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille,
6 +gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified).
7 +
8 +Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur.
9 +"""
10 +
11 +import time
12 +from urllib.parse import urlsplit
13 +
14 +import httpx
15 +
16 +from trouveka.config import Settings
17 +from trouveka.shared import canonicalize_url, is_safe_url
18 +from trouveka.types import ErrorCode, FetchResult
19 +
20 +ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain")
21 +
22 +
23 +class Fetcher:
24 + def __init__(self, client: httpx.AsyncClient, settings: Settings):
25 + self._client = client
26 + self._s = settings
27 +
28 + async def fetch(
29 + self, url: str, *, etag: str | None = None, last_modified: str | None = None
30 + ) -> FetchResult:
31 + """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut)."""
32 + start = time.monotonic()
33 + chain: list[str] = []
34 + current = url
35 +
36 + for _hop in range(self._s.max_redirects + 1):
37 + if not is_safe_url(current):
38 + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain)
39 +
40 + # Accept-Encoding volontairement absent : httpx annonce lui-même les
41 + # encodages qu'il sait décoder (br seulement si brotli est installé).
42 + # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca).
43 + headers = {
44 + "User-Agent": self._s.crawler_user_agent,
45 + "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1",
46 + "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7",
47 + }
48 + if etag and current == url:
49 + headers["If-None-Match"] = etag
50 + if last_modified and current == url:
51 + headers["If-Modified-Since"] = last_modified
52 +
53 + try:
54 + async with self._client.stream(
55 + "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False
56 + ) as resp:
57 + if resp.status_code in (301, 302, 303, 307, 308):
58 + location = resp.headers.get("location")
59 + if not location:
60 + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code)
61 + next_url = canonicalize_url(location, base=current)
62 + if not next_url:
63 + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code)
64 + chain.append(next_url)
65 + current = next_url
66 + continue
67 +
68 + if resp.status_code == 304:
69 + return FetchResult(
70 + url=url, final_url=current, status_code=304,
71 + duration_ms=self._ms(start), redirect_chain=chain,
72 + )
73 + if 400 <= resp.status_code < 500:
74 + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code)
75 + if resp.status_code >= 500:
76 + return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code)
77 +
78 + # X-Robots-Tag : noindex au niveau HTTP
79 + x_robots = (resp.headers.get("x-robots-tag") or "").lower()
80 +
81 + raw_ct = resp.headers.get("content-type") or ""
82 + content_type = raw_ct.split(";")[0].strip().lower()
83 + charset = None
84 + if "charset=" in raw_ct.lower():
85 + charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None
86 + if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES):
87 + return self._fail(
88 + url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code
89 + )
90 +
91 + declared = resp.headers.get("content-length")
92 + if declared and int(declared) > self._s.max_response_bytes:
93 + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code)
94 +
95 + body = b""
96 + async for part in resp.aiter_bytes():
97 + body += part
98 + if len(body) > self._s.max_response_bytes:
99 + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code)
100 +
101 + result = FetchResult(
102 + url=url,
103 + final_url=current,
104 + status_code=resp.status_code,
105 + content_type=content_type or None,
106 + charset=charset,
107 + body=body,
108 + etag=resp.headers.get("etag"),
109 + last_modified=resp.headers.get("last-modified"),
110 + duration_ms=self._ms(start),
111 + redirect_chain=chain,
112 + )
113 + if "noindex" in x_robots:
114 + result.error_code = ErrorCode.ROBOTS_DENIED
115 + return result
116 +
117 + except httpx.TimeoutException:
118 + return self._fail(url, current, ErrorCode.TIMEOUT, start, chain)
119 + except httpx.ConnectError as exc:
120 + code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS
121 + return self._fail(url, current, code, start, chain)
122 + except httpx.HTTPError:
123 + return self._fail(url, current, ErrorCode.CONNECTION, start, chain)
124 +
125 + return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain)
126 +
127 + @staticmethod
128 + def _ms(start: float) -> int:
129 + return int((time.monotonic() - start) * 1000)
130 +
131 + def _fail(
132 + self,
133 + url: str,
134 + final_url: str,
135 + code: ErrorCode,
136 + start: float,
137 + chain: list[str],
138 + status: int | None = None,
139 + ) -> FetchResult:
140 + return FetchResult(
141 + url=url, final_url=final_url, status_code=status, error_code=code,
142 + duration_ms=self._ms(start), redirect_chain=chain,
143 + )
144 +
145 +
146 +def scheme_host(url: str) -> str:
147 + parts = urlsplit(url)
148 + return f"{parts.scheme}://{parts.netloc}"
added M2M32brouve-ka/services/crawler/robots.py +84 −0
@@ -0,0 +1,84 @@
1 +# Trouve-KA — gestion robots.txt
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy).
6 +
7 +Les règles sont persistées en base (domains.robots_body) et rafraîchies
8 +après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur
9 +serveur = prudence, on refuse temporairement.
10 +"""
11 +
12 +from datetime import UTC, datetime, timedelta
13 +
14 +import httpx
15 +from protego import Protego
16 +
17 +from trouveka.logging import get_logger
18 +
19 +log = get_logger("crawler.robots")
20 +
21 +ROBOTS_TTL = timedelta(hours=24)
22 +_MAX_ROBOTS_BYTES = 512_000
23 +
24 +
25 +class RobotsCache:
26 + def __init__(self, db, client: httpx.AsyncClient, user_agent: str):
27 + self._db = db
28 + self._client = client
29 + self._ua = user_agent
30 + self._parsers: dict[int, tuple[Protego | None, datetime]] = {}
31 +
32 + async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]:
33 + """Retourne (body, status) : ok | not_found | error."""
34 + try:
35 + resp = await self._client.get(
36 + f"{scheme_host}/robots.txt",
37 + headers={"User-Agent": self._ua},
38 + timeout=15,
39 + follow_redirects=True,
40 + )
41 + except httpx.HTTPError:
42 + return None, "error"
43 + if resp.status_code == 200:
44 + return resp.text[:_MAX_ROBOTS_BYTES], "ok"
45 + if 400 <= resp.status_code < 500:
46 + return None, "not_found" # 4xx = pas de robots → tout permis (standard)
47 + return None, "error"
48 +
49 + async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]:
50 + """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms)."""
51 + cached = self._parsers.get(domain_id)
52 + now = datetime.now(UTC)
53 + if cached and now - cached[1] < ROBOTS_TTL:
54 + parser = cached[0]
55 + return parser, "ok" if parser else "not_found", None
56 +
57 + row = await self._db.get_domain_by_id(domain_id)
58 + if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL:
59 + body, status = row["robots_body"], row["robots_status"]
60 + else:
61 + body, status = await self._fetch_robots(scheme_host)
62 + delay_ms = None
63 + if body:
64 + delay = Protego.parse(body).crawl_delay(self._ua)
65 + delay_ms = int(delay * 1000) if delay else None
66 + await self._db.save_robots(domain_id, body, status, delay_ms)
67 +
68 + parser = Protego.parse(body) if body else None
69 + self._parsers[domain_id] = (parser, now)
70 + if len(self._parsers) > 5000: # borne mémoire du cache local
71 + self._parsers.pop(next(iter(self._parsers)))
72 + return parser, status, row["crawl_delay_ms"] if row else None
73 +
74 + async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]:
75 + """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence)."""
76 + parser, status, delay_ms = await self.get_parser(domain_id, scheme_host)
77 + if status == "error":
78 + return False, None
79 + if parser is None:
80 + return True, None
81 + delay = parser.crawl_delay(self._ua)
82 + if delay is None and delay_ms:
83 + delay = delay_ms / 1000
84 + return parser.can_fetch(url, self._ua), delay
added M2M32brouve-ka/services/crawler/seed.py +57 −0
@@ -0,0 +1,57 @@
1 +# Trouve-KA — chargeur de seeds
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Charge les seeds dans le frontier avec priorité maximale.
6 +
7 +Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt]
8 +"""
9 +
10 +import asyncio
11 +import pathlib
12 +import sys
13 +
14 +from trouveka.config import get_settings
15 +from trouveka.database import Database, run_migrations
16 +from trouveka.logging import get_logger
17 +from trouveka.shared import canonicalize_url, extract_domain
18 +
19 +log = get_logger("crawler.seed")
20 +
21 +DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt"
22 +
23 +
24 +async def load_seeds(path: pathlib.Path) -> int:
25 + settings = get_settings()
26 + applied = await run_migrations(settings.database_url)
27 + if applied:
28 + log.info("migrations appliquées", extra={"ctx": {"files": applied}})
29 +
30 + db = Database(settings.database_url, pool_min=1, pool_max=3)
31 + await db.connect()
32 + added = 0
33 + try:
34 + for line in path.read_text(encoding="utf-8").splitlines():
35 + line = line.strip()
36 + if not line or line.startswith("#"):
37 + continue
38 + url = canonicalize_url(line)
39 + domain = extract_domain(url) if url else None
40 + if not url or not domain:
41 + log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}})
42 + continue
43 + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):
44 + added += 1
45 + finally:
46 + await db.close()
47 + log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}})
48 + return added
49 +
50 +
51 +def main() -> None:
52 + path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS
53 + asyncio.run(load_seeds(path))
54 +
55 +
56 +if __name__ == "__main__":
57 + main()
added M2M32brouve-ka/services/crawler/traps.py +59 −0
@@ -0,0 +1,59 @@
1 +# Trouve-KA — détection de pièges de crawl
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs,
6 +explosions de facettes, pagination infinie, chemins répétitifs."""
7 +
8 +import re
9 +from urllib.parse import parse_qsl, urlsplit
10 +
11 +_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"}
12 +_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$")
13 +_LONG_NUMBER_RE = re.compile(r"\d{10,}")
14 +
15 +
16 +def looks_like_trap(
17 + url: str,
18 + *,
19 + max_query_params: int = 8,
20 + max_path_segments: int = 12,
21 + max_url_length: int = 1024,
22 +) -> bool:
23 + """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier."""
24 + if len(url) > max_url_length:
25 + return True
26 + try:
27 + parts = urlsplit(url)
28 + except ValueError:
29 + return True
30 +
31 + params = parse_qsl(parts.query, keep_blank_values=True)
32 + if len(params) > max_query_params:
33 + return True
34 + keys = {k.lower() for k, _ in params}
35 + if keys & _SESSION_PARAMS:
36 + return True
37 + # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…)
38 + raw_keys = [k.lower() for k, _ in params]
39 + if any(raw_keys.count(k) > 3 for k in set(raw_keys)):
40 + return True
41 +
42 + segments = [s for s in parts.path.split("/") if s]
43 + if len(segments) > max_path_segments:
44 + return True
45 + # Segment répété (boucles : /a/b/a/b/a/b)
46 + if any(segments.count(s) > 3 for s in set(segments)):
47 + return True
48 + # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17
49 + if _CALENDAR_RE.search(parts.path):
50 + year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path)
51 + if year_match and not (1995 <= int(year_match.group(1)) <= 2027):
52 + return True
53 + # Pagination excessive
54 + for key, value in params:
55 + if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500:
56 + return True
57 + if _LONG_NUMBER_RE.search(parts.query):
58 + return True
59 + return False
added M2M32brouve-ka/services/crawler/worker.py +447 −0
@@ -0,0 +1,447 @@
1 +# Trouve-KA — worker de crawl (pipeline complet)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline
6 +fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3).
7 +
8 +Chaque page traitée avec succès est cherchable en quelques secondes.
9 +L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais.
10 +N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte).
11 +"""
12 +
13 +import asyncio
14 +import os
15 +import random
16 +import signal
17 +import uuid
18 +from datetime import UTC, datetime, timedelta
19 +
20 +import httpx
21 +
22 +from trouveka.classifier import score_page
23 +from trouveka.config import get_settings
24 +from trouveka.database import Database
25 +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay
26 +from trouveka.indexer import build_search_document
27 +from trouveka.logging import get_logger
28 +from trouveka.parser import looks_like_garbage, parse_html
29 +from trouveka.queue import Coordination
30 +from trouveka.search_core import SearchCore
31 +from trouveka.shared import canonicalize_url, content_hash, extract_domain
32 +from trouveka.types import ErrorCode, Outcome
33 +
34 +from .fetcher import Fetcher, scheme_host
35 +from .robots import RobotsCache
36 +from .traps import looks_like_trap
37 +
38 +log = get_logger("crawler.worker")
39 +
40 +TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS}
41 +MAX_RETRIES = 3
42 +
43 +
44 +class CrawlerWorker:
45 + def __init__(self) -> None:
46 + self.s = get_settings()
47 + self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}"
48 + self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max)
49 + self.coord = Coordination(self.s.redis_url)
50 + self.search = SearchCore(self.s.search_url, self.s.search_index)
51 + self.client = httpx.AsyncClient(
52 + limits=httpx.Limits(
53 + max_connections=self.s.max_global_concurrency,
54 + max_keepalive_connections=self.s.max_global_concurrency,
55 + ),
56 + http2=False,
57 + )
58 + self.fetcher = Fetcher(self.client, self.s)
59 + self.robots: RobotsCache | None = None
60 + self.stop_event = asyncio.Event()
61 + self._domain_cache: dict[int, dict] = {}
62 + # Cache domaine→row pour la découverte : évite un SELECT par lien sortant
63 + # (une page peut avoir 300 liens, souvent vers les mêmes domaines).
64 + self._domain_by_name: dict[str, tuple[object, datetime]] = {}
65 +
66 + # ------------------------------------------------------------------ cycle de vie
67 +
68 + async def start(self) -> None:
69 + await self.db.connect()
70 + self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent)
71 + await self.search.ensure_index()
72 + log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}})
73 +
74 + loop = asyncio.get_running_loop()
75 + for sig in (signal.SIGINT, signal.SIGTERM):
76 + loop.add_signal_handler(sig, self.stop_event.set)
77 +
78 + # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère.
79 + # (Un gather par lot créait du head-of-line blocking : une page lente
80 + # bloquait tout le lot avant la réclamation suivante.)
81 + in_flight: set[asyncio.Task] = set()
82 + idle_sleep = 1.0
83 +
84 + async def run_one(item) -> None:
85 + try:
86 + await self.process_item(item)
87 + except Exception:
88 + log.exception(
89 + "échec inattendu du pipeline",
90 + extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}},
91 + )
92 + await self.db.release_item(
93 + item["url_id"], status="failed", error_code="parse_failed"
94 + )
95 +
96 + while not self.stop_event.is_set():
97 + if await self.coord.is_paused():
98 + await asyncio.sleep(3)
99 + continue
100 + free = self.s.max_global_concurrency - len(in_flight)
101 + if free <= 0:
102 + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED)
103 + continue
104 + batch = await self.db.claim_batch(self.worker_id, limit=free)
105 + if not batch:
106 + if in_flight:
107 + await asyncio.wait(
108 + in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep
109 + )
110 + else:
111 + await asyncio.sleep(idle_sleep)
112 + idle_sleep = min(idle_sleep * 1.5, 15)
113 + continue
114 + idle_sleep = 1.0
115 + for item in batch:
116 + task = asyncio.create_task(run_one(item))
117 + in_flight.add(task)
118 + task.add_done_callback(in_flight.discard)
119 +
120 + if in_flight:
121 + await asyncio.gather(*in_flight, return_exceptions=True)
122 + await self.shutdown()
123 +
124 + async def shutdown(self) -> None:
125 + await self.client.aclose()
126 + await self.search.close()
127 + await self.coord.close()
128 + await self.db.close()
129 + log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}})
130 +
131 + # ------------------------------------------------------------------ helpers
132 +
133 + async def _domain(self, domain_id: int) -> dict:
134 + cached = self._domain_cache.get(domain_id)
135 + if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60:
136 + return cached
137 + row = await self.db.get_domain_by_id(domain_id)
138 + entry = dict(row) | {"_at": datetime.now(UTC)}
139 + self._domain_cache[domain_id] = entry
140 + if len(self._domain_cache) > 5000:
141 + self._domain_cache.pop(next(iter(self._domain_cache)))
142 + return entry
143 +
144 + async def _defer(self, url_id: int, seconds: float) -> None:
145 + """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte).
146 +
147 + Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte
148 + (sinon elles reviennent toutes en tête de file au même instant et churnent).
149 + """
150 + await self.db.release_item(
151 + url_id,
152 + status="pending",
153 + next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)),
154 + )
155 +
156 + async def _domain_row_by_name(self, domain: str):
157 + """Row domaine (ou None si inconnu), avec cache TTL 60 s."""
158 + cached = self._domain_by_name.get(domain)
159 + now = datetime.now(UTC)
160 + if cached and (now - cached[1]).total_seconds() < 60:
161 + return cached[0]
162 + row = await self.db.get_domain(domain)
163 + self._domain_by_name[domain] = (row, now)
164 + if len(self._domain_by_name) > 20_000:
165 + self._domain_by_name.pop(next(iter(self._domain_by_name)))
166 + return row
167 +
168 + def _previous_delay_hours(self, item) -> float | None:
169 + if item["last_crawled_at"] and item["next_crawl_at"]:
170 + delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600
171 + return max(delta, 0.1)
172 + return None
173 +
174 + # ------------------------------------------------------------------ pipeline
175 +
176 + async def process_item(self, item) -> None:
177 + url: str = item["url"]
178 + url_id: int = item["url_id"]
179 + domain_row = await self._domain(item["domain_id"])
180 + domain: str = domain_row["domain"]
181 +
182 + if domain_row["blocked"]:
183 + await self.db.release_item(url_id, status="blocked")
184 + return
185 +
186 + host = scheme_host(url)
187 +
188 + # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse)
189 + allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host)
190 + if not allowed:
191 + await self.db.record_attempt(
192 + url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED,
193 + outcome=Outcome.ROBOTS_BLOCKED,
194 + )
195 + await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED)
196 + return
197 +
198 + # Politesse par hôte, tous workers confondus
199 + delay = max(robots_delay or 0, self.s.default_host_delay)
200 + if not await self.coord.acquire_host_slot(domain, delay):
201 + await self._defer(url_id, delay + 0.5)
202 + return
203 +
204 + # Cache HTTP conditionnel
205 + existing = await self.db.get_document(url_id)
206 + result = await self.fetcher.fetch(
207 + url,
208 + etag=existing["etag"] if existing else None,
209 + last_modified=existing["last_modified"] if existing else None,
210 + )
211 +
212 + # --- 304 : inchangé
213 + if result.status_code == 304:
214 + await self._finish_unchanged(item, existing)
215 + return
216 +
217 + # --- erreurs
218 + if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None:
219 + await self._finish_error(item, result)
220 + return
221 +
222 + # --- redirection vers une autre URL canonique : suivre la cible
223 + final = canonicalize_url(result.final_url) or result.final_url
224 + if final != url:
225 + final_domain = extract_domain(final)
226 + if final_domain and not looks_like_trap(final):
227 + await self.db.enqueue_url(
228 + final, final_domain, priority=item["priority"], depth=item["depth"],
229 + source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain,
230 + )
231 + await self.db.set_canonical(url_id, final)
232 + await self.db.record_attempt(
233 + url_id, status_code=result.status_code, error_code=ErrorCode.OK,
234 + outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms,
235 + )
236 + await self.db.release_item(url_id, status="done")
237 + return
238 +
239 + # --- parse
240 + try:
241 + page = parse_html(
242 + url, result.body, max_links=self.s.max_links_per_page, charset=result.charset
243 + )
244 + except Exception:
245 + await self.db.record_attempt(
246 + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED,
247 + outcome=Outcome.ERROR, duration_ms=result.duration_ms,
248 + )
249 + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED)
250 + return
251 +
252 + # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé
253 + if looks_like_garbage(page.title) or looks_like_garbage(page.body):
254 + await self.db.record_attempt(
255 + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED,
256 + outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80],
257 + )
258 + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED)
259 + return
260 +
261 + # --- score Québec (immédiat, déterministe)
262 + signals = score_page(page, domain)
263 +
264 + # --- détection de changement
265 + chash = content_hash(page.title, page.body)
266 + if existing and existing["content_hash"] == chash:
267 + await self._finish_unchanged(item, existing, status_code=result.status_code)
268 + await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité
269 + return
270 +
271 + # --- doublon exact sur une autre URL
272 + duplicate = await self.db.find_duplicate(chash, url_id)
273 + if duplicate:
274 + await self.db.record_attempt(
275 + url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE,
276 + outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms,
277 + title=page.title, quebec_score=signals.score,
278 + )
279 + await self.db.release_item(
280 + url_id, status="done",
281 + next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours),
282 + )
283 + return
284 +
285 + indexable = (
286 + not page.noindex
287 + and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex
288 + and signals.score >= self.s.min_quebec_score_to_index
289 + and len(page.body) >= self.s.min_body_length
290 + )
291 +
292 + if indexable:
293 + # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s -------
294 + canonical_target = page.canonical_url or url
295 + doc = build_search_document(
296 + page, signals,
297 + domain=domain,
298 + domain_quebec_score=float(domain_row["quebec_score"]),
299 + authority_score=float(domain_row["authority_score"]),
300 + )
301 + await self.search.index_document(doc)
302 + await self.db.upsert_document(
303 + url_id,
304 + content_hash=chash,
305 + etag=result.etag,
306 + last_modified=result.last_modified,
307 + title=page.title,
308 + description=page.description,
309 + language=page.language,
310 + page_quebec_score=signals.score,
311 + published_at=page.published_at,
312 + changed=existing is not None,
313 + )
314 + if page.canonical_url and page.canonical_url != url:
315 + await self.db.set_canonical(url_id, canonical_target)
316 + # Enrichissement asynchrone (jamais bloquant)
317 + await self.coord.enqueue_enrichment(
318 + {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2}
319 + )
320 + outcome = Outcome.INDEXED
321 + else:
322 + outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED
323 +
324 + await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language)
325 + self._domain_cache.pop(item["domain_id"], None)
326 +
327 + await self.db.record_attempt(
328 + url_id, status_code=result.status_code, error_code=ErrorCode.OK,
329 + outcome=outcome, content_hash=chash, num_bytes=len(result.body),
330 + duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score,
331 + )
332 + recrawl = next_recrawl_delay(
333 + changed=True, previous_delay_hours=self._previous_delay_hours(item),
334 + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours,
335 + default_hours=self.s.default_recrawl_hours,
336 + )
337 + await self.db.release_item(
338 + url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl,
339 + )
340 +
341 + await self._discover_links(item, page, signals)
342 + log.info(
343 + "page traitée",
344 + extra={"ctx": {
345 + "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"],
346 + "url": url, "outcome": str(outcome), "quebec_score": signals.score,
347 + "links": len(page.links), "ms": result.duration_ms,
348 + }},
349 + )
350 +
351 + # ------------------------------------------------------------------ issues terminales
352 +
353 + async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None:
354 + recrawl = next_recrawl_delay(
355 + changed=False, previous_delay_hours=self._previous_delay_hours(item),
356 + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours,
357 + default_hours=self.s.default_recrawl_hours,
358 + )
359 + await self.db.record_attempt(
360 + item["url_id"], status_code=status_code, error_code=ErrorCode.OK,
361 + outcome=Outcome.UNCHANGED,
362 + content_hash=existing["content_hash"] if existing else None,
363 + )
364 + await self.db.release_item(
365 + item["url_id"], status="pending",
366 + next_crawl_at=datetime.now(UTC) + recrawl,
367 + )
368 +
369 + async def _finish_error(self, item, result) -> None:
370 + url_id = item["url_id"]
371 + await self.db.record_attempt(
372 + url_id, status_code=result.status_code, error_code=result.error_code,
373 + outcome=Outcome.ERROR, duration_ms=result.duration_ms,
374 + )
375 + transient = result.error_code in TRANSIENT_ERRORS
376 + if transient and item["retries"] < MAX_RETRIES:
377 + await self.db.release_item(
378 + url_id, status="pending", error_code=result.error_code,
379 + next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]),
380 + increment_retries=True,
381 + )
382 + else:
383 + await self.db.release_item(url_id, status="failed", error_code=result.error_code)
384 +
385 + # ------------------------------------------------------------------ découverte
386 +
387 + async def _discover_links(self, item, page, signals) -> None:
388 + """Boucle de découverte (§8) : liens sortants → scoring → frontier."""
389 + if item["depth"] >= self.s.max_crawl_depth:
390 + return
391 + domain_row = await self._domain(item["domain_id"])
392 + source_domain = domain_row["domain"]
393 + source_quebec = max(float(domain_row["quebec_score"]), signals.score)
394 +
395 + outlink_domains: dict[int, int] = {}
396 + enqueued = 0
397 + for link in page.links:
398 + if link.nofollow or looks_like_trap(
399 + link.url,
400 + max_query_params=self.s.max_query_params,
401 + max_path_segments=self.s.max_path_segments,
402 + ):
403 + continue
404 + target_domain = extract_domain(link.url)
405 + if not target_domain:
406 + continue
407 + same_domain = target_domain == source_domain
408 + target_row = await self._domain_row_by_name(target_domain)
409 + is_new = target_row is None
410 + target_quebec = float(target_row["quebec_score"]) if target_row else 0.0
411 + # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec
412 + effective_quebec = target_quebec if not is_new else source_quebec * 0.7
413 + if same_domain:
414 + effective_quebec = max(effective_quebec, source_quebec)
415 +
416 + priority = compute_priority(
417 + domain_quebec_score=effective_quebec,
418 + authority_score=float(target_row["authority_score"]) if target_row else 0.0,
419 + link_signal=0.5 if not same_domain else 0.2,
420 + is_new_domain=is_new,
421 + depth=item["depth"] + 1,
422 + )
423 + # Économie de crawl : ignorer les cibles au signal Québec quasi nul
424 + if priority < 0.1:
425 + continue
426 + url_id = await self.db.enqueue_url(
427 + link.url, target_domain,
428 + priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"],
429 + max_urls_per_domain=self.s.max_urls_per_domain,
430 + )
431 + if url_id:
432 + enqueued += 1
433 + if not same_domain:
434 + target_id = (target_row["id"] if target_row
435 + else (await self.db.upsert_domain(target_domain)))
436 + outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1
437 +
438 + if outlink_domains:
439 + await self.db.record_domain_links(item["domain_id"], outlink_domains)
440 +
441 +
442 +def main() -> None:
443 + asyncio.run(CrawlerWorker().start())
444 +
445 +
446 +if __name__ == "__main__":
447 + main()
added M2M32brouve-ka/services/enrichment/__init__.py +14 −0
@@ -0,0 +1,14 @@
1 +# Trouve-KA — enrichissement asynchrone
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4).
6 +
7 +Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour
8 +les documents déjà cherchables (scores de domaine, fraîcheur, plus tard
9 +embeddings et entités).
10 +"""
11 +
12 +from .worker import EnrichmentWorker
13 +
14 +__all__ = ["EnrichmentWorker"]
added M2M32brouve-ka/services/enrichment/worker.py +82 −0
@@ -0,0 +1,82 @@
1 +# Trouve-KA — worker d'enrichissement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams).
6 +
7 +Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour
8 +dans le document indexé (le domaine apprend au fil du crawl, les documents déjà
9 +indexés en profitent rétroactivement).
10 +Étapes futures : embeddings, entités, classification thématique — même canal,
11 +même contrat : mise à jour partielle du document, jamais bloquante.
12 +"""
13 +
14 +import asyncio
15 +import os
16 +import signal
17 +import uuid
18 +
19 +from trouveka.config import get_settings
20 +from trouveka.database import Database
21 +from trouveka.logging import get_logger
22 +from trouveka.queue import Coordination
23 +from trouveka.search_core import SearchCore
24 +
25 +log = get_logger("enrichment")
26 +
27 +GROUP = "enrichers"
28 +
29 +
30 +class EnrichmentWorker:
31 + def __init__(self) -> None:
32 + self.s = get_settings()
33 + self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}"
34 + self.db = Database(self.s.database_url, pool_min=1, pool_max=3)
35 + self.coord = Coordination(self.s.redis_url)
36 + self.search = SearchCore(self.s.search_url, self.s.search_index)
37 + self.stop_event = asyncio.Event()
38 +
39 + async def enrich(self, payload: dict) -> None:
40 + domain_row = await self.db.get_domain_by_id(int(payload["domain_id"]))
41 + if not domain_row:
42 + return
43 + await self.search.update_document(
44 + payload["url"],
45 + {
46 + "domain_quebec_score": round(float(domain_row["quebec_score"]), 4),
47 + "authority_score": round(float(domain_row["authority_score"]), 4),
48 + },
49 + )
50 +
51 + async def start(self) -> None:
52 + await self.db.connect()
53 + loop = asyncio.get_running_loop()
54 + for sig in (signal.SIGINT, signal.SIGTERM):
55 + loop.add_signal_handler(sig, self.stop_event.set)
56 + log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}})
57 +
58 + while not self.stop_event.is_set():
59 + try:
60 + messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000)
61 + for msg_id, payload in messages:
62 + try:
63 + await self.enrich(payload)
64 + except Exception:
65 + log.exception("échec enrichissement", extra={"ctx": payload})
66 + finally:
67 + await self.coord.ack_enrichment(GROUP, msg_id)
68 + except Exception:
69 + log.exception("erreur boucle enrichissement (on continue)")
70 + await asyncio.sleep(2)
71 +
72 + await self.search.close()
73 + await self.coord.close()
74 + await self.db.close()
75 +
76 +
77 +def main() -> None:
78 + asyncio.run(EnrichmentWorker().start())
79 +
80 +
81 +if __name__ == "__main__":
82 + main()
added M2M32brouve-ka/services/frontier/__init__.py +9 −0
@@ -0,0 +1,9 @@
1 +# Trouve-KA — politique du frontier
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5)."""
6 +
7 +from .policy import compute_priority, next_recrawl_delay, retry_delay
8 +
9 +__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"]
added M2M32brouve-ka/services/frontier/policy.py +65 −0
@@ -0,0 +1,65 @@
1 +# Trouve-KA — priorités et recrawl adaptatif
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Fonction de priorité du frontier et scheduling de recrawl.
6 +
7 +P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3)
8 +Les poids sont des constantes nommées, destinées à être calibrées par mesures.
9 +"""
10 +
11 +from datetime import timedelta
12 +
13 +# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés
14 +W_QUEBEC = 0.40
15 +W_AUTHORITY = 0.15
16 +W_FRESHNESS = 0.10
17 +W_LINKS = 0.10
18 +W_NOVELTY = 0.15
19 +W_DEPTH = 0.05
20 +W_SPAM = 0.30
21 +
22 +
23 +def compute_priority(
24 + *,
25 + domain_quebec_score: float,
26 + authority_score: float = 0.0,
27 + freshness_hint: float = 0.0,
28 + link_signal: float = 0.0,
29 + is_new_domain: bool = False,
30 + depth: int = 0,
31 + spam_signal: float = 0.0,
32 + is_seed: bool = False,
33 +) -> float:
34 + """Priorité ∈ [0, 1]. Les seeds démarrent au maximum."""
35 + if is_seed:
36 + return 1.0
37 + p = (
38 + W_QUEBEC * domain_quebec_score
39 + + W_AUTHORITY * min(authority_score, 1.0)
40 + + W_FRESHNESS * min(freshness_hint, 1.0)
41 + + W_LINKS * min(link_signal, 1.0)
42 + + W_NOVELTY * (1.0 if is_new_domain else 0.3)
43 + - W_DEPTH * min(depth, 10) / 10.0
44 + - W_SPAM * min(spam_signal, 1.0)
45 + )
46 + return max(0.0, min(1.0, round(p, 4)))
47 +
48 +
49 +def next_recrawl_delay(
50 + *,
51 + changed: bool,
52 + previous_delay_hours: float | None,
53 + min_hours: float = 1.0,
54 + max_hours: float = 24 * 30.0,
55 + default_hours: float = 24.0,
56 +) -> timedelta:
57 + """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2."""
58 + prev = previous_delay_hours or default_hours
59 + hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0)
60 + return timedelta(hours=hours)
61 +
62 +
63 +def retry_delay(retries: int) -> timedelta:
64 + """Backoff exponentiel plafonné pour les erreurs transitoires."""
65 + return timedelta(minutes=min(15 * (2**retries), 60 * 24))
added M2M32brouve-ka/services/indexer/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — indexeur
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4).
6 +
7 +L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3
8 +(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle.
9 +"""
10 +
11 +from .build import build_search_document
12 +
13 +__all__ = ["build_search_document"]
added M2M32brouve-ka/services/indexer/build.py +66 −0
@@ -0,0 +1,66 @@
1 +# Trouve-KA — construction du document d'index
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1)."""
6 +
7 +from datetime import UTC, datetime
8 +from typing import Any
9 +
10 +from trouveka.types import ParsedPage, QuebecSignals
11 +
12 +# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2)
13 +_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca")
14 +_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"}
15 +_NEWS_DOMAINS = {
16 + "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com",
17 + "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com",
18 + "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca",
19 + "noovo.info", "24heures.ca", "montrealgazette.com",
20 +}
21 +_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca",
22 + ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca")
23 +
24 +
25 +def categorize_domain(domain: str) -> list[str]:
26 + d = domain.lower()
27 + cats: list[str] = []
28 + if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d:
29 + cats.append("government")
30 + if d in _NEWS_DOMAINS:
31 + cats.append("news")
32 + if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"):
33 + cats.append("education")
34 + return cats
35 +
36 +
37 +def build_search_document(
38 + page: ParsedPage,
39 + signals: QuebecSignals,
40 + *,
41 + domain: str,
42 + domain_quebec_score: float,
43 + authority_score: float = 0.0,
44 +) -> dict[str, Any]:
45 + return {
46 + "url": page.url,
47 + "canonical_url": page.canonical_url or page.url,
48 + "domain": domain,
49 + "title": page.title,
50 + "description": page.description,
51 + "body": page.body[:100_000],
52 + "headings": page.headings,
53 + "language": page.language,
54 + "page_quebec_score": signals.score,
55 + "domain_quebec_score": round(domain_quebec_score, 4),
56 + "locations": signals.locations,
57 + "organizations": [], # enrichissement étape 2
58 + "people": [], # enrichissement étape 2
59 + "categories": categorize_domain(domain),
60 + "published_at": page.published_at.isoformat() if page.published_at else None,
61 + "crawled_at": datetime.now(UTC).isoformat(),
62 + "authority_score": round(authority_score, 4),
63 + "freshness_score": 0.0, # enrichissement étape 3
64 + "quality_score": 0.0, # enrichissement étape 3
65 + "spam_score": 0.0, # enrichissement étape 3
66 + }
added M2M32brouve-ka/services/parser/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — extraction de contenu
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Extraction de contenu des pages crawlées (CLAUDE.md §6).
6 +
7 +Chaque page est traitée comme non fiable : texte extrait puis assaini,
8 +URLs re-canonicalisées, MIME types jamais crus sur parole.
9 +"""
10 +
11 +from .html_parser import parse_html, decode_html, looks_like_garbage
12 +
13 +__all__ = ["parse_html", "decode_html", "looks_like_garbage"]
added M2M32brouve-ka/services/parser/html_parser.py +246 −0
@@ -0,0 +1,246 @@
1 +# Trouve-KA — parseur HTML
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Extraction HTML avec selectolax (rapide, tolérant).
6 +
7 +Extrait : titre, meta description, corps principal (boilerplate retiré),
8 +headings, liens + anchors + nofollow, canonical, langue, dates de publication,
9 +auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec.
10 +"""
11 +
12 +import json
13 +import re
14 +from datetime import datetime
15 +
16 +from dateutil import parser as dateparser
17 +from langdetect import DetectorFactory, LangDetectException, detect
18 +from selectolax.parser import HTMLParser
19 +
20 +from trouveka.shared import canonicalize_url
21 +from trouveka.types import ExtractedLink, ParsedPage
22 +
23 +DetectorFactory.seed = 42 # détection de langue déterministe
24 +
25 +# Éléments retirés avant extraction du corps (navigation, pub, scripts…)
26 +_STRIP_SELECTORS = (
27 + "script", "style", "noscript", "template", "svg", "iframe", "form",
28 + "nav", "header", "footer", "aside",
29 + "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]",
30 + "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]",
31 +)
32 +
33 +_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content")
34 +
35 +_WS = re.compile(r"\s+")
36 +_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
37 +
38 +
39 +def _clean(text: str | None) -> str:
40 + if not text:
41 + return ""
42 + return _WS.sub(" ", _CTRL.sub(" ", text)).strip()
43 +
44 +
45 +def _parse_date(value: str | None) -> datetime | None:
46 + if not value:
47 + return None
48 + try:
49 + dt = dateparser.parse(value)
50 + except (ValueError, OverflowError, TypeError):
51 + return None
52 + if dt and dt.year >= 1990:
53 + return dt
54 + return None
55 +
56 +
57 +def _meta(tree: HTMLParser, *names: str) -> str | None:
58 + for name in names:
59 + for attr in ("name", "property", "itemprop"):
60 + node = tree.css_first(f'meta[{attr}="{name}"]')
61 + if node:
62 + content = node.attributes.get("content")
63 + if content and content.strip():
64 + return content.strip()
65 + return None
66 +
67 +
68 +def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]:
69 + """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished."""
70 + hints: list[str] = []
71 + published: datetime | None = None
72 + for node in tree.css('script[type="application/ld+json"]')[:max_blocks]:
73 + raw = node.text()
74 + if not raw or len(raw) > 100_000:
75 + continue
76 + try:
77 + data = json.loads(raw)
78 + except (json.JSONDecodeError, ValueError):
79 + continue
80 + stack = [data]
81 + while stack:
82 + item = stack.pop()
83 + if isinstance(item, list):
84 + stack.extend(item[:20])
85 + elif isinstance(item, dict):
86 + for key in ("name", "legalName", "addressLocality", "addressRegion",
87 + "postalCode", "streetAddress", "telephone"):
88 + val = item.get(key)
89 + if isinstance(val, str) and len(val) < 200:
90 + hints.append(val)
91 + if published is None:
92 + published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated"))
93 + stack.extend(v for v in item.values() if isinstance(v, (dict, list)))
94 + if len(hints) > 60:
95 + break
96 + return hints[:60], published
97 +
98 +
99 +_META_CHARSET_RE = re.compile(
100 + rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE
101 +)
102 +_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"}
103 +
104 +
105 +def decode_html(body: bytes, declared_charset: str | None = None) -> str:
106 + """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252.
107 +
108 + Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder
109 + aveuglément en UTF-8 produit du charabia qui finirait dans l'index.
110 + """
111 + candidates: list[str] = []
112 + if declared_charset:
113 + candidates.append(declared_charset.lower())
114 + meta = _META_CHARSET_RE.search(body[:4096])
115 + if meta:
116 + candidates.append(meta.group(1).decode("ascii", errors="ignore").lower())
117 + candidates.extend(["utf-8", "cp1252"])
118 + for charset in candidates:
119 + charset = _CHARSET_ALIASES.get(charset, charset)
120 + try:
121 + return body.decode(charset)
122 + except (UnicodeDecodeError, LookupError):
123 + continue
124 + return body.decode("utf-8", errors="replace")
125 +
126 +
127 +def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool:
128 + """True si le texte contient trop de caractères de remplacement/contrôle
129 + (contenu binaire ou mal décodé — ne doit jamais être indexé)."""
130 + if not text:
131 + return False
132 + sample = text[:20_000]
133 + bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r"))
134 + return bad / len(sample) > threshold
135 +
136 +
137 +def parse_html(
138 + url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None
139 +) -> ParsedPage:
140 + """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse."""
141 + if isinstance(html, bytes):
142 + html = decode_html(html, charset)
143 + tree = HTMLParser(html)
144 +
145 + # Directives robots de la page
146 + robots_meta = (_meta(tree, "robots", "googlebot") or "").lower()
147 + noindex = "noindex" in robots_meta
148 + nofollow_page = "nofollow" in robots_meta
149 +
150 + # Canonical
151 + canonical = None
152 + link_canonical = tree.css_first('link[rel="canonical"]')
153 + if link_canonical:
154 + canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url)
155 +
156 + # Titre / description
157 + title = _clean(tree.css_first("title").text() if tree.css_first("title") else None)
158 + og_title = _meta(tree, "og:title")
159 + if not title and og_title:
160 + title = _clean(og_title)
161 + description = _clean(_meta(tree, "description", "og:description") or "")
162 +
163 + # Langue déclarée
164 + declared_lang = None
165 + html_node = tree.css_first("html")
166 + if html_node:
167 + lang_attr = (html_node.attributes.get("lang") or "").strip().lower()
168 + if lang_attr:
169 + declared_lang = lang_attr[:2]
170 +
171 + # Dates / auteur
172 + published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date"))
173 + modified = _parse_date(_meta(tree, "article:modified_time", "dateModified"))
174 + author = _meta(tree, "author", "article:author")
175 +
176 + # Indices structurés (avant strip : JSON-LD est dans <script>)
177 + structured_hints, jsonld_published = _extract_jsonld_hints(tree)
178 + published = published or jsonld_published
179 +
180 + # Liens (avant strip : les liens de nav comptent pour la découverte)
181 + links: list[ExtractedLink] = []
182 + seen: set[str] = set()
183 + for node in tree.css("a[href]"):
184 + if len(links) >= max_links:
185 + break
186 + href = node.attributes.get("href") or ""
187 + if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):
188 + continue
189 + normalized = canonicalize_url(href, base=url)
190 + if not normalized or normalized in seen:
191 + continue
192 + seen.add(normalized)
193 + rel = (node.attributes.get("rel") or "").lower()
194 + links.append(
195 + ExtractedLink(
196 + url=normalized,
197 + anchor=_clean(node.text())[:200],
198 + nofollow="nofollow" in rel or nofollow_page,
199 + )
200 + )
201 +
202 + # Headings
203 + headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]]
204 + headings = [h for h in headings if h]
205 +
206 + # Corps principal : strip du boilerplate, puis zone principale si identifiable
207 + for selector in _STRIP_SELECTORS:
208 + for node in tree.css(selector):
209 + node.decompose()
210 + body_node = None
211 + for selector in _MAIN_SELECTORS:
212 + body_node = tree.css_first(selector)
213 + if body_node:
214 + break
215 + if body_node is None:
216 + body_node = tree.css_first("body") or tree.root
217 + body = _clean(body_node.text(separator=" ", deep=True))[:200_000]
218 +
219 + # Langue : déclaration HTML validée/complétée par détection statistique
220 + language = declared_lang if declared_lang in ("fr", "en") else None
221 + sample = body[:4000] or title
222 + if sample and len(sample) > 40:
223 + try:
224 + detected = detect(sample)
225 + if detected in ("fr", "en"):
226 + # La détection l'emporte si elle contredit une déclaration douteuse
227 + language = detected if language is None or detected != language else language
228 + except LangDetectException:
229 + pass
230 +
231 + return ParsedPage(
232 + url=url,
233 + canonical_url=canonical,
234 + title=title[:500],
235 + description=description[:1000],
236 + body=body,
237 + headings=headings,
238 + language=language,
239 + links=links,
240 + published_at=published,
241 + modified_at=modified,
242 + author=_clean(author)[:200] if author else None,
243 + noindex=noindex,
244 + nofollow_page=nofollow_page,
245 + structured_hints=structured_hints,
246 + )
added M2M32brouve-ka/services/ranking/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Ranking Québec-first (CLAUDE.md §9).
6 +
7 +Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité
8 +Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe.
9 +"""
10 +
11 +from .query import analyze_query, build_search_body
12 +
13 +__all__ = ["analyze_query", "build_search_body"]
added M2M32brouve-ka/services/ranking/query.py +146 −0
@@ -0,0 +1,146 @@
1 +# Trouve-KA — pipeline de requête et ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch.
6 +
7 +BM25 (multi_match bilingue) au cœur, function_score par-dessus :
8 +Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9).
9 +"""
10 +
11 +import re
12 +import unicodedata
13 +from typing import Any
14 +
15 +from trouveka.classifier import QUEBEC_TOPONYMS
16 +
17 +# Poids de ranking — à calibrer avec le dataset d'évaluation (§16)
18 +W_PAGE_QUEBEC = 1.6
19 +W_DOMAIN_QUEBEC = 1.0
20 +W_AUTHORITY = 0.8
21 +W_FRESHNESS = 0.5
22 +W_LOCALITY = 2.0
23 +
24 +_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour",
25 + "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"}
26 +_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"}
27 +
28 +
29 +def _fold(text: str) -> str:
30 + return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn")
31 +
32 +
33 +def analyze_query(q: str) -> dict[str, Any]:
34 + """Normalisation, détection de langue heuristique, extraction de lieux québécois."""
35 + normalized = re.sub(r"\s+", " ", q).strip()[:200]
36 + tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)]
37 +
38 + fr_hits = sum(1 for t in tokens if t in _FR_HINTS)
39 + en_hits = sum(1 for t in tokens if t in _EN_HINTS)
40 + has_accents = normalized != _fold(normalized)
41 + language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None)
42 +
43 + # Lieux : tokens simples + bigrammes contre le gazetteer
44 + folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS}
45 + locations: list[str] = []
46 + candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [
47 + f"{a} {b}" for a, b in zip(tokens, tokens[1:])
48 + ]
49 + for cand in candidates:
50 + folded = _fold(cand)
51 + if folded in folded_gazetteer:
52 + locations.append(folded_gazetteer[folded])
53 + return {"query": normalized, "language": language, "locations": sorted(set(locations))}
54 +
55 +
56 +def build_search_body(
57 + q: str,
58 + *,
59 + page: int = 1,
60 + limit: int = 10,
61 + language: str | None = None,
62 + category: str | None = None,
63 + quebec_only: bool = False,
64 + freshness: str | None = None,
65 +) -> tuple[dict[str, Any], dict[str, Any]]:
66 + """Construit le corps de requête OpenSearch. Retourne (body, analyse)."""
67 + analysis = analyze_query(q)
68 + normalized = analysis["query"]
69 +
70 + # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne
71 + text_query: dict[str, Any] = {
72 + "multi_match": {
73 + "query": normalized,
74 + "type": "most_fields",
75 + "fields": [
76 + "title^4", "title.en^4",
77 + "headings^2", "headings.en^2",
78 + "description^2", "description.en^2",
79 + "body", "body.en",
80 + ],
81 + "fuzziness": "AUTO",
82 + "prefix_length": 2,
83 + }
84 + }
85 +
86 + filters: list[dict[str, Any]] = []
87 + if language in ("fr", "en"):
88 + filters.append({"term": {"language": language}})
89 + if category:
90 + filters.append({"term": {"categories": category}})
91 + if quebec_only:
92 + filters.append({
93 + "bool": {
94 + "should": [
95 + {"range": {"page_quebec_score": {"gte": 0.45}}},
96 + {"range": {"domain_quebec_score": {"gte": 0.6}}},
97 + ],
98 + "minimum_should_match": 1,
99 + }
100 + })
101 + if freshness in ("day", "week", "month", "year"):
102 + filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}})
103 +
104 + functions: list[dict[str, Any]] = [
105 + {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}},
106 + {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}},
107 + {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}},
108 + {
109 + "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}},
110 + "weight": W_FRESHNESS,
111 + },
112 + ]
113 + # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite
114 + if analysis["locations"]:
115 + functions.append({
116 + "filter": {"terms": {"locations": analysis["locations"]}},
117 + "weight": W_LOCALITY,
118 + })
119 +
120 + body = {
121 + "from": max(page - 1, 0) * limit,
122 + "size": limit,
123 + "query": {
124 + "function_score": {
125 + "query": {"bool": {"must": [text_query], "filter": filters}},
126 + "functions": functions,
127 + "score_mode": "sum",
128 + "boost_mode": "sum",
129 + }
130 + },
131 + "highlight": {
132 + "pre_tags": ["<em>"],
133 + "post_tags": ["</em>"],
134 + "fields": {
135 + "body": {"fragment_size": 180, "number_of_fragments": 2},
136 + "description": {"fragment_size": 180, "number_of_fragments": 1},
137 + },
138 + "encoder": "html",
139 + },
140 + "_source": [
141 + "url", "canonical_url", "domain", "title", "description", "language",
142 + "page_quebec_score", "domain_quebec_score", "categories", "published_at",
143 + ],
144 + "track_total_hits": True,
145 + }
146 + return body, analysis
added M2M32brouve-ka/services/scheduler/__init__.py +9 −0
@@ -0,0 +1,9 @@
1 +# Trouve-KA — scheduler
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier."""
6 +
7 +from .loop import SchedulerLoop
8 +
9 +__all__ = ["SchedulerLoop"]
added M2M32brouve-ka/services/scheduler/loop.py +81 −0
@@ -0,0 +1,81 @@
1 +# Trouve-KA — boucle de maintenance
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Scheduler : tâches périodiques légères.
6 +
7 +- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13.
8 +- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés
9 + par le score Québec des domaines source) — version simple, PageRank-like plus tard.
10 +"""
11 +
12 +import asyncio
13 +import signal
14 +
15 +from trouveka.config import get_settings
16 +from trouveka.database import Database
17 +from trouveka.logging import get_logger
18 +
19 +log = get_logger("scheduler")
20 +
21 +STALE_RESET_INTERVAL = 60 # secondes
22 +AUTHORITY_INTERVAL = 15 * 60 # secondes
23 +
24 +
25 +class SchedulerLoop:
26 + def __init__(self) -> None:
27 + self.s = get_settings()
28 + self.db = Database(self.s.database_url, pool_min=1, pool_max=3)
29 + self.stop_event = asyncio.Event()
30 +
31 + async def recompute_authority(self) -> None:
32 + """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources."""
33 + await self.db.pool.execute(
34 + """
35 + WITH weighted AS (
36 + SELECT dl.to_domain_id AS id,
37 + sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w,
38 + count(DISTINCT dl.from_domain_id) AS in_domains
39 + FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id
40 + GROUP BY dl.to_domain_id
41 + )
42 + UPDATE domains SET
43 + authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)),
44 + inlink_domains = w.in_domains
45 + FROM weighted w WHERE domains.id = w.id
46 + """
47 + )
48 + log.info("autorité de domaine recalculée")
49 +
50 + async def start(self) -> None:
51 + await self.db.connect()
52 + loop = asyncio.get_running_loop()
53 + for sig in (signal.SIGINT, signal.SIGTERM):
54 + loop.add_signal_handler(sig, self.stop_event.set)
55 + log.info("scheduler démarré")
56 +
57 + elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat
58 + while not self.stop_event.is_set():
59 + try:
60 + reset = await self.db.reset_stale_items(older_than_minutes=30)
61 + if reset:
62 + log.info("items abandonnés relancés", extra={"ctx": {"count": reset}})
63 + if elapsed_authority >= AUTHORITY_INTERVAL:
64 + await self.recompute_authority()
65 + elapsed_authority = 0
66 + except Exception:
67 + log.exception("erreur scheduler (on continue)")
68 + try:
69 + await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL)
70 + except TimeoutError:
71 + pass
72 + elapsed_authority += STALE_RESET_INTERVAL
73 + await self.db.close()
74 +
75 +
76 +def main() -> None:
77 + asyncio.run(SchedulerLoop().start())
78 +
79 +
80 +if __name__ == "__main__":
81 + main()
added M2M32brouve-ka/tests/test_fetcher.py +110 −0
@@ -0,0 +1,110 @@
1 +# Trouve-KA — tests du fetcher (web de fixtures via MockTransport)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +import functools
6 +
7 +import httpx
8 +import pytest
9 +
10 +import trouveka.crawler.fetcher as fetcher_mod
11 +from trouveka.config import Settings
12 +from trouveka.crawler.fetcher import Fetcher
13 +from trouveka.shared import is_safe_url
14 +from trouveka.types import ErrorCode
15 +
16 +
17 +@pytest.fixture(autouse=True)
18 +def no_dns(monkeypatch):
19 + # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la
20 + # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution.
21 + monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False))
22 +
23 +
24 +def make_fetcher(handler, **overrides) -> Fetcher:
25 + settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides)
26 + client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
27 + return Fetcher(client, settings)
28 +
29 +
30 +async def test_simple_fetch():
31 + def handler(request):
32 + return httpx.Response(200, html="<html><title>ok</title></html>",
33 + headers={"content-type": "text/html; charset=utf-8"})
34 +
35 + result = await make_fetcher(handler).fetch("https://site.qc.ca/page")
36 + assert result.status_code == 200
37 + assert result.error_code == ErrorCode.OK
38 + assert b"ok" in result.body
39 +
40 +
41 +async def test_redirect_followed_with_ssrf_recheck():
42 + def handler(request):
43 + if request.url.path == "/a":
44 + return httpx.Response(301, headers={"location": "/b"})
45 + return httpx.Response(200, html="fini", headers={"content-type": "text/html"})
46 +
47 + result = await make_fetcher(handler).fetch("https://site.qc.ca/a")
48 + assert result.status_code == 200
49 + assert result.final_url == "https://site.qc.ca/b"
50 + assert result.redirect_chain == ["https://site.qc.ca/b"]
51 +
52 +
53 +async def test_redirect_to_private_ip_blocked():
54 + def handler(request):
55 + return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"})
56 +
57 + result = await make_fetcher(handler).fetch("https://site.qc.ca/evil")
58 + assert result.error_code == ErrorCode.SSRF_BLOCKED
59 +
60 +
61 +async def test_too_many_redirects():
62 + def handler(request):
63 + n = int(request.url.path.strip("/") or 0)
64 + return httpx.Response(301, headers={"location": f"/{n + 1}"})
65 +
66 + result = await make_fetcher(handler).fetch("https://site.qc.ca/0")
67 + assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS
68 +
69 +
70 +async def test_body_size_limit():
71 + def handler(request):
72 + return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"})
73 +
74 + result = await make_fetcher(handler).fetch("https://site.qc.ca/gros")
75 + assert result.error_code == ErrorCode.TOO_LARGE
76 +
77 +
78 +async def test_unsupported_content_type():
79 + def handler(request):
80 + return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"})
81 +
82 + result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf")
83 + assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT
84 +
85 +
86 +async def test_conditional_304():
87 + def handler(request):
88 + assert request.headers.get("if-none-match") == 'W/"abc"'
89 + return httpx.Response(304)
90 +
91 + result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"')
92 + assert result.status_code == 304
93 +
94 +
95 +async def test_4xx_and_5xx_classified():
96 + async def run(status):
97 + def handler(request):
98 + return httpx.Response(status)
99 + return await make_fetcher(handler).fetch("https://site.qc.ca/x")
100 +
101 + assert (await run(404)).error_code == ErrorCode.HTTP_4XX
102 + assert (await run(503)).error_code == ErrorCode.HTTP_5XX
103 +
104 +
105 +async def test_ssrf_blocked_upfront():
106 + def handler(request): # pragma: no cover — ne doit jamais être appelé
107 + raise AssertionError("fetch ne doit pas partir")
108 +
109 + result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin")
110 + assert result.error_code == ErrorCode.SSRF_BLOCKED
added M2M32brouve-ka/tests/test_frontier_policy.py +44 −0
@@ -0,0 +1,44 @@
1 +# Trouve-KA — tests de la politique du frontier
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay
6 +
7 +
8 +def test_seed_priority_is_max():
9 + assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0
10 +
11 +
12 +def test_priority_bounded():
13 + assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0,
14 + link_signal=1.0, is_new_domain=True) <= 1.0
15 + assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0
16 +
17 +
18 +def test_quebec_dominates_priority():
19 + high_q = compute_priority(domain_quebec_score=0.9, depth=2)
20 + low_q = compute_priority(domain_quebec_score=0.1, depth=2)
21 + assert high_q > low_q + 0.2
22 +
23 +
24 +def test_recrawl_backoff_doubles_when_unchanged():
25 + d = next_recrawl_delay(changed=False, previous_delay_hours=24.0)
26 + assert d.total_seconds() == 48 * 3600
27 +
28 +
29 +def test_recrawl_halves_when_changed():
30 + d = next_recrawl_delay(changed=True, previous_delay_hours=24.0)
31 + assert d.total_seconds() == 12 * 3600
32 +
33 +
34 +def test_recrawl_bounded():
35 + fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0)
36 + slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720)
37 + assert fast.total_seconds() == 3600
38 + assert slow.total_seconds() == 720 * 3600
39 +
40 +
41 +def test_retry_delay_grows_and_caps():
42 + assert retry_delay(0).total_seconds() == 15 * 60
43 + assert retry_delay(1).total_seconds() == 30 * 60
44 + assert retry_delay(10).total_seconds() == 24 * 3600
added M2M32brouve-ka/tests/test_parser.py +92 −0
@@ -0,0 +1,92 @@
1 +# Trouve-KA — tests d'extraction HTML
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.parser import decode_html, looks_like_garbage, parse_html
6 +
7 +FIXTURE = """<!DOCTYPE html>
8 +<html lang="fr">
9 +<head>
10 + <title>Plomberie XYZ — Plombier à Gatineau</title>
11 + <meta name="description" content="Service de plomberie en Outaouais depuis 1998.">
12 + <link rel="canonical" href="https://plomberiexyz.qc.ca/services">
13 + <meta property="article:published_time" content="2024-06-12T10:00:00-04:00">
14 + <script type="application/ld+json">
15 + {"@type": "Plumber", "name": "Plomberie XYZ",
16 + "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}}
17 + </script>
18 +</head>
19 +<body>
20 + <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav>
21 + <main>
22 + <h1>Plombier Gatineau</h1>
23 + <h2>Nos services</h2>
24 + <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p>
25 + <a href="/tarifs?utm_source=nav">Tarifs</a>
26 + <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a>
27 + <a href="mailto:info@x.ca">Courriel</a>
28 + <a href="javascript:void(0)">JS</a>
29 + </main>
30 + <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer>
31 + <script>trackingStuff();</script>
32 +</body>
33 +</html>"""
34 +
35 +
36 +def test_full_extraction():
37 + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE)
38 + assert page.title == "Plomberie XYZ — Plombier à Gatineau"
39 + assert "Outaouais" in page.description
40 + assert page.canonical_url == "https://plomberiexyz.qc.ca/services"
41 + assert page.language == "fr"
42 + assert "Plombier Gatineau" in page.headings
43 + assert page.published_at and page.published_at.year == 2024
44 + # Corps : contenu principal présent, nav/script exclus
45 + assert "Nous desservons Gatineau" in page.body
46 + assert "Menu qui ne doit pas" not in page.body
47 + assert "trackingStuff" not in page.body
48 + # JSON-LD → indices structurés
49 + assert "Gatineau" in page.structured_hints
50 +
51 +
52 +def test_links_normalized_and_flagged():
53 + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE)
54 + urls = {link.url for link in page.links}
55 + assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu
56 + nofollow = {link.url: link.nofollow for link in page.links}
57 + assert nofollow["https://autresite.qc.ca/page"] is True
58 + assert not any(u.startswith(("mailto:", "javascript:")) for u in urls)
59 +
60 +
61 +def test_noindex_detected():
62 + html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>'
63 + page = parse_html("https://x.qc.ca/", html)
64 + assert page.noindex is True
65 + assert page.nofollow_page is True
66 +
67 +
68 +def test_garbage_html_does_not_crash():
69 + page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>")
70 + assert page.url == "https://x.qc.ca/"
71 +
72 +
73 +def test_decode_latin1_quebec_site():
74 + # Vieux site québécois en ISO-8859-1 : les accents doivent survivre
75 + body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252")
76 + assert "Éducation à Québec" in decode_html(body)
77 + page = parse_html("https://vieux-site.qc.ca/", body)
78 + assert page.title == "Éducation à Québec"
79 +
80 +
81 +def test_decode_http_charset_priority():
82 + body = "<html><title>Montréal</title></html>".encode("cp1252")
83 + assert "Montréal" in decode_html(body, "iso-8859-1")
84 +
85 +
86 +def test_binary_garbage_detected():
87 + # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia
88 + binary = bytes(range(256)) * 40
89 + text = binary.decode("utf-8", errors="replace")
90 + assert looks_like_garbage(text)
91 + assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.")
92 + assert not looks_like_garbage("")
added M2M32brouve-ka/tests/test_quebec_scoring.py +88 −0
@@ -0,0 +1,88 @@
1 +# Trouve-KA — tests du scoring Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.classifier import score_page
6 +from trouveka.types import ParsedPage
7 +
8 +
9 +def _page(**kwargs) -> ParsedPage:
10 + defaults = {"url": "https://example.com/page", "title": "", "body": ""}
11 + return ParsedPage(**(defaults | kwargs))
12 +
13 +
14 +def test_quebec_business_scores_high():
15 + page = _page(
16 + url="https://plomberiexyz.qc.ca/services",
17 + title="Plomberie XYZ — Plombier à Gatineau",
18 + description="Service de plomberie résidentielle en Outaouais",
19 + body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. "
20 + "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.",
21 + headings=["Plombier Gatineau", "Nos services"],
22 + language="fr",
23 + )
24 + signals = score_page(page, "plomberiexyz.qc.ca")
25 + assert signals.score > 0.7
26 + assert "gatineau" in signals.locations
27 + assert "tld_quebec" in signals.reasons
28 + assert "code_postal_qc" in signals.reasons
29 +
30 +
31 +def test_ontario_business_scores_low():
32 + page = _page(
33 + url="https://ottawaplumbing.ca/",
34 + title="Ottawa Plumbing Services",
35 + description="Plumbing services in Ottawa, Ontario",
36 + body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.",
37 + headings=["Plumbing Ottawa"],
38 + language="en",
39 + )
40 + signals = score_page(page, "ottawaplumbing.ca")
41 + assert signals.score < 0.2
42 +
43 +
44 +def test_nyt_article_about_montreal_gets_page_signal():
45 + # Un article international SUR Montréal : le domaine n'est pas québécois,
46 + # mais la page a un signal réel (page_quebec_score > 0, sans être maximal).
47 + page = _page(
48 + url="https://www.nytimes.com/2026/01/01/travel/montreal.html",
49 + title="36 Hours in Montreal",
50 + description="What to do in Montreal, Quebec's largest city",
51 + body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, "
52 + "the city offers poutine, festivals and more. Nearby Laval and Longueuil...",
53 + headings=["36 Hours in Montreal"],
54 + language="en",
55 + )
56 + signals = score_page(page, "nytimes.com")
57 + assert 0.2 < signals.score < 0.8
58 + assert "montreal" in signals.locations
59 +
60 +
61 +def test_generic_english_page_scores_near_zero():
62 + page = _page(
63 + url="https://techblog.com/post",
64 + title="Understanding Rust lifetimes",
65 + body="Rust lifetimes are a way to express the scope of references in your program. " * 20,
66 + language="en",
67 + )
68 + signals = score_page(page, "techblog.com")
69 + assert signals.score < 0.1
70 +
71 +
72 +def test_french_alone_is_not_proof():
73 + # Une page française de France ne doit pas passer le seuil sur la langue seule
74 + page = _page(
75 + url="https://lemonde.fr/article",
76 + title="Actualités françaises",
77 + body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10,
78 + language="fr",
79 + )
80 + signals = score_page(page, "lemonde.fr")
81 + assert signals.score < 0.3
82 +
83 +
84 +def test_score_saturates_below_one():
85 + body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec"
86 + page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr")
87 + signals = score_page(page, "x.qc.ca")
88 + assert 0.8 < signals.score <= 1.0
added M2M32brouve-ka/tests/test_ranking.py +41 −0
@@ -0,0 +1,41 @@
1 +# Trouve-KA — tests du pipeline de requête et du ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.ranking import analyze_query, build_search_body
6 +
7 +
8 +def test_language_detection_heuristics():
9 + assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr"
10 + assert analyze_query("best heat pump rebate program")["language"] == "en"
11 +
12 +
13 +def test_location_extraction_with_accents():
14 + a = analyze_query("plombier gatineau")
15 + assert "gatineau" in a["locations"]
16 + b = analyze_query("subvention Trois-Rivières")
17 + assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"])
18 +
19 +
20 +def test_body_structure_bm25_core():
21 + body, analysis = build_search_body("plombier Gatineau", page=2, limit=10)
22 + assert body["from"] == 10 and body["size"] == 10
23 + fs = body["query"]["function_score"]
24 + fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"]
25 + assert "title^4" in fields and "body" in fields and "body.en" in fields
26 + # Localité : fonction de boost présente quand un lieu est détecté
27 + locality = [f for f in fs["functions"] if "filter" in f]
28 + assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"]
29 +
30 +
31 +def test_filters():
32 + body, _ = build_search_body("test", language="fr", category="government", quebec_only=True)
33 + filters = body["query"]["function_score"]["query"]["bool"]["filter"]
34 + assert {"term": {"language": "fr"}} in filters
35 + assert {"term": {"categories": "government"}} in filters
36 + assert any("bool" in f for f in filters) # quebec_only
37 +
38 +
39 +def test_no_locality_function_without_location():
40 + body, _ = build_search_body("recette de tourtière")
41 + assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f]
added M2M32brouve-ka/tests/test_ssrf.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — tests de prévention SSRF
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.shared import is_safe_ip, is_safe_url
6 +
7 +
8 +def test_blocks_loopback_and_private():
9 + for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"):
10 + assert not is_safe_ip(ip), ip
11 +
12 +
13 +def test_blocks_link_local_and_metadata():
14 + assert not is_safe_ip("169.254.169.254")
15 + assert not is_safe_ip("169.254.1.1")
16 + assert not is_safe_ip("fe80::1")
17 +
18 +
19 +def test_allows_public_ips():
20 + assert is_safe_ip("142.226.10.10")
21 + assert is_safe_ip("2607:f8b0::1")
22 +
23 +
24 +def test_blocks_localhost_and_schemes():
25 + assert not is_safe_url("http://localhost/admin", resolve=False)
26 + assert not is_safe_url("http://foo.localhost/x", resolve=False)
27 + assert not is_safe_url("http://metadata.google.internal/", resolve=False)
28 + assert not is_safe_url("file:///etc/passwd", resolve=False)
29 + assert not is_safe_url("gopher://x.ca", resolve=False)
30 +
31 +
32 +def test_blocks_ip_literal_urls():
33 + assert not is_safe_url("http://127.0.0.1:8080/", resolve=False)
34 + assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False)
35 + assert not is_safe_url("http://[::1]/", resolve=False)
36 +
37 +
38 +def test_allows_normal_url_without_resolution():
39 + assert is_safe_url("https://www.quebec.ca/", resolve=False)
added M2M32brouve-ka/tests/test_traps.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — tests de détection de pièges
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.crawler.traps import looks_like_trap
6 +
7 +
8 +def test_session_ids():
9 + assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123")
10 + assert looks_like_trap("https://x.ca/p?sid=9f8e7d")
11 +
12 +
13 +def test_too_many_params():
14 + url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12))
15 + assert looks_like_trap(url)
16 +
17 +
18 +def test_repeated_facet_param():
19 + assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e")
20 +
21 +
22 +def test_deep_paths_and_loops():
23 + assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15))
24 + assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b")
25 +
26 +
27 +def test_far_future_calendar():
28 + assert looks_like_trap("https://x.ca/events/2085/05/17")
29 + assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12")
30 +
31 +
32 +def test_excessive_pagination():
33 + assert looks_like_trap("https://x.ca/liste?page=9999")
34 + assert not looks_like_trap("https://x.ca/liste?page=3")
35 +
36 +
37 +def test_normal_urls_pass():
38 + assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire")
39 + assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre")
added M2M32brouve-ka/tests/test_urls.py +53 −0
@@ -0,0 +1,53 @@
1 +# Trouve-KA — tests de canonicalisation d'URL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.shared import canonicalize_url, display_url, extract_domain
6 +
7 +
8 +def test_removes_fragment_and_default_port():
9 + assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services"
10 + assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/"
11 +
12 +
13 +def test_preserves_non_default_port():
14 + assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x"
15 +
16 +
17 +def test_lowercases_host_but_preserves_path_case():
18 + assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF"
19 +
20 +
21 +def test_strips_tracking_params_only():
22 + url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1"
23 + assert canonicalize_url(url) == "https://ledevoir.com/a?id=42"
24 +
25 +
26 +def test_preserves_unknown_params_and_order():
27 + # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé
28 + assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1"
29 + assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2"
30 +
31 +
32 +def test_rejects_non_http_schemes():
33 + assert canonicalize_url("file:///etc/passwd") is None
34 + assert canonicalize_url("javascript:alert(1)") is None
35 + assert canonicalize_url("ftp://x.ca/f") is None
36 +
37 +
38 +def test_relative_resolution():
39 + assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b"
40 +
41 +
42 +def test_collapses_duplicate_slashes():
43 + assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b"
44 +
45 +
46 +def test_extract_domain_strips_www():
47 + assert extract_domain("https://www.quebec.ca/services") == "quebec.ca"
48 + assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca"
49 +
50 +
51 +def test_display_url_breadcrumb():
52 + crumb = display_url("https://www.quebec.ca/services/permis/conduire")
53 + assert crumb.startswith("quebec.ca › services › permis")
added M2M32crouve-ka/.env.example +34 −0
@@ -0,0 +1,34 @@
1 +# Trouve-KA — variables d'environnement (exemple, sans secrets)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +# --- Bases de données ---
6 +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka
7 +REDIS_URL=redis://localhost:6379/0
8 +SEARCH_URL=http://localhost:9200
9 +
10 +# --- Crawler ---
11 +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)"
12 +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot
13 +MAX_GLOBAL_CONCURRENCY=24
14 +MAX_PER_HOST_CONCURRENCY=2
15 +DEFAULT_HOST_DELAY=2.0
16 +MAX_RESPONSE_BYTES=3000000
17 +MAX_REDIRECTS=5
18 +FETCH_TIMEOUT=20
19 +MAX_CRAWL_DEPTH=8
20 +MAX_LINKS_PER_PAGE=300
21 +MAX_URLS_PER_DOMAIN=5000
22 +
23 +# --- API ---
24 +API_HOST=0.0.0.0
25 +API_PORT=8080
26 +ADMIN_TOKEN=change-me-admin-token
27 +
28 +# --- Web ---
29 +PUBLIC_URL=https://www.trouve-ka.com
30 +NGROK_DOMAIN=www.trouve-ka.com
31 +API_URL=http://localhost:8080
32 +
33 +# --- Index ---
34 +SEARCH_INDEX=trouveka-docs
added M2M32crouve-ka/.gitignore +13 −0
@@ -0,0 +1,13 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +__pycache__/
5 +*.pyc
6 +.venv/
7 +.env
8 +.env.local
9 +*.egg-info/
10 +.pytest_cache/
11 +.ruff_cache/
12 +data/
13 +.DS_Store
added M2M32crouve-ka/CLAUDE.md +363 −0
@@ -0,0 +1,363 @@
1 +# CLAUDE.md — Trouve-KA
2 +
3 +**Projet :** Trouve-KA — *Cherche le Québec.*
4 +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique.
5 +
6 +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois.
7 +
8 +```
9 +Crawler → Frontier → Fetcher → Parser → Classification Québec
10 +→ Déduplication → Indexer → Index → Ranking → API → Web App
11 +```
12 +
13 +Le crawler fait partie du produit. Tout vit dans un seul monorepo.
14 +
15 +---
16 +
17 +## 0. Règles non négociables
18 +
19 +### 0.1 Header obligatoire dans chaque fichier de code
20 +
21 +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur :
22 +
23 +```ts
24 +/**
25 + * Trouve-KA — <nom du module>
26 + * Author: Simon-Pierre Boucher
27 + * Contact: contact@spboucher.ai
28 + */
29 +```
30 +
31 +```python
32 +# Trouve-KA — <nom du module>
33 +# Author: Simon-Pierre Boucher
34 +# Contact: contact@spboucher.ai
35 +```
36 +
37 +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header.
38 +
39 +### 0.2 Cible de déploiement
40 +
41 +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine.
42 +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`).
43 +- Docker Compose est l'outil de déploiement. Pas de Kubernetes.
44 +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README.
45 +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement.
46 +
47 +### 0.3 Principe cardinal
48 +
49 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.**
50 +
51 +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes.
52 +
53 +```
54 +12:00:00 démarrage crawler
55 +12:00:04 première page fetchée
56 +12:00:06 score Québec calculé
57 +12:00:07 document indexé
58 +12:00:08 cherchable par l'utilisateur
59 +```
60 +
61 +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup).
62 +
63 +---
64 +
65 +## 1. Philosophie produit
66 +
67 +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique.
68 +
69 +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu.
70 +
71 +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local.
72 +
73 +---
74 +
75 +## 2. Architecture du dépôt
76 +
77 +Monorepo :
78 +
79 +```
80 +trouve-ka/
81 +├── apps/
82 +│ ├── web/ # moteur public
83 +│ ├── api/ # search + APIs internes
84 +│ └── admin/ # dashboard crawl/index
85 +├── services/
86 +│ ├── crawler/ frontier/ parser/ classifier/
87 +│ ├── indexer/ ranking/ scheduler/ enrichment/
88 +├── packages/
89 +│ ├── database/ shared/ config/ logging/
90 +│ ├── queue/ types/ search-core/
91 +├── infrastructure/
92 +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32
93 +├── scripts/
94 +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/
95 +├── docs/
96 +├── CLAUDE.md README.md docker-compose.yml
97 +```
98 +
99 +Claude peut améliorer cette structure sur justification technique solide.
100 +
101 +---
102 +
103 +## 3. Stack technologique
104 +
105 +| Couche | Choix |
106 +|---|---|
107 +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui |
108 +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS |
109 +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) |
110 +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) |
111 +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit |
112 +
113 +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut.
114 +
115 +---
116 +
117 +## 4. Qualité de recherche progressive
118 +
119 +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.**
120 +
121 +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25.
122 +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique.
123 +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale.
124 +
125 +---
126 +
127 +## 5. Crawler
128 +
129 +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements.
130 +
131 +### 5.1 Identité
132 +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli.
133 +
134 +### 5.2 robots.txt et politesse
135 +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel.
136 +
137 +### 5.3 Frontier
138 +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication.
139 +
140 +Fonction de priorité conceptuelle :
141 +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S`
142 +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés.
143 +
144 +### 5.4 Canonicalisation d'URL
145 +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.**
146 +
147 +### 5.5 Recrawl adaptatif
148 +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓.
149 +
150 +### 5.6 Détection de changement et doublons
151 +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance.
152 +
153 +### 5.7 Sécurité du crawl (SSRF)
154 +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max.
155 +
156 +### 5.8 Pièges de crawl
157 +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine.
158 +
159 +### 5.9 Erreurs
160 +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué.
161 +
162 +### 5.10 Rendu navigateur
163 +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32.
164 +
165 +---
166 +
167 +## 6. Extraction de contenu
168 +
169 +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse.
170 +
171 +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub.
172 +
173 +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML.
174 +
175 +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants.
176 +
177 +---
178 +
179 +## 7. Détection Québec
180 +
181 +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** :
182 +
183 +- `domain_quebec_score`
184 +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit)
185 +
186 +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer.
187 +
188 +---
189 +
190 +## 8. Découverte
191 +
192 +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises.
193 +
194 +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle.
195 +
196 +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…`
197 +
198 +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA.
199 +
200 +---
201 +
202 +## 9. Index et ranking
203 +
204 +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`.
205 +
206 +**BM25 d'abord.** Puis fonction de ranking dédiée :
207 +
208 +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam`
209 +
210 +Le ranking est un package/service indépendant, évolutif, aux poids non figés.
211 +
212 +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale.
213 +
214 +**Bilingue dès le départ :** comprendre `thermopompe``heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes.
215 +
216 +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.**
217 +
218 +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching.
219 +
220 +---
221 +
222 +## 10. Interface
223 +
224 +**Homepage = la boîte de recherche.** Rien d'autre d'important.
225 +
226 +```
227 + Trouve-KA
228 + [ Rechercher... ]
229 + Chercher
230 + Cherche le Québec.
231 +```
232 +
233 +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement.
234 +
235 +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index.
236 +
237 +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl.
238 +
239 +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA.
240 +
241 +---
242 +
243 +## 11. APIs
244 +
245 +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod.
246 +
247 +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine.
248 +
249 +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide.
250 +
251 +---
252 +
253 +## 12. Coûts, LLM et embeddings
254 +
255 +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants.
256 +
257 +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres.
258 +
259 +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs.
260 +
261 +---
262 +
263 +## 13. Observabilité et dégradation
264 +
265 +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique).
266 +
267 +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue.
268 +
269 +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ.
270 +
271 +---
272 +
273 +## 14. Provenance, droit d'auteur, avenir
274 +
275 +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu.
276 +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément.
277 +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives.
278 +
279 +---
280 +
281 +## 15. Configuration et DX
282 +
283 +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets.
284 +
285 +Expérience développeur cible :
286 +
287 +```
288 +git clone … && cd trouve-ka
289 +cp .env.example .env
290 +docker compose up -d
291 +pnpm install && pnpm dev
292 +pnpm crawl:seed
293 +# → des résultats apparaissent en quelques instants sur localhost:3000
294 +```
295 +
296 +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté.
297 +
298 +---
299 +
300 +## 16. Tests et évaluation
301 +
302 +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche.
303 +
304 +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche.
305 +
306 +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions.
307 +
308 +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée.
309 +
310 +---
311 +
312 +## 17. Ordre d'implémentation
313 +
314 +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique.
315 +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.**
316 +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots.
317 +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines.
318 +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête.
319 +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring.
320 +7. **Couche sémantique** — embeddings, hybride, reranking, entités.
321 +8. **Écosystème KA.**
322 +
323 +---
324 +
325 +## 18. Milestone critique (non négociable)
326 +
327 +La première implémentation n'est réussie que si Claude démontre :
328 +
329 +1. démarrage du stack;
330 +2. seed de sites québécois;
331 +3. le crawler découvre des URLs;
332 +4. télécharge des pages;
333 +5. le parser extrait du texte utile;
334 +6. les pages reçoivent un score Québec;
335 +7. insertion **immédiate** dans l'index;
336 +8. l'app web publique cherche ces pages;
337 +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**;
338 +10. le frontier continue de découvrir des sites.
339 +
340 +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert.
341 +
342 +---
343 +
344 +## 19. Méthode de travail de Claude
345 +
346 +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative).
347 +
348 +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant.
349 +
350 +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1).
351 +
352 +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid.
353 +
354 +---
355 +
356 +## 20. Vision
357 +
358 +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**.
359 +
360 +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.**
361 +
362 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
363 +> Le web ne « finit » jamais. Trouve-KA non plus.
added M2M32crouve-ka/README.md +166 −0
@@ -0,0 +1,166 @@
1 +# Trouve-KA — Cherche le Québec.
2 +
3 +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre
4 +index, son propre ranking, son API et son application web publique. Pas un métamoteur :
5 +aucune dépendance à Google, Bing ou Brave pour les résultats.
6 +
7 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
8 +
9 +En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok).
10 +
11 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
12 +
13 +---
14 +
15 +## Ce que c'est
16 +
17 +```
18 +Crawler → Frontier → Fetcher → Parser → Classification Québec
19 +→ Déduplication → Indexer → Index → Ranking → API → Web App
20 +```
21 +
22 +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement,
23 +municipalités, universités, médias), suit les liens sortants, juge la pertinence
24 +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`),
25 +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes,
26 +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités,
27 +embeddings) arrive après, en asynchrone, sans jamais bloquer.
28 +
29 +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et
30 +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres
31 +comme frontier, etc.).
32 +
33 +## Stack
34 +
35 +| Couche | Choix |
36 +|---|---|
37 +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) |
38 +| API | FastAPI (Python 3.12) |
39 +| Pipeline | Python 3.12 async — httpx, selectolax, Protego |
40 +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) |
41 +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) |
42 +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) |
43 +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) |
44 +
45 +## Layout du monorepo
46 +
47 +```
48 +apps/web # moteur public + dashboard /admin (Next.js)
49 +apps/api # FastAPI (trouveka.api)
50 +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment
51 +packages/ # config, database, logging, queue, search-core, shared, types
52 +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok)
53 +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py
54 +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking
55 +```
56 +
57 +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout
58 +(voir `pyproject.toml`).
59 +
60 +## Démarrage rapide (dev)
61 +
62 +```bash
63 +git clone && cd trouve-ka
64 +cp .env.example .env
65 +docker compose up -d postgres redis opensearch # infra
66 +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python
67 +pnpm install
68 +
69 +pnpm crawl:seed # migrations + 60+ seeds québécoises
70 +bash scripts/start-crawler/start.sh & # le crawl démarre
71 +.venv/bin/uvicorn trouveka.api.main:app --port 8080 &
72 +pnpm dev # → http://localhost:3000
73 +# → des résultats apparaissent en quelques secondes
74 +```
75 +
76 +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`,
77 +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes).
78 +
79 +Stack complet en containers : `docker compose up -d --build` (le service `migrate`
80 +applique les migrations, `crawler-worker` se scale avec
81 +`docker compose up -d --scale crawler-worker=3`).
82 +
83 +## Comment ça marche
84 +
85 +### Crawl et politesse
86 +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`,
87 + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth.
88 +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés.
89 +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté),
90 + quel que soit le nombre de workers.
91 +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque
92 + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges
93 + (session IDs, calendriers infinis, facettes explosives, pagination sans fin).
94 +
95 +### Indexation incrémentale
96 +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté
97 +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu +
98 +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2).
99 +
100 +### Détection Québec
101 +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec),
102 +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes
103 +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ,
104 +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve).
105 +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12).
106 +
107 +### Ranking
108 +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes
109 +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine
110 +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents
111 +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul.
112 +
113 +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080`
114 +(dataset dans `scripts/eval/ranking-eval.yaml`).
115 +
116 +## API
117 +
118 +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=`
119 +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler)
120 +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion)
121 +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live,
122 + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier
123 +
124 +## Tests
125 +
126 +```bash
127 +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking
128 +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI)
129 +bash scripts/health-check/check.sh # santé du stack
130 +```
131 +
132 +## Déploiement m2m32 + ngrok (www.trouve-ka.com)
133 +
134 +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié,
135 +domaine `www.trouve-ka.com` réservé dans le compte ngrok.
136 +
137 +```bash
138 +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh
139 +```
140 +
141 +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds
142 +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check.
143 +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues
144 +côté client, cookies/CORS sans surprise derrière le tunnel.
145 +
146 +Opérations courantes sur le node :
147 +
148 +```bash
149 +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct
150 +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3'
151 +```
152 +
153 +## Observabilité
154 +
155 +Logs JSON structurés par service, métriques réelles via `/api/status` et
156 +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences
157 +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md).
158 +
159 +## Règles du dépôt
160 +
161 +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par
162 + `scripts/check-headers.py`.
163 +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés;
164 + les fixtures vivent dans `tests/` uniquement.
165 +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source.
166 + Trouve-KA renvoie vers les éditeurs originaux.
added M2M32crouve-ka/apps/api/__init__.py +5 −0
@@ -0,0 +1,5 @@
1 +# Trouve-KA — application API
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée)."""
added M2M32crouve-ka/apps/api/main.py +297 −0
@@ -0,0 +1,297 @@
1 +# Trouve-KA — API FastAPI
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API de Trouve-KA.
6 +
7 +Publique : /api/search, /api/status, /api/submit, /api/health
8 +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth.
9 +
10 +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même;
11 +si Postgres tombe, la recherche répond quand même (analytics sautées).
12 +"""
13 +
14 +import html
15 +import re
16 +import time
17 +from contextlib import asynccontextmanager
18 +from typing import Annotated
19 +
20 +from fastapi import Depends, FastAPI, Header, HTTPException, Query
21 +from fastapi.middleware.cors import CORSMiddleware
22 +from pydantic import BaseModel
23 +
24 +from trouveka.config import get_settings
25 +from trouveka.database import Database
26 +from trouveka.logging import get_logger
27 +from trouveka.queue import Coordination
28 +from trouveka.ranking import build_search_body
29 +from trouveka.search_core import SearchCore
30 +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url
31 +
32 +log = get_logger("api")
33 +settings = get_settings()
34 +
35 +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max)
36 +coord = Coordination(settings.redis_url)
37 +search = SearchCore(settings.search_url, settings.search_index)
38 +
39 +
40 +@asynccontextmanager
41 +async def lifespan(_app: FastAPI):
42 + await db.connect()
43 + await search.ensure_index()
44 + yield
45 + await search.close()
46 + await coord.close()
47 + await db.close()
48 +
49 +
50 +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan)
51 +
52 +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile
53 +# en prod, mais pratique en dev local (web sur :3000, API sur :8080).
54 +app.add_middleware(
55 + CORSMiddleware,
56 + allow_origins=["http://localhost:3000", settings.public_url],
57 + allow_methods=["GET", "POST"],
58 + allow_headers=["*", "X-Admin-Token"],
59 +)
60 +
61 +
62 +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None:
63 + if not x_admin_token or x_admin_token != settings.admin_token:
64 + raise HTTPException(status_code=401, detail="Jeton admin invalide")
65 +
66 +
67 +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>")
68 +
69 +
70 +def _safe_snippet(fragments: list[str]) -> str:
71 + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch."""
72 + return _TAG_RE.sub("", " … ".join(fragments))[:400]
73 +
74 +
75 +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"}
76 +
77 +
78 +# ---------------------------------------------------------------------- publique
79 +
80 +@app.get("/api/health")
81 +async def health():
82 + return {"ok": True, "search_ok": await search.ping()}
83 +
84 +
85 +@app.get("/api/search")
86 +async def api_search(
87 + q: str = Query(..., min_length=1, max_length=200),
88 + page: int = Query(1, ge=1, le=100),
89 + limit: int = Query(10, ge=1, le=50),
90 + language: str | None = Query(None, pattern="^(fr|en)$"),
91 + location: str | None = None,
92 + category: str | None = Query(None, max_length=40),
93 + quebec_only: bool = False,
94 + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"),
95 +):
96 + started = time.monotonic()
97 + query_text = f"{q} {location}" if location else q
98 + body, analysis = build_search_body(
99 + query_text, page=page, limit=limit, language=language,
100 + category=category, quebec_only=quebec_only, freshness=freshness,
101 + )
102 + try:
103 + res = await search.search(body)
104 + except Exception:
105 + log.exception("recherche échouée", extra={"ctx": {"q": q}})
106 + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible")
107 +
108 + took_ms = int((time.monotonic() - started) * 1000)
109 + results = []
110 + for hit in res["hits"]["hits"]:
111 + src = hit["_source"]
112 + highlight = hit.get("highlight", {})
113 + fragments = highlight.get("body") or highlight.get("description") or []
114 + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400]
115 + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS]
116 + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6:
117 + badges.insert(0, "Québec")
118 + results.append({
119 + "title": src.get("title") or src["url"],
120 + "url": src["url"],
121 + "display_url": display_url(src["url"]),
122 + "snippet": snippet,
123 + "domain": src["domain"],
124 + "language": src.get("language"),
125 + "quebec_score": src.get("page_quebec_score", 0),
126 + "badges": badges,
127 + "published_at": src.get("published_at"),
128 + })
129 +
130 + total = res["hits"]["total"]["value"]
131 + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes
132 + try:
133 + await db.record_search_query(q, analysis["language"], total, took_ms)
134 + except Exception:
135 + log.exception("analytics de recherche sautées")
136 +
137 + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results}
138 +
139 +
140 +@app.get("/api/status")
141 +async def api_status():
142 + snapshot: dict = {}
143 + try:
144 + snapshot = await db.status_snapshot()
145 + except Exception:
146 + log.exception("statut PG indisponible")
147 + search_ok = await search.ping()
148 + try:
149 + paused = await coord.is_paused()
150 + except Exception:
151 + paused = False
152 + return {
153 + "pages_indexed": snapshot.get("pages_indexed", 0),
154 + "domains_count": snapshot.get("domains_count", 0),
155 + "indexed_last_hour": snapshot.get("indexed_last_hour", 0),
156 + "fetched_last_hour": snapshot.get("fetched_last_hour", 0),
157 + "errors_last_hour": snapshot.get("errors_last_hour", 0),
158 + "frontier_pending": snapshot.get("frontier_pending", 0),
159 + "frontier_in_progress": snapshot.get("frontier_in_progress", 0),
160 + "crawler_state": "paused" if paused else "running",
161 + "search_ok": search_ok,
162 + }
163 +
164 +
165 +@app.get("/api/live")
166 +async def api_live():
167 + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer.
168 +
169 + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne.
170 + """
171 + try:
172 + events = await db.recent_events(1)
173 + except Exception:
174 + return {"event": None}
175 + if not events:
176 + return {"event": None}
177 + e = events[0]
178 + return {
179 + "event": {
180 + "at": e["at"],
181 + "url": e["url"],
182 + "domain": extract_domain(e["url"]),
183 + "outcome": e["outcome"],
184 + }
185 + }
186 +
187 +
188 +class SubmitBody(BaseModel):
189 + url: str
190 +
191 +
192 +@app.post("/api/submit")
193 +async def api_submit(payload: SubmitBody):
194 + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide."""
195 + if not is_http_url(payload.url):
196 + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)")
197 + url = canonicalize_url(payload.url)
198 + domain = extract_domain(url) if url else None
199 + if not url or not domain:
200 + raise HTTPException(status_code=422, detail="URL invalide")
201 + await db.add_submission(url)
202 + await db.enqueue_url(url, domain, priority=0.7, depth=0)
203 + return {
204 + "accepted": True,
205 + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.",
206 + }
207 +
208 +
209 +# ---------------------------------------------------------------------- admin
210 +
211 +class SeedsBody(BaseModel):
212 + urls: list[str]
213 +
214 +
215 +class RecrawlBody(BaseModel):
216 + url: str | None = None
217 + domain: str | None = None
218 +
219 +
220 +class DomainBody(BaseModel):
221 + domain: str
222 +
223 +
224 +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)])
225 +async def admin_overview():
226 + overview = await db.admin_overview()
227 + overview["paused"] = await coord.is_paused()
228 + try:
229 + overview["enrich_backlog"] = await coord.enrich_backlog()
230 + except Exception:
231 + overview["enrich_backlog"] = None
232 + return overview
233 +
234 +
235 +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)])
236 +async def admin_recent(limit: int = Query(50, ge=1, le=200)):
237 + return {"events": await db.recent_events(limit)}
238 +
239 +
240 +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)])
241 +async def admin_pause():
242 + await coord.pause_crawler()
243 + return {"paused": True}
244 +
245 +
246 +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)])
247 +async def admin_resume():
248 + await coord.resume_crawler()
249 + return {"paused": False}
250 +
251 +
252 +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)])
253 +async def admin_seeds(payload: SeedsBody):
254 + added = 0
255 + for raw in payload.urls[:500]:
256 + url = canonicalize_url(raw.strip())
257 + domain = extract_domain(url) if url else None
258 + if url and domain:
259 + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):
260 + added += 1
261 + return {"added": added}
262 +
263 +
264 +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)])
265 +async def admin_recrawl(payload: RecrawlBody):
266 + if payload.url:
267 + url = canonicalize_url(payload.url)
268 + ok = await db.requeue_url(url) if url else False
269 + return {"requeued": 1 if ok else 0}
270 + if payload.domain:
271 + return {"requeued": await db.requeue_domain(payload.domain.lower())}
272 + raise HTTPException(status_code=422, detail="url ou domain requis")
273 +
274 +
275 +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)])
276 +async def admin_block_domain(payload: DomainBody):
277 + await db.block_domain(payload.domain.lower())
278 + return {"blocked": payload.domain.lower()}
279 +
280 +
281 +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)])
282 +async def admin_frontier(
283 + domain: str | None = None,
284 + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"),
285 + limit: int = Query(100, ge=1, le=500),
286 +):
287 + return {"items": await db.frontier_inspect(domain, status, limit)}
288 +
289 +
290 +def main() -> None:
291 + import uvicorn
292 +
293 + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1)
294 +
295 +
296 +if __name__ == "__main__":
297 + main()
added M2M32crouve-ka/docker-compose.yml +149 −0
@@ -0,0 +1,149 @@
1 +# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +#
5 +# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger,
6 +# workers scalables via `docker compose up -d --scale crawler-worker=3`.
7 +
8 +services:
9 + postgres:
10 + image: postgres:16-alpine
11 + environment:
12 + POSTGRES_USER: trouveka
13 + POSTGRES_PASSWORD: trouveka
14 + POSTGRES_DB: trouveka
15 + ports:
16 + - "${PG_PORT:-5432}:5432"
17 + volumes:
18 + - pgdata:/var/lib/postgresql/data
19 + healthcheck:
20 + test: ["CMD-SHELL", "pg_isready -U trouveka"]
21 + interval: 5s
22 + timeout: 3s
23 + retries: 20
24 +
25 + redis:
26 + image: redis:7-alpine
27 + ports:
28 + - "${REDIS_PORT:-6379}:6379"
29 + volumes:
30 + - redisdata:/data
31 + command: ["redis-server", "--appendonly", "yes"]
32 + healthcheck:
33 + test: ["CMD", "redis-cli", "ping"]
34 + interval: 5s
35 + timeout: 3s
36 + retries: 20
37 +
38 + opensearch:
39 + image: opensearchproject/opensearch:2.17.1
40 + environment:
41 + discovery.type: single-node
42 + plugins.security.disabled: "true"
43 + OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g"
44 + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1"
45 + bootstrap.memory_lock: "true"
46 + ulimits:
47 + memlock:
48 + soft: -1
49 + hard: -1
50 + ports:
51 + - "${SEARCH_PORT:-9200}:9200"
52 + volumes:
53 + - osdata:/usr/share/opensearch/data
54 + healthcheck:
55 + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
56 + interval: 10s
57 + timeout: 5s
58 + retries: 30
59 +
60 + api:
61 + build:
62 + context: .
63 + dockerfile: infrastructure/docker/backend.Dockerfile
64 + command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"]
65 + environment: &backend_env
66 + DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka
67 + REDIS_URL: redis://redis:6379/0
68 + SEARCH_URL: http://opensearch:9200
69 + ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token}
70 + PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com}
71 + MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24}
72 + DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0}
73 + MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000}
74 + ports:
75 + - "8080:8080"
76 + depends_on:
77 + postgres:
78 + condition: service_healthy
79 + redis:
80 + condition: service_healthy
81 + opensearch:
82 + condition: service_healthy
83 + restart: unless-stopped
84 +
85 + migrate:
86 + build:
87 + context: .
88 + dockerfile: infrastructure/docker/backend.Dockerfile
89 + command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"]
90 + environment: *backend_env
91 + depends_on:
92 + postgres:
93 + condition: service_healthy
94 + restart: "no"
95 +
96 + crawler-worker:
97 + build:
98 + context: .
99 + dockerfile: infrastructure/docker/backend.Dockerfile
100 + command: ["python", "-m", "trouveka.crawler.worker"]
101 + environment: *backend_env
102 + depends_on:
103 + migrate:
104 + condition: service_completed_successfully
105 + opensearch:
106 + condition: service_healthy
107 + redis:
108 + condition: service_healthy
109 + restart: unless-stopped
110 +
111 + enrichment-worker:
112 + build:
113 + context: .
114 + dockerfile: infrastructure/docker/backend.Dockerfile
115 + command: ["python", "-m", "trouveka.enrichment.worker"]
116 + environment: *backend_env
117 + depends_on:
118 + migrate:
119 + condition: service_completed_successfully
120 + restart: unless-stopped
121 +
122 + scheduler:
123 + build:
124 + context: .
125 + dockerfile: infrastructure/docker/backend.Dockerfile
126 + command: ["python", "-m", "trouveka.scheduler.loop"]
127 + environment: *backend_env
128 + depends_on:
129 + migrate:
130 + condition: service_completed_successfully
131 + restart: unless-stopped
132 +
133 + web:
134 + build:
135 + context: .
136 + dockerfile: infrastructure/docker/web.Dockerfile
137 + environment:
138 + API_URL: http://api:8080
139 + PORT: "3000"
140 + ports:
141 + - "3000:3000"
142 + depends_on:
143 + - api
144 + restart: unless-stopped
145 +
146 +volumes:
147 + pgdata:
148 + redisdata:
149 + osdata:
added M2M32crouve-ka/docs/architecture.md +106 −0
@@ -0,0 +1,106 @@
1 +# Trouve-KA — Architecture
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## Vue d'ensemble
6 +
7 +```mermaid
8 +flowchart LR
9 + subgraph Découverte
10 + SEEDS[Seeds §8] --> FRONTIER
11 + SUBMIT[Soumissions /soumettre] --> FRONTIER
12 + LINKS[Liens sortants] --> FRONTIER
13 + end
14 + FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N]
15 + CW -->|politesse SET NX PX| REDIS[(Redis)]
16 + CW --> ROBOTS[robots.txt cache]
17 + CW --> FETCH[Fetcher HTTP<br/>garde SSRF]
18 + FETCH --> PARSE[Parser selectolax]
19 + PARSE --> QC[Classification Québec<br/>page_quebec_score]
20 + QC --> DEDUP[Hash contenu / doublons]
21 + DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)]
22 + DEDUP --> PG[(Postgres<br/>documents, domaines, liens)]
23 + CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich]
24 + STREAM --> EW[enrichment-worker]
25 + EW -->|update partiel| OS
26 + SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG
27 + OS --> API[API FastAPI]
28 + PG --> API
29 + API --> WEB[Next.js web<br/>+ /admin]
30 + WEB --> NGROK[ngrok<br/>www.trouve-ka.com]
31 +```
32 +
33 +## Principe cardinal (§0.3)
34 +
35 +```mermaid
36 +sequenceDiagram
37 + participant F as Frontier
38 + participant W as crawler-worker
39 + participant O as OpenSearch
40 + participant U as Utilisateur
41 + F->>W: claim URL (t+0s)
42 + W->>W: fetch + parse + score Québec (t+2s)
43 + W->>O: index_document (t+3s)
44 + Note over O: refresh_interval 1s
45 + U->>O: recherche (t+4s) — la page est déjà cherchable
46 + W--)W: enrichissement async (étapes 2-3, plus tard)
47 +```
48 +
49 +Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour
50 +des documents **déjà cherchables** (update partiel), il ne conditionne rien.
51 +
52 +## Cycle de vie d'une URL
53 +
54 +```mermaid
55 +stateDiagram-v2
56 + [*] --> pending: découverte (seed, lien, soumission)
57 + pending --> in_progress: claim (priorité DESC, SKIP LOCKED)
58 + in_progress --> pending: politesse (trop tôt pour cet hôte)
59 + in_progress --> pending: succès → next_crawl_at adaptatif
60 + in_progress --> pending: erreur transitoire (retry backoff ≤3)
61 + in_progress --> done: robots refusé / redirection / doublon
62 + in_progress --> failed: erreur permanente ou retries épuisés
63 + in_progress --> blocked: domaine bloqué
64 + pending --> in_progress: recrawl (fréquence mesurée §5.5)
65 +```
66 +
67 +## Scores (§4, §7, §9)
68 +
69 +| Étape | Quand | Champs |
70 +|---|---|---|
71 +| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières |
72 +| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) |
73 +| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) |
74 +
75 +Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) +
76 +w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité`
77 +(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9).
78 +
79 +## Dégradation gracieuse (§13)
80 +
81 +- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit.
82 +- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact.
83 +- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min.
84 +- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin).
85 +
86 +## Déploiement m2m32 (§0.2)
87 +
88 +```mermaid
89 +flowchart LR
90 + NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000]
91 + WEB3000 -->|rewrite /api/*| API8080[api :8080]
92 + subgraph m2m32[Docker Compose sur m2m32 — 32 Go]
93 + WEB3000
94 + API8080
95 + PG5432[postgres]
96 + RD[redis]
97 + OS9200[opensearch 2 Go heap]
98 + CWX[crawler-worker × N]
99 + EN[enrichment-worker]
100 + SC[scheduler]
101 + end
102 +```
103 +
104 +Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go
105 +de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo —
106 +large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`.
added M2M32crouve-ka/docs/decisions.md +43 −0
@@ -0,0 +1,43 @@
1 +# Trouve-KA — Décisions d'architecture
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## D1 — Backend de recherche : OpenSearch 2.x
6 +
7 +**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3).
8 +
9 +**Choix : OpenSearch 2.17**, parce que :
10 +- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP.
11 +- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3).
12 +- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation.
13 +- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion).
14 +- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur.
15 +- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32.
16 +
17 +**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive).
18 +
19 +## D2 — Backend Python unique (FastAPI + pipeline)
20 +
21 +Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS).
22 +
23 +## D3 — Frontier dans Postgres, coordination dans Redis
24 +
25 +- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur.
26 +- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite).
27 +- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde.
28 +
29 +## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker
30 +
31 +Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais.
32 +
33 +## D5 — apps/admin fusionné dans apps/web (route /admin)
34 +
35 +Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »).
36 +
37 +## D6 — Graphe de liens au niveau domaine d'abord
38 +
39 +`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8).
40 +
41 +## D7 — Réseau : le web proxifie /api
42 +
43 +Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod.
added M2M32crouve-ka/infrastructure/deployment/deploy-m2m32.sh +38 −0
@@ -0,0 +1,38 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +#
6 +# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié.
7 +# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok.
8 +set -euo pipefail
9 +
10 +NODE="${NODE:-M2M32}"
11 +REMOTE_DIR="~/trouve-ka"
12 +NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}"
13 +
14 +cd "$(dirname "$0")/../.."
15 +
16 +echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR"
17 +rsync -az --delete \
18 + --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \
19 + --exclude .git --exclude data --exclude '*.egg-info' \
20 + ./ "$NODE:$REMOTE_DIR/"
21 +
22 +echo "→ Build et démarrage du stack sur $NODE"
23 +ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build"
24 +
25 +echo "→ Chargement des seeds (idempotent)"
26 +ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \
27 + docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt"
28 +
29 +echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)"
30 +ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \
31 + nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &"
32 +
33 +echo "→ Vérification de santé"
34 +sleep 5
35 +ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true
36 +
37 +echo "✓ Déploiement terminé : https://$NGROK_DOMAIN"
38 +echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json"
added M2M32crouve-ka/infrastructure/docker/backend.Dockerfile +20 −0
@@ -0,0 +1,20 @@
1 +# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +FROM python:3.12-slim
6 +
7 +WORKDIR /app
8 +
9 +COPY pyproject.toml ./
10 +COPY packages ./packages
11 +COPY services ./services
12 +COPY apps/api ./apps/api
13 +COPY infrastructure/migrations ./infrastructure/migrations
14 +COPY scripts ./scripts
15 +
16 +RUN pip install --no-cache-dir .
17 +
18 +ENV PYTHONUNBUFFERED=1
19 +
20 +CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"]
added M2M32crouve-ka/infrastructure/docker/web.Dockerfile +26 −0
@@ -0,0 +1,26 @@
1 +# Trouve-KA — image web Next.js
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +FROM node:22-alpine AS builder
6 +WORKDIR /repo
7 +RUN corepack enable
8 +COPY package.json pnpm-workspace.yaml ./
9 +COPY apps/web/package.json apps/web/
10 +# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next
11 +RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds
12 +COPY apps/web apps/web
13 +# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici
14 +ARG API_URL=http://api:8080
15 +ENV API_URL=${API_URL}
16 +RUN pnpm --filter @trouveka/web build
17 +
18 +FROM node:22-alpine
19 +WORKDIR /repo
20 +RUN corepack enable
21 +ENV NODE_ENV=production
22 +COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./
23 +COPY --from=builder /repo/node_modules ./node_modules
24 +COPY --from=builder /repo/apps/web ./apps/web
25 +EXPOSE 3000
26 +CMD ["pnpm", "--filter", "@trouveka/web", "start"]
added M2M32crouve-ka/infrastructure/migrations/001_init.sql +152 −0
@@ -0,0 +1,152 @@
1 +-- Trouve-KA — migration 001 : schéma initial
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +
5 +BEGIN;
6 +
7 +CREATE TABLE IF NOT EXISTS schema_migrations (
8 + version INTEGER PRIMARY KEY,
9 + applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
10 +);
11 +
12 +-- ---------------------------------------------------------------------------
13 +-- Domaines connus du web québécois (et non québécois, pour mémoire)
14 +-- ---------------------------------------------------------------------------
15 +CREATE TABLE IF NOT EXISTS domains (
16 + id BIGSERIAL PRIMARY KEY,
17 + domain TEXT NOT NULL UNIQUE,
18 + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
19 + last_crawled_at TIMESTAMPTZ,
20 + robots_fetched_at TIMESTAMPTZ,
21 + robots_body TEXT,
22 + robots_status TEXT, -- ok | not_found | error | forbidden
23 + crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay)
24 + quebec_score REAL NOT NULL DEFAULT 0,
25 + authority_score REAL NOT NULL DEFAULT 0,
26 + page_count INTEGER NOT NULL DEFAULT 0,
27 + inlink_domains INTEGER NOT NULL DEFAULT 0,
28 + outlink_domains INTEGER NOT NULL DEFAULT 0,
29 + language_stats JSONB NOT NULL DEFAULT '{}'::jsonb,
30 + content_change_rate REAL,
31 + blocked BOOLEAN NOT NULL DEFAULT FALSE,
32 + is_seed BOOLEAN NOT NULL DEFAULT FALSE
33 +);
34 +CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC);
35 +
36 +-- ---------------------------------------------------------------------------
37 +-- URLs canoniques connues
38 +-- ---------------------------------------------------------------------------
39 +CREATE TABLE IF NOT EXISTS urls (
40 + id BIGSERIAL PRIMARY KEY,
41 + url TEXT NOT NULL UNIQUE,
42 + canonical_url TEXT,
43 + domain_id BIGINT NOT NULL REFERENCES domains(id),
44 + first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
45 +);
46 +CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id);
47 +
48 +-- ---------------------------------------------------------------------------
49 +-- Frontier : la file d'URLs à crawler, avec priorités et scheduling
50 +-- ---------------------------------------------------------------------------
51 +CREATE TABLE IF NOT EXISTS frontier_items (
52 + id BIGSERIAL PRIMARY KEY,
53 + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
54 + priority REAL NOT NULL DEFAULT 0.5,
55 + depth INTEGER NOT NULL DEFAULT 0,
56 + source_url_id BIGINT REFERENCES urls(id),
57 + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
58 + last_crawled_at TIMESTAMPTZ,
59 + next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60 + status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked
61 + retries INTEGER NOT NULL DEFAULT 0,
62 + error_code TEXT,
63 + locked_by TEXT,
64 + locked_at TIMESTAMPTZ
65 +);
66 +CREATE INDEX IF NOT EXISTS idx_frontier_ready
67 + ON frontier_items (next_crawl_at, priority DESC)
68 + WHERE status = 'pending';
69 +CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status);
70 +
71 +-- ---------------------------------------------------------------------------
72 +-- Historique des tentatives de crawl (l'échec est normal; il est traqué)
73 +-- ---------------------------------------------------------------------------
74 +CREATE TABLE IF NOT EXISTS crawl_attempts (
75 + id BIGSERIAL PRIMARY KEY,
76 + url_id BIGINT NOT NULL REFERENCES urls(id),
77 + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
78 + status_code INTEGER,
79 + error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed|
80 + -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok
81 + outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged
82 + content_hash TEXT,
83 + bytes INTEGER,
84 + duration_ms INTEGER,
85 + redirect_url TEXT,
86 + title TEXT,
87 + quebec_score REAL
88 +);
89 +CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC);
90 +CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id);
91 +
92 +-- ---------------------------------------------------------------------------
93 +-- Documents indexés (métadonnées; le contenu cherchable vit dans l'index)
94 +-- ---------------------------------------------------------------------------
95 +CREATE TABLE IF NOT EXISTS documents (
96 + id BIGSERIAL PRIMARY KEY,
97 + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
98 + content_hash TEXT NOT NULL,
99 + etag TEXT,
100 + last_modified TEXT,
101 + title TEXT,
102 + description TEXT,
103 + language TEXT,
104 + page_quebec_score REAL NOT NULL DEFAULT 0,
105 + published_at TIMESTAMPTZ,
106 + last_changed_at TIMESTAMPTZ,
107 + first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
108 + last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
109 + enrichment_stage INTEGER NOT NULL DEFAULT 1
110 +);
111 +CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash);
112 +
113 +-- ---------------------------------------------------------------------------
114 +-- Graphe de liens agrégé au niveau domaine (page→page viendra plus tard)
115 +-- ---------------------------------------------------------------------------
116 +CREATE TABLE IF NOT EXISTS domain_links (
117 + from_domain_id BIGINT NOT NULL REFERENCES domains(id),
118 + to_domain_id BIGINT NOT NULL REFERENCES domains(id),
119 + link_count INTEGER NOT NULL DEFAULT 1,
120 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
121 + PRIMARY KEY (from_domain_id, to_domain_id)
122 +);
123 +CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id);
124 +
125 +-- ---------------------------------------------------------------------------
126 +-- Analytics de recherche agrégées et respectueuses de la vie privée
127 +-- (les requêtes zéro-résultat sont de l'or : elles pilotent le crawl)
128 +-- ---------------------------------------------------------------------------
129 +CREATE TABLE IF NOT EXISTS search_queries (
130 + id BIGSERIAL PRIMARY KEY,
131 + query TEXT NOT NULL,
132 + language TEXT,
133 + results_total INTEGER NOT NULL,
134 + took_ms INTEGER NOT NULL,
135 + zero_result BOOLEAN NOT NULL DEFAULT FALSE,
136 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
137 +);
138 +CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result;
139 +
140 +-- ---------------------------------------------------------------------------
141 +-- Soumissions publiques d'URL (soumission ≠ inclusion)
142 +-- ---------------------------------------------------------------------------
143 +CREATE TABLE IF NOT EXISTS submissions (
144 + id BIGSERIAL PRIMARY KEY,
145 + url TEXT NOT NULL,
146 + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
147 + status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected
148 +);
149 +
150 +INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
151 +
152 +COMMIT;
added M2M32crouve-ka/infrastructure/migrations/002_frontier_priority_index.sql +17 −0
@@ -0,0 +1,17 @@
1 +-- Trouve-KA — migration 002 : index de claim par priorité
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +--
5 +-- Le claim ordonne par priority DESC; l'index 001 (next_crawl_at, priority)
6 +-- force un tri de toutes les lignes dues à chaque claim. Cet index sert
7 +-- l'ORDER BY directement (la plupart des lignes pending sont dues).
8 +
9 +BEGIN;
10 +
11 +CREATE INDEX IF NOT EXISTS idx_frontier_priority
12 + ON frontier_items (priority DESC, next_crawl_at)
13 + WHERE status = 'pending';
14 +
15 +INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
16 +
17 +COMMIT;
added M2M32crouve-ka/infrastructure/monitoring/README.md +22 −0
@@ -0,0 +1,22 @@
1 +# Trouve-KA — Observabilité
2 +
3 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
4 +
5 +## En place (MVP)
6 +
7 +- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service,
8 + avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms`
9 + (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`.
10 +- **Métriques opérationnelles** exposées par l'API :
11 + - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler.
12 + - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP,
13 + latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement.
14 + - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) —
15 + aucun compteur simulé (§ fake).
16 +- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage).
17 +
18 +## Prochaine étape (post-MVP)
19 +
20 +Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher
21 +Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité,
22 +l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13.
added M2M32crouve-ka/package.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "name": "trouve-ka",
3 + "private": true,
4 + "version": "0.1.0",
5 + "packageManager": "pnpm@11.1.2",
6 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
7 + "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.",
8 + "pnpm": {
9 + "onlyBuiltDependencies": ["sharp", "unrs-resolver"]
10 + },
11 + "scripts": {
12 + "dev": "pnpm --filter @trouveka/web dev",
13 + "build": "pnpm --filter @trouveka/web build",
14 + "start": "pnpm --filter @trouveka/web start",
15 + "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh",
16 + "check:headers": "python3 scripts/check-headers.py",
17 + "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh"
18 + }
19 +}
added M2M32crouve-ka/packages/config/__init__.py +67 −0
@@ -0,0 +1,67 @@
1 +# Trouve-KA — configuration validée par variables d'environnement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Configuration centrale de Trouve-KA.
6 +
7 +Toutes les valeurs sont surchargeables par variable d'environnement et
8 +dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM).
9 +"""
10 +
11 +from functools import lru_cache
12 +
13 +from pydantic_settings import BaseSettings, SettingsConfigDict
14 +
15 +
16 +class Settings(BaseSettings):
17 + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
18 +
19 + # Bases de données
20 + database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka"
21 + redis_url: str = "redis://localhost:6379/0"
22 + search_url: str = "http://localhost:9200"
23 + search_index: str = "trouveka-docs"
24 +
25 + # Crawler — identité et politesse
26 + crawler_user_agent: str = (
27 + "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)"
28 + )
29 + crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot"
30 + max_global_concurrency: int = 24
31 + max_per_host_concurrency: int = 2
32 + default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte
33 +
34 + # Crawler — limites de sécurité (chaque réponse a des limites)
35 + max_response_bytes: int = 3_000_000
36 + max_redirects: int = 5
37 + fetch_timeout: float = 20.0
38 + max_crawl_depth: int = 8
39 + max_links_per_page: int = 300
40 + max_urls_per_domain: int = 5_000
41 + max_query_params: int = 8
42 + max_path_segments: int = 12
43 +
44 + # Recrawl adaptatif (bornes, en heures)
45 + min_recrawl_hours: float = 1.0
46 + max_recrawl_hours: float = 24 * 30.0
47 + default_recrawl_hours: float = 24.0
48 +
49 + # Indexation
50 + min_quebec_score_to_index: float = 0.15
51 + min_body_length: int = 80
52 +
53 + # API
54 + api_host: str = "0.0.0.0"
55 + api_port: int = 8080
56 + admin_token: str = "change-me-admin-token"
57 + public_url: str = "https://www.trouve-ka.com"
58 + ngrok_domain: str = "www.trouve-ka.com"
59 +
60 + # Postgres pool (dimensionné pour 32 Go / plusieurs workers)
61 + pg_pool_min: int = 2
62 + pg_pool_max: int = 10
63 +
64 +
65 +@lru_cache
66 +def get_settings() -> Settings:
67 + return Settings()
added M2M32crouve-ka/packages/database/__init__.py +10 −0
@@ -0,0 +1,10 @@
1 +# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents."""
6 +
7 +from .db import Database
8 +from .migrate import run_migrations
9 +
10 +__all__ = ["Database", "run_migrations"]
added M2M32crouve-ka/packages/database/db.py +522 −0
@@ -0,0 +1,522 @@
1 +# Trouve-KA — dépôts PostgreSQL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Dépôts PostgreSQL de Trouve-KA.
6 +
7 +Le frontier vit dans Postgres : les workers réclament des lots d'URLs via
8 +FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination
9 +externe (CLAUDE.md §13 : pas de complexité distribuée prématurée).
10 +"""
11 +
12 +from datetime import UTC, datetime, timedelta
13 +from typing import Any
14 +
15 +import asyncpg
16 +
17 +
18 +def _dsn(url: str) -> str:
19 + # asyncpg accepte postgresql:// mais pas postgresql+asyncpg://
20 + return url.replace("postgresql+asyncpg://", "postgresql://")
21 +
22 +
23 +class Database:
24 + def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10):
25 + self._url = _dsn(database_url)
26 + self._pool_min = pool_min
27 + self._pool_max = pool_max
28 + self.pool: asyncpg.Pool | None = None
29 +
30 + async def connect(self) -> None:
31 + self.pool = await asyncpg.create_pool(
32 + self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30
33 + )
34 +
35 + async def close(self) -> None:
36 + if self.pool:
37 + await self.pool.close()
38 + self.pool = None
39 +
40 + # ------------------------------------------------------------------ domaines
41 +
42 + async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int:
43 + row = await self.pool.fetchrow(
44 + """
45 + INSERT INTO domains (domain, is_seed) VALUES ($1, $2)
46 + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed
47 + RETURNING id
48 + """,
49 + domain,
50 + is_seed,
51 + )
52 + return row["id"]
53 +
54 + async def get_domain(self, domain: str) -> asyncpg.Record | None:
55 + return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain)
56 +
57 + async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None:
58 + return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id)
59 +
60 + async def save_robots(
61 + self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None
62 + ) -> None:
63 + await self.pool.execute(
64 + """
65 + UPDATE domains SET robots_body = $2, robots_status = $3,
66 + robots_fetched_at = now(), crawl_delay_ms = $4
67 + WHERE id = $1
68 + """,
69 + domain_id,
70 + body,
71 + status,
72 + crawl_delay_ms,
73 + )
74 +
75 + async def block_domain(self, domain: str) -> None:
76 + await self.pool.execute(
77 + "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain
78 + )
79 + await self.pool.execute(
80 + """
81 + UPDATE frontier_items SET status = 'blocked'
82 + WHERE status IN ('pending', 'in_progress')
83 + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id
84 + WHERE d.domain = $1)
85 + """,
86 + domain,
87 + )
88 +
89 + async def update_domain_after_page(
90 + self, domain_id: int, page_quebec_score: float, language: str | None
91 + ) -> None:
92 + """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue."""
93 + lang = language or "unknown"
94 + await self.pool.execute(
95 + """
96 + UPDATE domains SET
97 + page_count = page_count + 1,
98 + last_crawled_at = now(),
99 + quebec_score = (quebec_score * page_count + $2) / (page_count + 1),
100 + language_stats = jsonb_set(
101 + language_stats, ARRAY[$3],
102 + (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb)
103 + WHERE id = $1
104 + """,
105 + domain_id,
106 + page_quebec_score,
107 + lang,
108 + )
109 +
110 + async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None:
111 + if not to_domain_counts:
112 + return
113 + await self.pool.executemany(
114 + """
115 + INSERT INTO domain_links (from_domain_id, to_domain_id, link_count)
116 + VALUES ($1, $2, $3)
117 + ON CONFLICT (from_domain_id, to_domain_id)
118 + DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count,
119 + updated_at = now()
120 + """,
121 + [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id],
122 + )
123 +
124 + # ------------------------------------------------------------------ frontier
125 +
126 + async def enqueue_url(
127 + self,
128 + url: str,
129 + domain: str,
130 + *,
131 + priority: float = 0.5,
132 + depth: int = 0,
133 + source_url_id: int | None = None,
134 + is_seed: bool = False,
135 + max_urls_per_domain: int = 5000,
136 + ) -> int | None:
137 + """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon."""
138 + async with self.pool.acquire() as conn:
139 + async with conn.transaction():
140 + domain_id = await conn.fetchval(
141 + """
142 + INSERT INTO domains (domain, is_seed) VALUES ($1, $2)
143 + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed
144 + RETURNING id
145 + """,
146 + domain,
147 + is_seed,
148 + )
149 + dom = await conn.fetchrow(
150 + "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id
151 + )
152 + if dom["blocked"]:
153 + return None
154 + if not is_seed and dom["page_count"] >= max_urls_per_domain:
155 + return None
156 + url_id = await conn.fetchval(
157 + """
158 + INSERT INTO urls (url, domain_id) VALUES ($1, $2)
159 + ON CONFLICT (url) DO NOTHING RETURNING id
160 + """,
161 + url,
162 + domain_id,
163 + )
164 + if url_id is None:
165 + return None # URL déjà connue : pas de doublon dans le frontier
166 + await conn.execute(
167 + """
168 + INSERT INTO frontier_items (url_id, priority, depth, source_url_id)
169 + VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING
170 + """,
171 + url_id,
172 + priority,
173 + depth,
174 + source_url_id,
175 + )
176 + return url_id
177 +
178 + async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]:
179 + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr)."""
180 + return await self.pool.fetch(
181 + """
182 + WITH claimed AS (
183 + SELECT f.id FROM frontier_items f
184 + WHERE f.status = 'pending' AND f.next_crawl_at <= now()
185 + ORDER BY f.priority DESC, f.next_crawl_at
186 + LIMIT $2
187 + FOR UPDATE SKIP LOCKED
188 + )
189 + UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now()
190 + FROM claimed
191 + WHERE f.id = claimed.id
192 + RETURNING f.id, f.url_id, f.priority, f.depth, f.retries,
193 + f.last_crawled_at, f.next_crawl_at,
194 + (SELECT url FROM urls WHERE id = f.url_id) AS url,
195 + (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id
196 + """,
197 + worker_id,
198 + limit,
199 + )
200 +
201 + async def release_item(
202 + self,
203 + url_id: int,
204 + *,
205 + status: str,
206 + error_code: str | None = None,
207 + next_crawl_at: datetime | None = None,
208 + priority: float | None = None,
209 + increment_retries: bool = False,
210 + ) -> None:
211 + await self.pool.execute(
212 + """
213 + UPDATE frontier_items SET
214 + status = $2,
215 + error_code = $3,
216 + last_crawled_at = now(),
217 + next_crawl_at = COALESCE($4, next_crawl_at),
218 + priority = COALESCE($5, priority),
219 + retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END,
220 + locked_by = NULL, locked_at = NULL
221 + WHERE url_id = $1
222 + """,
223 + url_id,
224 + status,
225 + error_code,
226 + next_crawl_at,
227 + priority,
228 + increment_retries,
229 + )
230 +
231 + async def reset_stale_items(self, older_than_minutes: int = 30) -> int:
232 + """Relance les items in_progress abandonnés (worker mort). Retourne le nombre."""
233 + result = await self.pool.execute(
234 + """
235 + UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL
236 + WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval
237 + """,
238 + str(older_than_minutes),
239 + )
240 + return int(result.split()[-1])
241 +
242 + async def requeue_url(self, url: str) -> bool:
243 + result = await self.pool.execute(
244 + """
245 + UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0
246 + WHERE url_id = (SELECT id FROM urls WHERE url = $1)
247 + """,
248 + url,
249 + )
250 + return result.endswith("1")
251 +
252 + async def requeue_domain(self, domain: str) -> int:
253 + result = await self.pool.execute(
254 + """
255 + UPDATE frontier_items SET status = 'pending', next_crawl_at = now()
256 + WHERE status IN ('done', 'failed')
257 + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id
258 + WHERE d.domain = $1)
259 + """,
260 + domain,
261 + )
262 + return int(result.split()[-1])
263 +
264 + # ------------------------------------------------------------------ crawl / documents
265 +
266 + async def record_attempt(
267 + self,
268 + url_id: int,
269 + *,
270 + status_code: int | None,
271 + error_code: str,
272 + outcome: str,
273 + content_hash: str | None = None,
274 + num_bytes: int | None = None,
275 + duration_ms: int | None = None,
276 + redirect_url: str | None = None,
277 + title: str | None = None,
278 + quebec_score: float | None = None,
279 + ) -> None:
280 + await self.pool.execute(
281 + """
282 + INSERT INTO crawl_attempts
283 + (url_id, status_code, error_code, outcome, content_hash, bytes,
284 + duration_ms, redirect_url, title, quebec_score)
285 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
286 + """,
287 + url_id,
288 + status_code,
289 + error_code,
290 + outcome,
291 + content_hash,
292 + num_bytes,
293 + duration_ms,
294 + redirect_url,
295 + title,
296 + quebec_score,
297 + )
298 +
299 + async def get_document(self, url_id: int) -> asyncpg.Record | None:
300 + return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id)
301 +
302 + async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None:
303 + """Doublon exact : même hash de contenu sur une autre URL."""
304 + return await self.pool.fetchrow(
305 + "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1",
306 + chash,
307 + url_id,
308 + )
309 +
310 + async def upsert_document(
311 + self,
312 + url_id: int,
313 + *,
314 + content_hash: str,
315 + etag: str | None,
316 + last_modified: str | None,
317 + title: str,
318 + description: str,
319 + language: str | None,
320 + page_quebec_score: float,
321 + published_at: datetime | None,
322 + changed: bool,
323 + ) -> int:
324 + row = await self.pool.fetchrow(
325 + """
326 + INSERT INTO documents
327 + (url_id, content_hash, etag, last_modified, title, description, language,
328 + page_quebec_score, published_at, last_changed_at)
329 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
330 + ON CONFLICT (url_id) DO UPDATE SET
331 + content_hash = EXCLUDED.content_hash,
332 + etag = EXCLUDED.etag,
333 + last_modified = EXCLUDED.last_modified,
334 + title = EXCLUDED.title,
335 + description = EXCLUDED.description,
336 + language = EXCLUDED.language,
337 + page_quebec_score = EXCLUDED.page_quebec_score,
338 + published_at = EXCLUDED.published_at,
339 + last_indexed_at = now(),
340 + last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END
341 + RETURNING id
342 + """,
343 + url_id,
344 + content_hash,
345 + etag,
346 + last_modified,
347 + title,
348 + description,
349 + language,
350 + page_quebec_score,
351 + published_at,
352 + changed,
353 + )
354 + return row["id"]
355 +
356 + async def set_canonical(self, url_id: int, canonical_url: str) -> None:
357 + await self.pool.execute(
358 + "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url
359 + )
360 +
361 + # ------------------------------------------------------------------ analytics / statut
362 +
363 + async def record_search_query(
364 + self, query: str, language: str | None, total: int, took_ms: int
365 + ) -> None:
366 + await self.pool.execute(
367 + """
368 + INSERT INTO search_queries (query, language, results_total, took_ms, zero_result)
369 + VALUES ($1, $2, $3, $4, $3 = 0)
370 + """,
371 + query[:500],
372 + language,
373 + total,
374 + took_ms,
375 + )
376 +
377 + async def add_submission(self, url: str) -> None:
378 + await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url)
379 +
380 + async def status_snapshot(self) -> dict[str, Any]:
381 + since = datetime.now(UTC) - timedelta(hours=1)
382 + frontier = {
383 + r["status"]: r["n"]
384 + for r in await self.pool.fetch(
385 + "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status"
386 + )
387 + }
388 + row = await self.pool.fetchrow(
389 + """
390 + SELECT
391 + (SELECT count(*)::int FROM documents) AS docs,
392 + (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains,
393 + (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h,
394 + (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h,
395 + (SELECT count(*)::int FROM crawl_attempts
396 + WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h
397 + """,
398 + since,
399 + )
400 + return {
401 + "pages_indexed": row["docs"],
402 + "domains_count": row["domains"],
403 + "indexed_last_hour": row["indexed_1h"],
404 + "fetched_last_hour": row["fetched_1h"],
405 + "errors_last_hour": row["errors_1h"],
406 + "frontier_pending": frontier.get("pending", 0),
407 + "frontier_in_progress": frontier.get("in_progress", 0),
408 + "frontier": frontier,
409 + }
410 +
411 + async def admin_overview(self) -> dict[str, Any]:
412 + since = datetime.now(UTC) - timedelta(hours=1)
413 + base = await self.status_snapshot()
414 + http_status = {
415 + str(r["status_code"]): r["n"]
416 + for r in await self.pool.fetch(
417 + """
418 + SELECT status_code, count(*)::int AS n FROM crawl_attempts
419 + WHERE fetched_at >= $1 AND status_code IS NOT NULL
420 + GROUP BY status_code ORDER BY n DESC
421 + """,
422 + since,
423 + )
424 + }
425 + outcomes = {
426 + r["outcome"]: r["n"]
427 + for r in await self.pool.fetch(
428 + "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome",
429 + since,
430 + )
431 + }
432 + top_domains = [
433 + dict(r)
434 + for r in await self.pool.fetch(
435 + """
436 + SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score
437 + FROM domains WHERE page_count > 0
438 + ORDER BY page_count DESC LIMIT 20
439 + """
440 + )
441 + ]
442 + recent_errors = [
443 + {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()}
444 + for r in await self.pool.fetch(
445 + """
446 + SELECT u.url, a.error_code, a.fetched_at
447 + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id
448 + WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20
449 + """
450 + )
451 + ]
452 + latency = await self.pool.fetchrow(
453 + """
454 + SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50,
455 + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95
456 + FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL
457 + """,
458 + since,
459 + )
460 + return {
461 + "frontier": {
462 + "pending": base["frontier"].get("pending", 0),
463 + "in_progress": base["frontier"].get("in_progress", 0),
464 + "done": base["frontier"].get("done", 0),
465 + "failed": base["frontier"].get("failed", 0),
466 + "blocked": base["frontier"].get("blocked", 0),
467 + },
468 + "rates": {
469 + "fetched_1h": base["fetched_last_hour"],
470 + "indexed_1h": outcomes.get("indexed", 0),
471 + "errors_1h": outcomes.get("error", 0),
472 + "robots_blocked_1h": outcomes.get("robots_blocked", 0),
473 + "duplicates_1h": outcomes.get("duplicate", 0),
474 + "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0),
475 + },
476 + "http_status": http_status,
477 + "top_domains": top_domains,
478 + "recent_errors": recent_errors,
479 + "latency": {
480 + "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None,
481 + "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None,
482 + },
483 + }
484 +
485 + async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]:
486 + rows = await self.pool.fetch(
487 + """
488 + SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title
489 + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id
490 + ORDER BY a.fetched_at DESC LIMIT $1
491 + """,
492 + min(limit, 200),
493 + )
494 + return [
495 + {
496 + "at": r["fetched_at"].isoformat(),
497 + "url": r["url"],
498 + "status": r["status_code"],
499 + "outcome": r["outcome"],
500 + "quebec_score": r["quebec_score"],
501 + "title": r["title"],
502 + }
503 + for r in rows
504 + ]
505 +
506 + async def frontier_inspect(
507 + self, domain: str | None = None, status: str | None = None, limit: int = 100
508 + ) -> list[dict[str, Any]]:
509 + rows = await self.pool.fetch(
510 + """
511 + SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code
512 + FROM frontier_items f JOIN urls u ON u.id = f.url_id
513 + JOIN domains d ON d.id = u.domain_id
514 + WHERE ($1::text IS NULL OR d.domain = $1)
515 + AND ($2::text IS NULL OR f.status = $2)
516 + ORDER BY f.priority DESC LIMIT $3
517 + """,
518 + domain,
519 + status,
520 + min(limit, 500),
521 + )
522 + return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows]
added M2M32crouve-ka/packages/database/migrate.py +46 −0
@@ -0,0 +1,46 @@
1 +# Trouve-KA — exécution des migrations SQL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Applique les migrations SQL de infrastructure/migrations dans l'ordre."""
6 +
7 +import os
8 +import pathlib
9 +import re
10 +
11 +import asyncpg
12 +
13 +
14 +def _find_migrations_dir() -> pathlib.Path:
15 + """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable)."""
16 + candidates = [
17 + os.environ.get("TROUVEKA_MIGRATIONS_DIR"),
18 + pathlib.Path.cwd() / "infrastructure" / "migrations",
19 + pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations",
20 + ]
21 + for cand in candidates:
22 + if cand and pathlib.Path(cand).is_dir():
23 + return pathlib.Path(cand)
24 + raise FileNotFoundError("dossier infrastructure/migrations introuvable")
25 +
26 +
27 +async def run_migrations(database_url: str) -> list[str]:
28 + """Applique les migrations manquantes. Retourne la liste des fichiers appliqués."""
29 + migrations_dir = _find_migrations_dir()
30 + conn = await asyncpg.connect(database_url)
31 + applied: list[str] = []
32 + try:
33 + await conn.execute(
34 + "CREATE TABLE IF NOT EXISTS schema_migrations ("
35 + " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
36 + )
37 + done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")}
38 + for path in sorted(migrations_dir.glob("*.sql")):
39 + match = re.match(r"^(\d+)_", path.name)
40 + if not match or int(match.group(1)) in done:
41 + continue
42 + await conn.execute(path.read_text(encoding="utf-8"))
43 + applied.append(path.name)
44 + finally:
45 + await conn.close()
46 + return applied
added M2M32crouve-ka/packages/logging/__init__.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — logging structuré JSON
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id."""
6 +
7 +import json
8 +import logging
9 +import sys
10 +import time
11 +from typing import Any
12 +
13 +
14 +class JsonFormatter(logging.Formatter):
15 + def format(self, record: logging.LogRecord) -> str:
16 + payload: dict[str, Any] = {
17 + "ts": round(time.time(), 3),
18 + "level": record.levelname.lower(),
19 + "logger": record.name,
20 + "msg": record.getMessage(),
21 + }
22 + extra = getattr(record, "ctx", None)
23 + if isinstance(extra, dict):
24 + payload.update(extra)
25 + if record.exc_info and record.exc_info[0] is not None:
26 + payload["exc"] = self.formatException(record.exc_info)
27 + return json.dumps(payload, ensure_ascii=False, default=str)
28 +
29 +
30 +def get_logger(name: str) -> logging.Logger:
31 + """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`."""
32 + logger = logging.getLogger(name)
33 + if not logger.handlers:
34 + handler = logging.StreamHandler(sys.stdout)
35 + handler.setFormatter(JsonFormatter())
36 + logger.addHandler(handler)
37 + logger.setLevel(logging.INFO)
38 + logger.propagate = False
39 + return logger
added M2M32crouve-ka/packages/queue/__init__.py +71 −0
@@ -0,0 +1,71 @@
1 +# Trouve-KA — coordination Redis (politesse, pause, enrichissement)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Coordination inter-workers via Redis.
6 +
7 +- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre,
8 + quel que soit le nombre de workers.
9 +- Pause globale du crawler : simple clé drapeau.
10 +- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation).
11 +"""
12 +
13 +import json
14 +from typing import Any
15 +
16 +import redis.asyncio as aioredis
17 +
18 +PAUSE_KEY = "trouveka:crawler:paused"
19 +HOST_LOCK_PREFIX = "trouveka:host-lock:"
20 +ENRICH_STREAM = "trouveka:enrich"
21 +
22 +
23 +class Coordination:
24 + def __init__(self, redis_url: str):
25 + self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True)
26 +
27 + async def close(self) -> None:
28 + await self.redis.aclose()
29 +
30 + # ------------------------------------------------------------- politesse
31 + async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool:
32 + """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard."""
33 + px = max(int(delay_seconds * 1000), 100)
34 + return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px))
35 +
36 + # ------------------------------------------------------------- pause
37 + async def pause_crawler(self) -> None:
38 + await self.redis.set(PAUSE_KEY, "1")
39 +
40 + async def resume_crawler(self) -> None:
41 + await self.redis.delete(PAUSE_KEY)
42 +
43 + async def is_paused(self) -> bool:
44 + return await self.redis.exists(PAUSE_KEY) == 1
45 +
46 + # ------------------------------------------------------------- enrichissement
47 + async def enqueue_enrichment(self, payload: dict[str, Any]) -> None:
48 + await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000)
49 +
50 + async def read_enrichment(
51 + self, group: str, consumer: str, count: int = 10, block_ms: int = 5000
52 + ) -> list[tuple[str, dict[str, Any]]]:
53 + try:
54 + await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True)
55 + except aioredis.ResponseError as exc:
56 + if "BUSYGROUP" not in str(exc):
57 + raise
58 + entries = await self.redis.xreadgroup(
59 + group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms
60 + )
61 + out: list[tuple[str, dict[str, Any]]] = []
62 + for _stream, items in entries or []:
63 + for msg_id, fields in items:
64 + out.append((msg_id, json.loads(fields["data"])))
65 + return out
66 +
67 + async def ack_enrichment(self, group: str, msg_id: str) -> None:
68 + await self.redis.xack(ENRICH_STREAM, group, msg_id)
69 +
70 + async def enrich_backlog(self) -> int:
71 + return await self.redis.xlen(ENRICH_STREAM)
added M2M32crouve-ka/packages/search-core/__init__.py +10 −0
@@ -0,0 +1,10 @@
1 +# Trouve-KA — couche OpenSearch (client, mapping, indexation)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate."""
6 +
7 +from .client import SearchCore
8 +from .mapping import INDEX_SETTINGS
9 +
10 +__all__ = ["SearchCore", "INDEX_SETTINGS"]
added M2M32crouve-ka/packages/search-core/client.py +66 −0
@@ -0,0 +1,66 @@
1 +# Trouve-KA — client OpenSearch
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut."""
6 +
7 +import hashlib
8 +from typing import Any
9 +
10 +from opensearchpy import AsyncOpenSearch
11 +from opensearchpy.exceptions import NotFoundError
12 +
13 +from .mapping import INDEX_SETTINGS
14 +
15 +
16 +def doc_id_for_url(url: str) -> str:
17 + """ID de document stable dérivé de l'URL canonique."""
18 + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32]
19 +
20 +
21 +class SearchCore:
22 + def __init__(self, search_url: str, index: str):
23 + self.index = index
24 + self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True)
25 +
26 + async def close(self) -> None:
27 + await self.client.close()
28 +
29 + async def ensure_index(self) -> None:
30 + if not await self.client.indices.exists(index=self.index):
31 + await self.client.indices.create(index=self.index, body=INDEX_SETTINGS)
32 +
33 + async def index_document(self, doc: dict[str, Any]) -> str:
34 + """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s)."""
35 + _id = doc_id_for_url(doc["canonical_url"] or doc["url"])
36 + await self.client.index(index=self.index, id=_id, body=doc)
37 + return _id
38 +
39 + async def update_document(self, url: str, partial: dict[str, Any]) -> None:
40 + """Mise à jour partielle (enrichissement asynchrone, étapes 2-3)."""
41 + try:
42 + await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial})
43 + except NotFoundError:
44 + pass
45 +
46 + async def delete_document(self, url: str) -> None:
47 + try:
48 + await self.client.delete(index=self.index, id=doc_id_for_url(url))
49 + except NotFoundError:
50 + pass
51 +
52 + async def search(self, body: dict[str, Any]) -> dict[str, Any]:
53 + return await self.client.search(index=self.index, body=body)
54 +
55 + async def count(self) -> int:
56 + try:
57 + res = await self.client.count(index=self.index)
58 + return res["count"]
59 + except NotFoundError:
60 + return 0
61 +
62 + async def ping(self) -> bool:
63 + try:
64 + return await self.client.ping()
65 + except Exception:
66 + return False
added M2M32crouve-ka/packages/search-core/mapping.py +124 −0
@@ -0,0 +1,124 @@
1 +# Trouve-KA — mapping OpenSearch bilingue
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Mapping et settings de l'index documents.
6 +
7 +- Analyzers français ET anglais sur les champs texte (bilingue dès le départ).
8 +- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche
9 + seulement : pas de traduction à l'ingestion.
10 +- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal).
11 +"""
12 +
13 +# Synonymes bilingues de départ — extensibles sans réindexation (search-time)
14 +BILINGUAL_SYNONYMS = [
15 + "thermopompe, heat pump",
16 + "subvention, grant, subsidy",
17 + "emploi, job, employment",
18 + "entreprise, company, business",
19 + "université, university",
20 + "cégep, cegep, college",
21 + "garderie, daycare, cpe",
22 + "impôt, tax, taxes",
23 + "santé, health",
24 + "plombier, plumber",
25 + "électricien, electrician",
26 + "déneigement, snow removal",
27 + "assurance, insurance",
28 + "logement, housing, apartment",
29 + "ville, city, municipalité, municipality",
30 + "gouvernement, government",
31 + "permis, permit, license, licence",
32 +]
33 +
34 +INDEX_SETTINGS = {
35 + "settings": {
36 + "number_of_shards": 1,
37 + "number_of_replicas": 0,
38 + "refresh_interval": "1s",
39 + "analysis": {
40 + "filter": {
41 + "french_elision": {
42 + "type": "elision",
43 + "articles_case": True,
44 + "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"],
45 + },
46 + "french_stop": {"type": "stop", "stopwords": "_french_"},
47 + "french_stemmer": {"type": "stemmer", "language": "light_french"},
48 + "english_stop": {"type": "stop", "stopwords": "_english_"},
49 + "english_stemmer": {"type": "stemmer", "language": "light_english"},
50 + "bilingual_synonyms": {
51 + "type": "synonym_graph",
52 + "lenient": True,
53 + "synonyms": BILINGUAL_SYNONYMS,
54 + },
55 + },
56 + "analyzer": {
57 + "fr_text": {
58 + "tokenizer": "standard",
59 + "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"],
60 + },
61 + "fr_search": {
62 + "tokenizer": "standard",
63 + "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms",
64 + "french_stop", "french_stemmer"],
65 + },
66 + "en_text": {
67 + "tokenizer": "standard",
68 + "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"],
69 + },
70 + "en_search": {
71 + "tokenizer": "standard",
72 + "filter": ["lowercase", "asciifolding", "bilingual_synonyms",
73 + "english_stop", "english_stemmer"],
74 + },
75 + },
76 + },
77 + },
78 + "mappings": {
79 + "properties": {
80 + "url": {"type": "keyword"},
81 + "canonical_url": {"type": "keyword"},
82 + "domain": {"type": "keyword"},
83 + "title": {
84 + "type": "text",
85 + "analyzer": "fr_text",
86 + "search_analyzer": "fr_search",
87 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
88 + },
89 + "description": {
90 + "type": "text",
91 + "analyzer": "fr_text",
92 + "search_analyzer": "fr_search",
93 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
94 + },
95 + "body": {
96 + "type": "text",
97 + "analyzer": "fr_text",
98 + "search_analyzer": "fr_search",
99 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
100 + },
101 + "headings": {
102 + "type": "text",
103 + "analyzer": "fr_text",
104 + "search_analyzer": "fr_search",
105 + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}},
106 + },
107 + "language": {"type": "keyword"},
108 + "page_quebec_score": {"type": "float"},
109 + "domain_quebec_score": {"type": "float"},
110 + "locations": {"type": "keyword"},
111 + "organizations": {"type": "keyword"},
112 + "people": {"type": "keyword"},
113 + "categories": {"type": "keyword"},
114 + "published_at": {"type": "date"},
115 + "crawled_at": {"type": "date"},
116 + "authority_score": {"type": "float"},
117 + "freshness_score": {"type": "float"},
118 + "quality_score": {"type": "float"},
119 + "spam_score": {"type": "float"},
120 + # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7),
121 + # le mapping est extensible sans réindexation pour un nouveau champ.
122 + }
123 + },
124 +}
added M2M32crouve-ka/packages/shared/__init__.py +20 −0
@@ -0,0 +1,20 @@
1 +# Trouve-KA — utilitaires partagés
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu."""
6 +
7 +from .urls import canonicalize_url, display_url, extract_domain, is_http_url
8 +from .ssrf import is_safe_url, is_safe_ip
9 +from .hashing import content_hash, text_fingerprint
10 +
11 +__all__ = [
12 + "canonicalize_url",
13 + "display_url",
14 + "extract_domain",
15 + "is_http_url",
16 + "is_safe_url",
17 + "is_safe_ip",
18 + "content_hash",
19 + "text_fingerprint",
20 +]
added M2M32crouve-ka/packages/shared/hashing.py +25 −0
@@ -0,0 +1,25 @@
1 +# Trouve-KA — hachage de contenu et détection de changement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Hachage de contenu pour la déduplication exacte et la détection de changement.
6 +
7 +Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour
8 +ignorer le bruit de balisage (nonces, timestamps de rendu, etc.).
9 +"""
10 +
11 +import hashlib
12 +import re
13 +
14 +_WHITESPACE = re.compile(r"\s+")
15 +
16 +
17 +def text_fingerprint(text: str) -> str:
18 + """Texte normalisé pour hachage : espaces réduits, minuscules."""
19 + return _WHITESPACE.sub(" ", text).strip().lower()
20 +
21 +
22 +def content_hash(title: str, body: str) -> str:
23 + """SHA-256 du contenu textuel normalisé (titre + corps)."""
24 + payload = text_fingerprint(title) + "\n" + text_fingerprint(body)
25 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
added M2M32crouve-ka/packages/shared/ssrf.py +75 −0
@@ -0,0 +1,75 @@
1 +# Trouve-KA — prévention SSRF
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Garde SSRF du crawler (CLAUDE.md §5.7).
6 +
7 +Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud,
8 +schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection
9 +par l'appelant (fetcher).
10 +"""
11 +
12 +import ipaddress
13 +import socket
14 +from urllib.parse import urlsplit
15 +
16 +BLOCKED_HOSTS = {
17 + "localhost",
18 + "metadata.google.internal",
19 + "metadata.gke.internal",
20 +}
21 +
22 +# Endpoint de métadonnées AWS/GCP/Azure/OpenStack
23 +METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"}
24 +
25 +
26 +def is_safe_ip(ip_str: str) -> bool:
27 + try:
28 + ip = ipaddress.ip_address(ip_str)
29 + except ValueError:
30 + return False
31 + if ip_str in METADATA_IPS:
32 + return False
33 + return not (
34 + ip.is_private
35 + or ip.is_loopback
36 + or ip.is_link_local
37 + or ip.is_multicast
38 + or ip.is_reserved
39 + or ip.is_unspecified
40 + )
41 +
42 +
43 +def resolve_host(host: str) -> list[str]:
44 + """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable."""
45 + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
46 + return list({info[4][0] for info in infos})
47 +
48 +
49 +def is_safe_url(url: str, *, resolve: bool = True) -> bool:
50 + """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues)."""
51 + try:
52 + parts = urlsplit(url)
53 + except ValueError:
54 + return False
55 + if parts.scheme.lower() not in ("http", "https"):
56 + return False
57 + host = parts.hostname
58 + if not host:
59 + return False
60 + host = host.lower().strip(".")
61 + if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"):
62 + return False
63 + # Hôte littéral IP
64 + try:
65 + ipaddress.ip_address(host)
66 + return is_safe_ip(host)
67 + except ValueError:
68 + pass
69 + if not resolve:
70 + return True
71 + try:
72 + ips = resolve_host(host)
73 + except (socket.gaierror, OSError):
74 + return False
75 + return bool(ips) and all(is_safe_ip(ip) for ip in ips)
added M2M32crouve-ka/packages/shared/urls.py +103 −0
@@ -0,0 +1,103 @@
1 +# Trouve-KA — canonicalisation d'URL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Canonicalisation d'URL prudente.
6 +
7 +Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources
8 +distinctes par accident. On normalise seulement ce qui est sûr :
9 +fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus,
10 +encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus.
11 +"""
12 +
13 +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote
14 +
15 +# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu)
16 +TRACKING_PARAMS = {
17 + "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id",
18 + "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid",
19 + "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid",
20 + "ref_src", "cmpid", "s_kwcid", "spm",
21 +}
22 +
23 +DEFAULT_PORTS = {"http": 80, "https": 443}
24 +
25 +
26 +def is_http_url(url: str) -> bool:
27 + try:
28 + scheme = urlsplit(url).scheme.lower()
29 + except ValueError:
30 + return False
31 + return scheme in ("http", "https")
32 +
33 +
34 +def canonicalize_url(url: str, base: str | None = None) -> str | None:
35 + """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http)."""
36 + url = url.strip()
37 + if not url:
38 + return None
39 + if base:
40 + from urllib.parse import urljoin
41 +
42 + url = urljoin(base, url)
43 + try:
44 + parts = urlsplit(url)
45 + except ValueError:
46 + return None
47 + scheme = parts.scheme.lower()
48 + if scheme not in ("http", "https"):
49 + return None
50 + host = parts.hostname
51 + if not host:
52 + return None
53 + host = host.strip(".").lower()
54 + try:
55 + host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host
56 + except UnicodeError:
57 + return None
58 +
59 + port = parts.port
60 + netloc = host
61 + if port and port != DEFAULT_PORTS.get(scheme):
62 + netloc = f"{host}:{port}"
63 +
64 + # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée)
65 + path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._")
66 + # Slashs multiples consécutifs → un seul (sûr pour HTTP)
67 + while "//" in path:
68 + path = path.replace("//", "/")
69 + if not path:
70 + path = "/"
71 +
72 + # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre
73 + query = ""
74 + if parts.query:
75 + kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
76 + if k.lower() not in TRACKING_PARAMS]
77 + query = urlencode(kept)
78 +
79 + # Fragment : toujours retiré (jamais envoyé au serveur)
80 + return urlunsplit((scheme, netloc, path, query, ""))
81 +
82 +
83 +def extract_domain(url: str) -> str | None:
84 + """Domaine enregistrable approximatif : hôte sans le préfixe www."""
85 + try:
86 + host = urlsplit(url).hostname
87 + except ValueError:
88 + return None
89 + if not host:
90 + return None
91 + host = host.lower().strip(".")
92 + return host[4:] if host.startswith("www.") else host
93 +
94 +
95 +def display_url(url: str, max_len: int = 80) -> str:
96 + """URL d'affichage façon breadcrumb : quebec.ca › services › permis."""
97 + parts = urlsplit(url)
98 + host = (parts.hostname or "").removeprefix("www.")
99 + segments = [unquote(s) for s in parts.path.split("/") if s]
100 + crumb = " › ".join([host, *segments[:3]])
101 + if len(crumb) > max_len:
102 + crumb = crumb[: max_len - 1] + "…"
103 + return crumb
added M2M32crouve-ka/packages/types/__init__.py +103 −0
@@ -0,0 +1,103 @@
1 +# Trouve-KA — types partagés du pipeline
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API."""
6 +
7 +from datetime import datetime
8 +from enum import StrEnum
9 +
10 +from pydantic import BaseModel, Field
11 +
12 +
13 +class ErrorCode(StrEnum):
14 + DNS = "dns"
15 + TIMEOUT = "timeout"
16 + TLS = "tls"
17 + HTTP_4XX = "http_4xx"
18 + HTTP_5XX = "http_5xx"
19 + ROBOTS_DENIED = "robots_denied"
20 + PARSE_FAILED = "parse_failed"
21 + UNSUPPORTED_CONTENT = "unsupported_content"
22 + TOO_LARGE = "too_large"
23 + DUPLICATE = "duplicate"
24 + SPAM = "spam"
25 + NOT_QUEBEC = "not_quebec"
26 + SSRF_BLOCKED = "ssrf_blocked"
27 + TOO_MANY_REDIRECTS = "too_many_redirects"
28 + CONNECTION = "connection"
29 + OK = "ok"
30 +
31 +
32 +class Outcome(StrEnum):
33 + INDEXED = "indexed"
34 + DUPLICATE = "duplicate"
35 + UNCHANGED = "unchanged"
36 + ERROR = "error"
37 + NOT_QUEBEC = "not_quebec"
38 + ROBOTS_BLOCKED = "robots_blocked"
39 + REDIRECT = "redirect"
40 +
41 +
42 +class FetchResult(BaseModel):
43 + url: str
44 + final_url: str
45 + status_code: int | None = None
46 + error_code: ErrorCode = ErrorCode.OK
47 + content_type: str | None = None
48 + charset: str | None = None
49 + body: bytes | None = None
50 + etag: str | None = None
51 + last_modified: str | None = None
52 + duration_ms: int = 0
53 + redirect_chain: list[str] = Field(default_factory=list)
54 +
55 +
56 +class ExtractedLink(BaseModel):
57 + url: str
58 + anchor: str = ""
59 + nofollow: bool = False
60 +
61 +
62 +class ParsedPage(BaseModel):
63 + url: str
64 + canonical_url: str | None = None
65 + title: str = ""
66 + description: str = ""
67 + body: str = ""
68 + headings: list[str] = Field(default_factory=list)
69 + language: str | None = None
70 + links: list[ExtractedLink] = Field(default_factory=list)
71 + published_at: datetime | None = None
72 + modified_at: datetime | None = None
73 + author: str | None = None
74 + noindex: bool = False
75 + nofollow_page: bool = False
76 + structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring
77 +
78 +
79 +class QuebecSignals(BaseModel):
80 + score: float = 0.0
81 + locations: list[str] = Field(default_factory=list)
82 + reasons: list[str] = Field(default_factory=list)
83 +
84 +
85 +class SearchResult(BaseModel):
86 + title: str
87 + url: str
88 + display_url: str
89 + snippet: str
90 + domain: str
91 + language: str | None = None
92 + quebec_score: float = 0.0
93 + badges: list[str] = Field(default_factory=list)
94 + published_at: datetime | None = None
95 +
96 +
97 +class SearchResponse(BaseModel):
98 + query: str
99 + total: int
100 + took_ms: int
101 + page: int
102 + limit: int
103 + results: list[SearchResult]
added M2M32crouve-ka/pnpm-lock.yaml +3967 −0
@@ -0,0 +1,3967 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .: {}
10 +
11 + apps/web:
12 + dependencies:
13 + class-variance-authority:
14 + specifier: ^0.7.1
15 + version: 0.7.1
16 + clsx:
17 + specifier: ^2.1.1
18 + version: 2.1.1
19 + next:
20 + specifier: ^15.3.5
21 + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
22 + react:
23 + specifier: ^19.1.0
24 + version: 19.2.8
25 + react-dom:
26 + specifier: ^19.1.0
27 + version: 19.2.8(react@19.2.8)
28 + tailwind-merge:
29 + specifier: ^2.6.0
30 + version: 2.6.1
31 + devDependencies:
32 + '@types/node':
33 + specifier: ^22.15.0
34 + version: 22.20.1
35 + '@types/react':
36 + specifier: ^19.1.0
37 + version: 19.2.18
38 + '@types/react-dom':
39 + specifier: ^19.1.0
40 + version: 19.2.4(@types/react@19.2.18)
41 + autoprefixer:
42 + specifier: ^10.4.21
43 + version: 10.5.4(postcss@8.5.26)
44 + eslint:
45 + specifier: ^8.57.1
46 + version: 8.57.1
47 + eslint-config-next:
48 + specifier: ^15.3.5
49 + version: 15.5.23(eslint@8.57.1)(typescript@5.9.3)
50 + postcss:
51 + specifier: ^8.5.3
52 + version: 8.5.26
53 + tailwindcss:
54 + specifier: ^3.4.17
55 + version: 3.4.19
56 + typescript:
57 + specifier: ^5.8.3
58 + version: 5.9.3
59 +
60 +packages:
61 +
62 + '@alloc/quick-lru@5.2.0':
63 + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
64 + engines: {node: '>=10'}
65 +
66 + '@emnapi/core@1.10.0':
67 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
68 +
69 + '@emnapi/runtime@1.10.0':
70 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
71 +
72 + '@emnapi/runtime@1.11.3':
73 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
74 +
75 + '@emnapi/wasi-threads@1.2.1':
76 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
77 +
78 + '@eslint-community/eslint-utils@4.10.1':
79 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
80 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
81 + peerDependencies:
82 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
83 +
84 + '@eslint-community/regexpp@4.12.2':
85 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
86 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
87 +
88 + '@eslint/eslintrc@2.1.4':
89 + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
90 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
91 +
92 + '@eslint/js@8.57.1':
93 + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
94 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
95 +
96 + '@humanwhocodes/config-array@0.13.0':
97 + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
98 + engines: {node: '>=10.10.0'}
99 + deprecated: Use @eslint/config-array instead
100 +
101 + '@humanwhocodes/module-importer@1.0.1':
102 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
103 + engines: {node: '>=12.22'}
104 +
105 + '@humanwhocodes/object-schema@2.0.3':
106 + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
107 + deprecated: Use @eslint/object-schema instead
108 +
109 + '@img/colour@1.1.0':
110 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
111 + engines: {node: '>=18'}
112 +
113 + '@img/sharp-darwin-arm64@0.34.5':
114 + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
115 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
116 + cpu: [arm64]
117 + os: [darwin]
118 +
119 + '@img/sharp-darwin-x64@0.34.5':
120 + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
121 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
122 + cpu: [x64]
123 + os: [darwin]
124 +
125 + '@img/sharp-libvips-darwin-arm64@1.2.4':
126 + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
127 + cpu: [arm64]
128 + os: [darwin]
129 +
130 + '@img/sharp-libvips-darwin-x64@1.2.4':
131 + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
132 + cpu: [x64]
133 + os: [darwin]
134 +
135 + '@img/sharp-libvips-linux-arm64@1.2.4':
136 + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
137 + cpu: [arm64]
138 + os: [linux]
139 + libc: [glibc]
140 +
141 + '@img/sharp-libvips-linux-arm@1.2.4':
142 + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
143 + cpu: [arm]
144 + os: [linux]
145 + libc: [glibc]
146 +
147 + '@img/sharp-libvips-linux-ppc64@1.2.4':
148 + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
149 + cpu: [ppc64]
150 + os: [linux]
151 + libc: [glibc]
152 +
153 + '@img/sharp-libvips-linux-riscv64@1.2.4':
154 + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
155 + cpu: [riscv64]
156 + os: [linux]
157 + libc: [glibc]
158 +
159 + '@img/sharp-libvips-linux-s390x@1.2.4':
160 + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
161 + cpu: [s390x]
162 + os: [linux]
163 + libc: [glibc]
164 +
165 + '@img/sharp-libvips-linux-x64@1.2.4':
166 + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
167 + cpu: [x64]
168 + os: [linux]
169 + libc: [glibc]
170 +
171 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
172 + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
173 + cpu: [arm64]
174 + os: [linux]
175 + libc: [musl]
176 +
177 + '@img/sharp-libvips-linuxmusl-x64@1.2.4':
178 + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
179 + cpu: [x64]
180 + os: [linux]
181 + libc: [musl]
182 +
183 + '@img/sharp-linux-arm64@0.34.5':
184 + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
185 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
186 + cpu: [arm64]
187 + os: [linux]
188 + libc: [glibc]
189 +
190 + '@img/sharp-linux-arm@0.34.5':
191 + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
192 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
193 + cpu: [arm]
194 + os: [linux]
195 + libc: [glibc]
196 +
197 + '@img/sharp-linux-ppc64@0.34.5':
198 + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
199 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
200 + cpu: [ppc64]
201 + os: [linux]
202 + libc: [glibc]
203 +
204 + '@img/sharp-linux-riscv64@0.34.5':
205 + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
206 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
207 + cpu: [riscv64]
208 + os: [linux]
209 + libc: [glibc]
210 +
211 + '@img/sharp-linux-s390x@0.34.5':
212 + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
213 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
214 + cpu: [s390x]
215 + os: [linux]
216 + libc: [glibc]
217 +
218 + '@img/sharp-linux-x64@0.34.5':
219 + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
220 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
221 + cpu: [x64]
222 + os: [linux]
223 + libc: [glibc]
224 +
225 + '@img/sharp-linuxmusl-arm64@0.34.5':
226 + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
227 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
228 + cpu: [arm64]
229 + os: [linux]
230 + libc: [musl]
231 +
232 + '@img/sharp-linuxmusl-x64@0.34.5':
233 + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
234 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
235 + cpu: [x64]
236 + os: [linux]
237 + libc: [musl]
238 +
239 + '@img/sharp-wasm32@0.34.5':
240 + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
241 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
242 + cpu: [wasm32]
243 +
244 + '@img/sharp-win32-arm64@0.34.5':
245 + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
246 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
247 + cpu: [arm64]
248 + os: [win32]
249 +
250 + '@img/sharp-win32-ia32@0.34.5':
251 + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
252 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
253 + cpu: [ia32]
254 + os: [win32]
255 +
256 + '@img/sharp-win32-x64@0.34.5':
257 + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
258 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
259 + cpu: [x64]
260 + os: [win32]
261 +
262 + '@jridgewell/gen-mapping@0.3.13':
263 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
264 +
265 + '@jridgewell/resolve-uri@3.1.2':
266 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
267 + engines: {node: '>=6.0.0'}
268 +
269 + '@jridgewell/sourcemap-codec@1.5.5':
270 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
271 +
272 + '@jridgewell/trace-mapping@0.3.31':
273 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
274 +
275 + '@napi-rs/wasm-runtime@1.2.3':
276 + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
277 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
278 + peerDependencies:
279 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
280 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
281 +
282 + '@next/env@15.5.23':
283 + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==}
284 +
285 + '@next/eslint-plugin-next@15.5.23':
286 + resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==}
287 +
288 + '@next/swc-darwin-arm64@15.5.23':
289 + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==}
290 + engines: {node: '>= 10'}
291 + cpu: [arm64]
292 + os: [darwin]
293 +
294 + '@next/swc-darwin-x64@15.5.23':
295 + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==}
296 + engines: {node: '>= 10'}
297 + cpu: [x64]
298 + os: [darwin]
299 +
300 + '@next/swc-linux-arm64-gnu@15.5.23':
301 + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==}
302 + engines: {node: '>= 10'}
303 + cpu: [arm64]
304 + os: [linux]
305 + libc: [glibc]
306 +
307 + '@next/swc-linux-arm64-musl@15.5.23':
308 + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==}
309 + engines: {node: '>= 10'}
310 + cpu: [arm64]
311 + os: [linux]
312 + libc: [musl]
313 +
314 + '@next/swc-linux-x64-gnu@15.5.23':
315 + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==}
316 + engines: {node: '>= 10'}
317 + cpu: [x64]
318 + os: [linux]
319 + libc: [glibc]
320 +
321 + '@next/swc-linux-x64-musl@15.5.23':
322 + resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==}
323 + engines: {node: '>= 10'}
324 + cpu: [x64]
325 + os: [linux]
326 + libc: [musl]
327 +
328 + '@next/swc-win32-arm64-msvc@15.5.23':
329 + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==}
330 + engines: {node: '>= 10'}
331 + cpu: [arm64]
332 + os: [win32]
333 +
334 + '@next/swc-win32-x64-msvc@15.5.23':
335 + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==}
336 + engines: {node: '>= 10'}
337 + cpu: [x64]
338 + os: [win32]
339 +
340 + '@nodelib/fs.scandir@2.1.5':
341 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
342 + engines: {node: '>= 8'}
343 +
344 + '@nodelib/fs.stat@2.0.5':
345 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
346 + engines: {node: '>= 8'}
347 +
348 + '@nodelib/fs.walk@1.2.8':
349 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
350 + engines: {node: '>= 8'}
351 +
352 + '@nolyfill/is-core-module@1.0.39':
353 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
354 + engines: {node: '>=12.4.0'}
355 +
356 + '@rtsao/scc@1.1.0':
357 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
358 +
359 + '@rushstack/eslint-patch@1.16.1':
360 + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
361 +
362 + '@swc/helpers@0.5.15':
363 + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
364 +
365 + '@tybys/wasm-util@0.10.3':
366 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
367 +
368 + '@types/json5@0.0.29':
369 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
370 +
371 + '@types/node@22.20.1':
372 + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
373 +
374 + '@types/react-dom@19.2.4':
375 + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
376 + peerDependencies:
377 + '@types/react': ^19.2.0
378 +
379 + '@types/react@19.2.18':
380 + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
381 +
382 + '@typescript-eslint/eslint-plugin@8.67.0':
383 + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
384 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
385 + peerDependencies:
386 + '@typescript-eslint/parser': ^8.67.0
387 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
388 + typescript: '>=4.8.4 <6.1.0'
389 +
390 + '@typescript-eslint/parser@8.67.0':
391 + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
392 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
393 + peerDependencies:
394 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
395 + typescript: '>=4.8.4 <6.1.0'
396 +
397 + '@typescript-eslint/project-service@8.67.0':
398 + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
399 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
400 + peerDependencies:
401 + typescript: '>=4.8.4 <6.1.0'
402 +
403 + '@typescript-eslint/scope-manager@8.67.0':
404 + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
405 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
406 +
407 + '@typescript-eslint/tsconfig-utils@8.67.0':
408 + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
409 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
410 + peerDependencies:
411 + typescript: '>=4.8.4 <6.1.0'
412 +
413 + '@typescript-eslint/type-utils@8.67.0':
414 + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
415 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
416 + peerDependencies:
417 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
418 + typescript: '>=4.8.4 <6.1.0'
419 +
420 + '@typescript-eslint/types@8.67.0':
421 + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
422 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
423 +
424 + '@typescript-eslint/typescript-estree@8.67.0':
425 + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
426 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
427 + peerDependencies:
428 + typescript: '>=4.8.4 <6.1.0'
429 +
430 + '@typescript-eslint/utils@8.67.0':
431 + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
432 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
433 + peerDependencies:
434 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
435 + typescript: '>=4.8.4 <6.1.0'
436 +
437 + '@typescript-eslint/visitor-keys@8.67.0':
438 + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
439 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
440 +
441 + '@ungap/structured-clone@1.3.3':
442 + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
443 +
444 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
445 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
446 + cpu: [arm]
447 + os: [android]
448 +
449 + '@unrs/resolver-binding-android-arm64@1.12.2':
450 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
451 + cpu: [arm64]
452 + os: [android]
453 +
454 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
455 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
456 + cpu: [arm64]
457 + os: [darwin]
458 +
459 + '@unrs/resolver-binding-darwin-x64@1.12.2':
460 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
461 + cpu: [x64]
462 + os: [darwin]
463 +
464 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
465 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
466 + cpu: [x64]
467 + os: [freebsd]
468 +
469 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
470 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
471 + cpu: [arm]
472 + os: [linux]
473 +
474 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
475 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
476 + cpu: [arm]
477 + os: [linux]
478 +
479 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
480 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
481 + cpu: [arm64]
482 + os: [linux]
483 + libc: [glibc]
484 +
485 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
486 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
487 + cpu: [arm64]
488 + os: [linux]
489 + libc: [musl]
490 +
491 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
492 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
493 + cpu: [loong64]
494 + os: [linux]
495 + libc: [glibc]
496 +
497 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
498 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
499 + cpu: [loong64]
500 + os: [linux]
501 + libc: [musl]
502 +
503 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
504 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
505 + cpu: [ppc64]
506 + os: [linux]
507 + libc: [glibc]
508 +
509 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
510 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
511 + cpu: [riscv64]
512 + os: [linux]
513 + libc: [glibc]
514 +
515 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
516 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
517 + cpu: [riscv64]
518 + os: [linux]
519 + libc: [musl]
520 +
521 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
522 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
523 + cpu: [s390x]
524 + os: [linux]
525 + libc: [glibc]
526 +
527 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
528 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
529 + cpu: [x64]
530 + os: [linux]
531 + libc: [glibc]
532 +
533 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
534 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
535 + cpu: [x64]
536 + os: [linux]
537 + libc: [musl]
538 +
539 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
540 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
541 + cpu: [arm64]
542 + os: [openharmony]
543 +
544 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
545 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
546 + engines: {node: '>=14.0.0'}
547 + cpu: [wasm32]
548 +
549 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
550 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
551 + cpu: [arm64]
552 + os: [win32]
553 +
554 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
555 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
556 + cpu: [ia32]
557 + os: [win32]
558 +
559 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
560 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
561 + cpu: [x64]
562 + os: [win32]
563 +
564 + acorn-jsx@5.3.2:
565 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
566 + peerDependencies:
567 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
568 +
569 + acorn@8.18.0:
570 + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
571 + engines: {node: '>=0.4.0'}
572 + hasBin: true
573 +
574 + ajv@6.15.0:
575 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
576 +
577 + ansi-regex@5.0.1:
578 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
579 + engines: {node: '>=8'}
580 +
581 + ansi-styles@4.3.0:
582 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
583 + engines: {node: '>=8'}
584 +
585 + any-promise@1.3.0:
586 + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
587 +
588 + anymatch@3.1.3:
589 + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
590 + engines: {node: '>= 8'}
591 +
592 + arg@5.0.2:
593 + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
594 +
595 + argparse@2.0.1:
596 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
597 +
598 + aria-query@5.3.2:
599 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
600 + engines: {node: '>= 0.4'}
601 +
602 + array-buffer-byte-length@1.0.2:
603 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
604 + engines: {node: '>= 0.4'}
605 +
606 + array-includes@3.1.9:
607 + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
608 + engines: {node: '>= 0.4'}
609 +
610 + array.prototype.findlast@1.2.5:
611 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
612 + engines: {node: '>= 0.4'}
613 +
614 + array.prototype.findlastindex@1.2.6:
615 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
616 + engines: {node: '>= 0.4'}
617 +
618 + array.prototype.flat@1.3.3:
619 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
620 + engines: {node: '>= 0.4'}
621 +
622 + array.prototype.flatmap@1.3.3:
623 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
624 + engines: {node: '>= 0.4'}
625 +
626 + array.prototype.tosorted@1.1.4:
627 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
628 + engines: {node: '>= 0.4'}
629 +
630 + arraybuffer.prototype.slice@1.0.4:
631 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
632 + engines: {node: '>= 0.4'}
633 +
634 + ast-types-flow@0.0.8:
635 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
636 +
637 + async-function@1.0.0:
638 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
639 + engines: {node: '>= 0.4'}
640 +
641 + autoprefixer@10.5.4:
642 + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==}
643 + engines: {node: ^10 || ^12 || >=14}
644 + hasBin: true
645 + peerDependencies:
646 + postcss: ^8.1.0
647 +
648 + available-typed-arrays@1.0.7:
649 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
650 + engines: {node: '>= 0.4'}
651 +
652 + axe-core@4.13.0:
653 + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
654 + engines: {node: '>=4'}
655 +
656 + axobject-query@4.1.0:
657 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
658 + engines: {node: '>= 0.4'}
659 +
660 + balanced-match@1.0.2:
661 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
662 +
663 + balanced-match@4.0.4:
664 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
665 + engines: {node: 18 || 20 || >=22}
666 +
667 + baseline-browser-mapping@2.11.13:
668 + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
669 + engines: {node: '>=6.0.0'}
670 + hasBin: true
671 +
672 + binary-extensions@2.3.0:
673 + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
674 + engines: {node: '>=8'}
675 +
676 + brace-expansion@1.1.18:
677 + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
678 +
679 + brace-expansion@5.0.9:
680 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
681 + engines: {node: 20 || >=22}
682 +
683 + braces@3.0.3:
684 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
685 + engines: {node: '>=8'}
686 +
687 + browserslist@4.28.8:
688 + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
689 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
690 + hasBin: true
691 +
692 + call-bind-apply-helpers@1.0.2:
693 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
694 + engines: {node: '>= 0.4'}
695 +
696 + call-bind@1.0.9:
697 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
698 + engines: {node: '>= 0.4'}
699 +
700 + call-bound@1.0.4:
701 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
702 + engines: {node: '>= 0.4'}
703 +
704 + callsites@3.1.0:
705 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
706 + engines: {node: '>=6'}
707 +
708 + camelcase-css@2.0.1:
709 + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
710 + engines: {node: '>= 6'}
711 +
712 + caniuse-lite@1.0.30001809:
713 + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
714 +
715 + chalk@4.1.2:
716 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
717 + engines: {node: '>=10'}
718 +
719 + chokidar@3.6.0:
720 + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
721 + engines: {node: '>= 8.10.0'}
722 +
723 + class-variance-authority@0.7.1:
724 + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
725 +
726 + client-only@0.0.1:
727 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
728 +
729 + clsx@2.1.1:
730 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
731 + engines: {node: '>=6'}
732 +
733 + color-convert@2.0.1:
734 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
735 + engines: {node: '>=7.0.0'}
736 +
737 + color-name@1.1.4:
738 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
739 +
740 + commander@4.1.1:
741 + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
742 + engines: {node: '>= 6'}
743 +
744 + concat-map@0.0.1:
745 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
746 +
747 + cross-spawn@7.0.6:
748 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
749 + engines: {node: '>= 8'}
750 +
751 + cssesc@3.0.0:
752 + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
753 + engines: {node: '>=4'}
754 + hasBin: true
755 +
756 + csstype@3.2.3:
757 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
758 +
759 + damerau-levenshtein@1.0.8:
760 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
761 +
762 + data-view-buffer@1.0.2:
763 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
764 + engines: {node: '>= 0.4'}
765 +
766 + data-view-byte-length@1.0.2:
767 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
768 + engines: {node: '>= 0.4'}
769 +
770 + data-view-byte-offset@1.0.1:
771 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
772 + engines: {node: '>= 0.4'}
773 +
774 + debug@3.2.7:
775 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
776 + peerDependencies:
777 + supports-color: '*'
778 + peerDependenciesMeta:
779 + supports-color:
780 + optional: true
781 +
782 + debug@4.4.3:
783 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
784 + engines: {node: '>=6.0'}
785 + peerDependencies:
786 + supports-color: '*'
787 + peerDependenciesMeta:
788 + supports-color:
789 + optional: true
790 +
791 + deep-is@0.1.4:
792 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
793 +
794 + define-data-property@1.1.4:
795 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
796 + engines: {node: '>= 0.4'}
797 +
798 + define-properties@1.2.1:
799 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
800 + engines: {node: '>= 0.4'}
801 +
802 + detect-libc@2.1.2:
803 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
804 + engines: {node: '>=8'}
805 +
806 + didyoumean@1.2.2:
807 + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
808 +
809 + dlv@1.1.3:
810 + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
811 +
812 + doctrine@2.1.0:
813 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
814 + engines: {node: '>=0.10.0'}
815 +
816 + doctrine@3.0.0:
817 + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
818 + engines: {node: '>=6.0.0'}
819 +
820 + dunder-proto@1.0.1:
821 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
822 + engines: {node: '>= 0.4'}
823 +
824 + electron-to-chromium@1.5.405:
825 + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
826 +
827 + emoji-regex@9.2.2:
828 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
829 +
830 + es-abstract-get@1.0.0:
831 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
832 + engines: {node: '>= 0.4'}
833 +
834 + es-abstract@1.24.2:
835 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
836 + engines: {node: '>= 0.4'}
837 +
838 + es-define-property@1.0.1:
839 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
840 + engines: {node: '>= 0.4'}
841 +
842 + es-errors@1.3.0:
843 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
844 + engines: {node: '>= 0.4'}
845 +
846 + es-iterator-helpers@1.4.0:
847 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
848 + engines: {node: '>= 0.4'}
849 +
850 + es-object-atoms@1.1.2:
851 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
852 + engines: {node: '>= 0.4'}
853 +
854 + es-set-tostringtag@2.1.0:
855 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
856 + engines: {node: '>= 0.4'}
857 +
858 + es-shim-unscopables@1.1.0:
859 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
860 + engines: {node: '>= 0.4'}
861 +
862 + es-to-primitive@1.3.4:
863 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
864 + engines: {node: '>= 0.4'}
865 +
866 + escalade@3.2.0:
867 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
868 + engines: {node: '>=6'}
869 +
870 + escape-string-regexp@4.0.0:
871 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
872 + engines: {node: '>=10'}
873 +
874 + eslint-config-next@15.5.23:
875 + resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==}
876 + peerDependencies:
877 + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
878 + typescript: '>=3.3.1'
879 + peerDependenciesMeta:
880 + typescript:
881 + optional: true
882 +
883 + eslint-import-resolver-node@0.3.10:
884 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
885 +
886 + eslint-import-resolver-typescript@3.10.1:
887 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
888 + engines: {node: ^14.18.0 || >=16.0.0}
889 + peerDependencies:
890 + eslint: '*'
891 + eslint-plugin-import: '*'
892 + eslint-plugin-import-x: '*'
893 + peerDependenciesMeta:
894 + eslint-plugin-import:
895 + optional: true
896 + eslint-plugin-import-x:
897 + optional: true
898 +
899 + eslint-module-utils@2.14.0:
900 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
901 + engines: {node: '>=4'}
902 + peerDependencies:
903 + '@typescript-eslint/parser': '*'
904 + eslint: '*'
905 + eslint-import-resolver-node: '*'
906 + eslint-import-resolver-typescript: '*'
907 + eslint-import-resolver-webpack: '*'
908 + peerDependenciesMeta:
909 + '@typescript-eslint/parser':
910 + optional: true
911 + eslint:
912 + optional: true
913 + eslint-import-resolver-node:
914 + optional: true
915 + eslint-import-resolver-typescript:
916 + optional: true
917 + eslint-import-resolver-webpack:
918 + optional: true
919 +
920 + eslint-plugin-import@2.32.0:
921 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
922 + engines: {node: '>=4'}
923 + peerDependencies:
924 + '@typescript-eslint/parser': '*'
925 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
926 + peerDependenciesMeta:
927 + '@typescript-eslint/parser':
928 + optional: true
929 +
930 + eslint-plugin-jsx-a11y@6.10.2:
931 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
932 + engines: {node: '>=4.0'}
933 + peerDependencies:
934 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
935 +
936 + eslint-plugin-react-hooks@5.2.0:
937 + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
938 + engines: {node: '>=10'}
939 + peerDependencies:
940 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
941 +
942 + eslint-plugin-react@7.37.5:
943 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
944 + engines: {node: '>=4'}
945 + peerDependencies:
946 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
947 +
948 + eslint-scope@7.2.2:
949 + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
950 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
951 +
952 + eslint-visitor-keys@3.4.3:
953 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
954 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
955 +
956 + eslint-visitor-keys@5.0.1:
957 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
958 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
959 +
960 + eslint@8.57.1:
961 + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
962 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
963 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
964 + hasBin: true
965 +
966 + espree@9.6.1:
967 + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
968 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
969 +
970 + esquery@1.7.0:
971 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
972 + engines: {node: '>=0.10'}
973 +
974 + esrecurse@4.3.0:
975 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
976 + engines: {node: '>=4.0'}
977 +
978 + estraverse@5.3.0:
979 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
980 + engines: {node: '>=4.0'}
981 +
982 + esutils@2.0.3:
983 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
984 + engines: {node: '>=0.10.0'}
985 +
986 + fast-deep-equal@3.1.3:
987 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
988 +
989 + fast-glob@3.3.1:
990 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
991 + engines: {node: '>=8.6.0'}
992 +
993 + fast-glob@3.3.3:
994 + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
995 + engines: {node: '>=8.6.0'}
996 +
997 + fast-json-stable-stringify@2.1.0:
998 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
999 +
1000 + fast-levenshtein@2.0.6:
1001 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1002 +
1003 + fastq@1.20.1:
1004 + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
1005 +
1006 + fdir@6.5.0:
1007 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1008 + engines: {node: '>=12.0.0'}
1009 + peerDependencies:
1010 + picomatch: ^3 || ^4
1011 + peerDependenciesMeta:
1012 + picomatch:
1013 + optional: true
1014 +
1015 + file-entry-cache@6.0.1:
1016 + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
1017 + engines: {node: ^10.12.0 || >=12.0.0}
1018 +
1019 + fill-range@7.1.1:
1020 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1021 + engines: {node: '>=8'}
1022 +
1023 + find-up@5.0.0:
1024 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1025 + engines: {node: '>=10'}
1026 +
1027 + flat-cache@3.2.0:
1028 + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
1029 + engines: {node: ^10.12.0 || >=12.0.0}
1030 +
1031 + flatted@3.4.4:
1032 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
1033 +
1034 + for-each@0.3.5:
1035 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1036 + engines: {node: '>= 0.4'}
1037 +
1038 + fraction.js@5.3.4:
1039 + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
1040 +
1041 + fs.realpath@1.0.0:
1042 + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
1043 +
1044 + fsevents@2.3.3:
1045 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1046 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1047 + os: [darwin]
1048 +
1049 + function-bind@1.1.2:
1050 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1051 +
1052 + function.prototype.name@1.2.0:
1053 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
1054 + engines: {node: '>= 0.4'}
1055 +
1056 + functions-have-names@1.2.3:
1057 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
1058 +
1059 + generator-function@2.0.1:
1060 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
1061 + engines: {node: '>= 0.4'}
1062 +
1063 + get-intrinsic@1.3.0:
1064 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1065 + engines: {node: '>= 0.4'}
1066 +
1067 + get-proto@1.0.1:
1068 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1069 + engines: {node: '>= 0.4'}
1070 +
1071 + get-symbol-description@1.1.0:
1072 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
1073 + engines: {node: '>= 0.4'}
1074 +
1075 + get-tsconfig@4.14.2:
1076 + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==}
1077 +
1078 + glob-parent@5.1.2:
1079 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1080 + engines: {node: '>= 6'}
1081 +
1082 + glob-parent@6.0.2:
1083 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1084 + engines: {node: '>=10.13.0'}
1085 +
1086 + glob@7.2.3:
1087 + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
1088 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
1089 +
1090 + globals@13.24.0:
1091 + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
1092 + engines: {node: '>=8'}
1093 +
1094 + globalthis@1.0.4:
1095 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
1096 + engines: {node: '>= 0.4'}
1097 +
1098 + gopd@1.2.0:
1099 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1100 + engines: {node: '>= 0.4'}
1101 +
1102 + graphemer@1.4.0:
1103 + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
1104 +
1105 + has-bigints@1.1.0:
1106 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
1107 + engines: {node: '>= 0.4'}
1108 +
1109 + has-flag@4.0.0:
1110 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1111 + engines: {node: '>=8'}
1112 +
1113 + has-property-descriptors@1.0.2:
1114 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
1115 +
1116 + has-proto@1.2.0:
1117 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
1118 + engines: {node: '>= 0.4'}
1119 +
1120 + has-symbols@1.1.0:
1121 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1122 + engines: {node: '>= 0.4'}
1123 +
1124 + has-tostringtag@1.0.2:
1125 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1126 + engines: {node: '>= 0.4'}
1127 +
1128 + hasown@2.0.4:
1129 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1130 + engines: {node: '>= 0.4'}
1131 +
1132 + ignore@5.3.2:
1133 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1134 + engines: {node: '>= 4'}
1135 +
1136 + ignore@7.0.6:
1137 + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
1138 + engines: {node: '>= 4'}
1139 +
1140 + import-fresh@3.3.1:
1141 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1142 + engines: {node: '>=6'}
1143 +
1144 + imurmurhash@0.1.4:
1145 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1146 + engines: {node: '>=0.8.19'}
1147 +
1148 + inflight@1.0.6:
1149 + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
1150 + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
1151 +
1152 + inherits@2.0.4:
1153 + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
1154 +
1155 + internal-slot@1.1.0:
1156 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1157 + engines: {node: '>= 0.4'}
1158 +
1159 + is-array-buffer@3.0.5:
1160 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1161 + engines: {node: '>= 0.4'}
1162 +
1163 + is-async-function@2.1.1:
1164 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
1165 + engines: {node: '>= 0.4'}
1166 +
1167 + is-bigint@1.1.0:
1168 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
1169 + engines: {node: '>= 0.4'}
1170 +
1171 + is-binary-path@2.1.0:
1172 + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
1173 + engines: {node: '>=8'}
1174 +
1175 + is-boolean-object@1.2.2:
1176 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
1177 + engines: {node: '>= 0.4'}
1178 +
1179 + is-bun-module@2.0.0:
1180 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
1181 +
1182 + is-callable@1.2.7:
1183 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
1184 + engines: {node: '>= 0.4'}
1185 +
1186 + is-core-module@2.16.2:
1187 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
1188 + engines: {node: '>= 0.4'}
1189 +
1190 + is-data-view@1.0.2:
1191 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
1192 + engines: {node: '>= 0.4'}
1193 +
1194 + is-date-object@1.1.0:
1195 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1196 + engines: {node: '>= 0.4'}
1197 +
1198 + is-document.all@1.0.0:
1199 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1200 + engines: {node: '>= 0.4'}
1201 +
1202 + is-extglob@2.1.1:
1203 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1204 + engines: {node: '>=0.10.0'}
1205 +
1206 + is-finalizationregistry@1.1.1:
1207 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
1208 + engines: {node: '>= 0.4'}
1209 +
1210 + is-generator-function@1.1.2:
1211 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
1212 + engines: {node: '>= 0.4'}
1213 +
1214 + is-glob@4.0.3:
1215 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1216 + engines: {node: '>=0.10.0'}
1217 +
1218 + is-map@2.0.3:
1219 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
1220 + engines: {node: '>= 0.4'}
1221 +
1222 + is-negative-zero@2.0.3:
1223 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
1224 + engines: {node: '>= 0.4'}
1225 +
1226 + is-number-object@1.1.1:
1227 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
1228 + engines: {node: '>= 0.4'}
1229 +
1230 + is-number@7.0.0:
1231 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1232 + engines: {node: '>=0.12.0'}
1233 +
1234 + is-path-inside@3.0.3:
1235 + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
1236 + engines: {node: '>=8'}
1237 +
1238 + is-regex@1.2.1:
1239 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1240 + engines: {node: '>= 0.4'}
1241 +
1242 + is-set@2.0.3:
1243 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
1244 + engines: {node: '>= 0.4'}
1245 +
1246 + is-shared-array-buffer@1.0.4:
1247 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
1248 + engines: {node: '>= 0.4'}
1249 +
1250 + is-string@1.1.1:
1251 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
1252 + engines: {node: '>= 0.4'}
1253 +
1254 + is-symbol@1.1.1:
1255 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
1256 + engines: {node: '>= 0.4'}
1257 +
1258 + is-typed-array@1.1.15:
1259 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
1260 + engines: {node: '>= 0.4'}
1261 +
1262 + is-weakmap@2.0.2:
1263 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
1264 + engines: {node: '>= 0.4'}
1265 +
1266 + is-weakref@1.1.1:
1267 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
1268 + engines: {node: '>= 0.4'}
1269 +
1270 + is-weakset@2.0.4:
1271 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
1272 + engines: {node: '>= 0.4'}
1273 +
1274 + isarray@2.0.5:
1275 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
1276 +
1277 + isexe@2.0.0:
1278 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1279 +
1280 + iterator.prototype@1.1.5:
1281 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
1282 + engines: {node: '>= 0.4'}
1283 +
1284 + jiti@1.21.7:
1285 + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
1286 + hasBin: true
1287 +
1288 + js-tokens@4.0.0:
1289 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1290 +
1291 + js-yaml@4.3.1:
1292 + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
1293 + hasBin: true
1294 +
1295 + json-buffer@3.0.1:
1296 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
1297 +
1298 + json-schema-traverse@0.4.1:
1299 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
1300 +
1301 + json-stable-stringify-without-jsonify@1.0.1:
1302 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
1303 +
1304 + json5@1.0.2:
1305 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
1306 + hasBin: true
1307 +
1308 + jsx-ast-utils@3.3.5:
1309 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
1310 + engines: {node: '>=4.0'}
1311 +
1312 + keyv@4.5.4:
1313 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
1314 +
1315 + language-subtag-registry@0.3.23:
1316 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
1317 +
1318 + language-tags@1.0.9:
1319 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
1320 + engines: {node: '>=0.10'}
1321 +
1322 + levn@0.4.1:
1323 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
1324 + engines: {node: '>= 0.8.0'}
1325 +
1326 + lilconfig@3.1.3:
1327 + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
1328 + engines: {node: '>=14'}
1329 +
1330 + lines-and-columns@1.2.4:
1331 + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
1332 +
1333 + locate-path@6.0.0:
1334 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
1335 + engines: {node: '>=10'}
1336 +
1337 + lodash.merge@4.6.2:
1338 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
1339 +
1340 + loose-envify@1.4.0:
1341 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1342 + hasBin: true
1343 +
1344 + math-intrinsics@1.1.0:
1345 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
1346 + engines: {node: '>= 0.4'}
1347 +
1348 + merge2@1.4.1:
1349 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
1350 + engines: {node: '>= 8'}
1351 +
1352 + micromatch@4.0.8:
1353 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1354 + engines: {node: '>=8.6'}
1355 +
1356 + minimatch@10.2.6:
1357 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1358 + engines: {node: 18 || 20 || >=22}
1359 +
1360 + minimatch@3.1.5:
1361 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
1362 +
1363 + minimist@1.2.8:
1364 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
1365 +
1366 + ms@2.1.3:
1367 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1368 +
1369 + mz@2.7.0:
1370 + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
1371 +
1372 + nanoid@3.3.18:
1373 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
1374 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1375 + hasBin: true
1376 +
1377 + napi-postinstall@0.3.4:
1378 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
1379 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
1380 + hasBin: true
1381 +
1382 + natural-compare@1.4.0:
1383 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
1384 +
1385 + next@15.5.23:
1386 + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==}
1387 + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
1388 + hasBin: true
1389 + peerDependencies:
1390 + '@opentelemetry/api': ^1.1.0
1391 + '@playwright/test': ^1.51.1
1392 + babel-plugin-react-compiler: '*'
1393 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1394 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1395 + sass: ^1.3.0
1396 + peerDependenciesMeta:
1397 + '@opentelemetry/api':
1398 + optional: true
1399 + '@playwright/test':
1400 + optional: true
1401 + babel-plugin-react-compiler:
1402 + optional: true
1403 + sass:
1404 + optional: true
1405 +
1406 + node-exports-info@1.6.2:
1407 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
1408 + engines: {node: '>= 0.4'}
1409 +
1410 + node-releases@2.0.53:
1411 + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
1412 + engines: {node: '>=18'}
1413 +
1414 + normalize-path@3.0.0:
1415 + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
1416 + engines: {node: '>=0.10.0'}
1417 +
1418 + object-assign@4.1.1:
1419 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1420 + engines: {node: '>=0.10.0'}
1421 +
1422 + object-hash@3.0.0:
1423 + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
1424 + engines: {node: '>= 6'}
1425 +
1426 + object-inspect@1.13.4:
1427 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
1428 + engines: {node: '>= 0.4'}
1429 +
1430 + object-keys@1.1.1:
1431 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
1432 + engines: {node: '>= 0.4'}
1433 +
1434 + object.assign@4.1.7:
1435 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
1436 + engines: {node: '>= 0.4'}
1437 +
1438 + object.entries@1.1.9:
1439 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
1440 + engines: {node: '>= 0.4'}
1441 +
1442 + object.fromentries@2.0.8:
1443 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
1444 + engines: {node: '>= 0.4'}
1445 +
1446 + object.groupby@1.0.3:
1447 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
1448 + engines: {node: '>= 0.4'}
1449 +
1450 + object.values@1.2.1:
1451 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1452 + engines: {node: '>= 0.4'}
1453 +
1454 + once@1.4.0:
1455 + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
1456 +
1457 + optionator@0.9.4:
1458 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1459 + engines: {node: '>= 0.8.0'}
1460 +
1461 + own-keys@1.0.2:
1462 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
1463 + engines: {node: '>= 0.4'}
1464 +
1465 + p-limit@3.1.0:
1466 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
1467 + engines: {node: '>=10'}
1468 +
1469 + p-locate@5.0.0:
1470 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
1471 + engines: {node: '>=10'}
1472 +
1473 + parent-module@1.0.1:
1474 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1475 + engines: {node: '>=6'}
1476 +
1477 + path-exists@4.0.0:
1478 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1479 + engines: {node: '>=8'}
1480 +
1481 + path-is-absolute@1.0.1:
1482 + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
1483 + engines: {node: '>=0.10.0'}
1484 +
1485 + path-key@3.1.1:
1486 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
1487 + engines: {node: '>=8'}
1488 +
1489 + path-parse@1.0.7:
1490 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
1491 +
1492 + picocolors@1.1.1:
1493 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1494 +
1495 + picomatch@2.3.2:
1496 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
1497 + engines: {node: '>=8.6'}
1498 +
1499 + picomatch@4.0.5:
1500 + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
1501 + engines: {node: '>=12'}
1502 +
1503 + pify@2.3.0:
1504 + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
1505 + engines: {node: '>=0.10.0'}
1506 +
1507 + pirates@4.0.7:
1508 + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
1509 + engines: {node: '>= 6'}
1510 +
1511 + possible-typed-array-names@1.1.0:
1512 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1513 + engines: {node: '>= 0.4'}
1514 +
1515 + postcss-import@15.1.0:
1516 + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
1517 + engines: {node: '>=14.0.0'}
1518 + peerDependencies:
1519 + postcss: ^8.0.0
1520 +
1521 + postcss-js@4.1.0:
1522 + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
1523 + engines: {node: ^12 || ^14 || >= 16}
1524 + peerDependencies:
1525 + postcss: ^8.4.21
1526 +
1527 + postcss-load-config@6.0.1:
1528 + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
1529 + engines: {node: '>= 18'}
1530 + peerDependencies:
1531 + jiti: '>=1.21.0'
1532 + postcss: '>=8.0.9'
1533 + tsx: ^4.8.1
1534 + yaml: ^2.4.2
1535 + peerDependenciesMeta:
1536 + jiti:
1537 + optional: true
1538 + postcss:
1539 + optional: true
1540 + tsx:
1541 + optional: true
1542 + yaml:
1543 + optional: true
1544 +
1545 + postcss-nested@6.2.0:
1546 + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
1547 + engines: {node: '>=12.0'}
1548 + peerDependencies:
1549 + postcss: ^8.2.14
1550 +
1551 + postcss-selector-parser@6.1.4:
1552 + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
1553 + engines: {node: '>=4'}
1554 +
1555 + postcss-value-parser@4.2.0:
1556 + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
1557 +
1558 + postcss@8.4.31:
1559 + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
1560 + engines: {node: ^10 || ^12 || >=14}
1561 +
1562 + postcss@8.5.26:
1563 + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
1564 + engines: {node: ^10 || ^12 || >=14}
1565 +
1566 + prelude-ls@1.2.1:
1567 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1568 + engines: {node: '>= 0.8.0'}
1569 +
1570 + prop-types@15.8.1:
1571 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1572 +
1573 + punycode@2.3.1:
1574 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1575 + engines: {node: '>=6'}
1576 +
1577 + queue-microtask@1.2.3:
1578 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1579 +
1580 + react-dom@19.2.8:
1581 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1582 + peerDependencies:
1583 + react: ^19.2.8
1584 +
1585 + react-is@16.13.1:
1586 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1587 +
1588 + react@19.2.8:
1589 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1590 + engines: {node: '>=0.10.0'}
1591 +
1592 + read-cache@1.0.0:
1593 + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
1594 +
1595 + readdirp@3.6.0:
1596 + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
1597 + engines: {node: '>=8.10.0'}
1598 +
1599 + reflect.getprototypeof@1.0.10:
1600 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1601 + engines: {node: '>= 0.4'}
1602 +
1603 + regexp.prototype.flags@1.5.4:
1604 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
1605 + engines: {node: '>= 0.4'}
1606 +
1607 + resolve-from@4.0.0:
1608 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
1609 + engines: {node: '>=4'}
1610 +
1611 + resolve-pkg-maps@1.0.0:
1612 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1613 +
1614 + resolve@1.22.12:
1615 + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
1616 + engines: {node: '>= 0.4'}
1617 + hasBin: true
1618 +
1619 + resolve@2.0.0-next.7:
1620 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
1621 + engines: {node: '>= 0.4'}
1622 + hasBin: true
1623 +
1624 + reusify@1.1.0:
1625 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1626 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1627 +
1628 + rimraf@3.0.2:
1629 + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
1630 + deprecated: Rimraf versions prior to v4 are no longer supported
1631 + hasBin: true
1632 +
1633 + run-parallel@1.2.0:
1634 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1635 +
1636 + safe-array-concat@1.1.4:
1637 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
1638 + engines: {node: '>=0.4'}
1639 +
1640 + safe-push-apply@1.0.0:
1641 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
1642 + engines: {node: '>= 0.4'}
1643 +
1644 + safe-regex-test@1.1.0:
1645 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1646 + engines: {node: '>= 0.4'}
1647 +
1648 + scheduler@0.27.0:
1649 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1650 +
1651 + semver@6.3.1:
1652 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
1653 + hasBin: true
1654 +
1655 + semver@7.8.5:
1656 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1657 + engines: {node: '>=10'}
1658 + hasBin: true
1659 +
1660 + set-function-length@1.2.2:
1661 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
1662 + engines: {node: '>= 0.4'}
1663 +
1664 + set-function-name@2.0.2:
1665 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
1666 + engines: {node: '>= 0.4'}
1667 +
1668 + set-proto@1.0.0:
1669 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
1670 + engines: {node: '>= 0.4'}
1671 +
1672 + sharp@0.34.5:
1673 + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
1674 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
1675 +
1676 + shebang-command@2.0.0:
1677 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
1678 + engines: {node: '>=8'}
1679 +
1680 + shebang-regex@3.0.0:
1681 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1682 + engines: {node: '>=8'}
1683 +
1684 + side-channel-list@1.0.1:
1685 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
1686 + engines: {node: '>= 0.4'}
1687 +
1688 + side-channel-map@1.0.1:
1689 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
1690 + engines: {node: '>= 0.4'}
1691 +
1692 + side-channel-weakmap@1.0.2:
1693 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
1694 + engines: {node: '>= 0.4'}
1695 +
1696 + side-channel@1.1.1:
1697 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
1698 + engines: {node: '>= 0.4'}
1699 +
1700 + source-map-js@1.2.1:
1701 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1702 + engines: {node: '>=0.10.0'}
1703 +
1704 + stable-hash@0.0.5:
1705 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
1706 +
1707 + stop-iteration-iterator@1.1.0:
1708 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1709 + engines: {node: '>= 0.4'}
1710 +
1711 + string.prototype.includes@2.0.1:
1712 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
1713 + engines: {node: '>= 0.4'}
1714 +
1715 + string.prototype.matchall@4.0.12:
1716 + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
1717 + engines: {node: '>= 0.4'}
1718 +
1719 + string.prototype.repeat@1.0.0:
1720 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
1721 +
1722 + string.prototype.trim@1.2.11:
1723 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
1724 + engines: {node: '>= 0.4'}
1725 +
1726 + string.prototype.trimend@1.0.10:
1727 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
1728 + engines: {node: '>= 0.4'}
1729 +
1730 + string.prototype.trimstart@1.0.8:
1731 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
1732 + engines: {node: '>= 0.4'}
1733 +
1734 + strip-ansi@6.0.1:
1735 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
1736 + engines: {node: '>=8'}
1737 +
1738 + strip-bom@3.0.0:
1739 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1740 + engines: {node: '>=4'}
1741 +
1742 + strip-json-comments@3.1.1:
1743 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1744 + engines: {node: '>=8'}
1745 +
1746 + styled-jsx@5.1.6:
1747 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
1748 + engines: {node: '>= 12.0.0'}
1749 + peerDependencies:
1750 + '@babel/core': '*'
1751 + babel-plugin-macros: '*'
1752 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
1753 + peerDependenciesMeta:
1754 + '@babel/core':
1755 + optional: true
1756 + babel-plugin-macros:
1757 + optional: true
1758 +
1759 + sucrase@3.35.1:
1760 + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
1761 + engines: {node: '>=16 || 14 >=14.17'}
1762 + hasBin: true
1763 +
1764 + supports-color@7.2.0:
1765 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
1766 + engines: {node: '>=8'}
1767 +
1768 + supports-preserve-symlinks-flag@1.0.0:
1769 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
1770 + engines: {node: '>= 0.4'}
1771 +
1772 + tailwind-merge@2.6.1:
1773 + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
1774 +
1775 + tailwindcss@3.4.19:
1776 + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
1777 + engines: {node: '>=14.0.0'}
1778 + hasBin: true
1779 +
1780 + text-table@0.2.0:
1781 + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
1782 +
1783 + thenify-all@1.6.0:
1784 + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
1785 + engines: {node: '>=0.8'}
1786 +
1787 + thenify@3.3.1:
1788 + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
1789 +
1790 + tinyglobby@0.2.17:
1791 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1792 + engines: {node: '>=12.0.0'}
1793 +
1794 + to-regex-range@5.0.1:
1795 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1796 + engines: {node: '>=8.0'}
1797 +
1798 + ts-api-utils@2.5.0:
1799 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
1800 + engines: {node: '>=18.12'}
1801 + peerDependencies:
1802 + typescript: '>=4.8.4'
1803 +
1804 + ts-interface-checker@0.1.13:
1805 + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
1806 +
1807 + tsconfig-paths@3.15.0:
1808 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
1809 +
1810 + tslib@2.8.1:
1811 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
1812 +
1813 + type-check@0.4.0:
1814 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
1815 + engines: {node: '>= 0.8.0'}
1816 +
1817 + type-fest@0.20.2:
1818 + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
1819 + engines: {node: '>=10'}
1820 +
1821 + typed-array-buffer@1.0.3:
1822 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
1823 + engines: {node: '>= 0.4'}
1824 +
1825 + typed-array-byte-length@1.0.3:
1826 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
1827 + engines: {node: '>= 0.4'}
1828 +
1829 + typed-array-byte-offset@1.0.4:
1830 + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
1831 + engines: {node: '>= 0.4'}
1832 +
1833 + typed-array-length@1.0.8:
1834 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
1835 + engines: {node: '>= 0.4'}
1836 +
1837 + typescript@5.9.3:
1838 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
1839 + engines: {node: '>=14.17'}
1840 + hasBin: true
1841 +
1842 + unbox-primitive@1.1.0:
1843 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
1844 + engines: {node: '>= 0.4'}
1845 +
1846 + undici-types@6.21.0:
1847 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
1848 +
1849 + unrs-resolver@1.12.2:
1850 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
1851 +
1852 + update-browserslist-db@1.3.1:
1853 + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
1854 + hasBin: true
1855 + peerDependencies:
1856 + browserslist: '>= 4.21.0'
1857 +
1858 + uri-js@4.4.1:
1859 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
1860 +
1861 + util-deprecate@1.0.2:
1862 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
1863 +
1864 + which-boxed-primitive@1.1.1:
1865 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
1866 + engines: {node: '>= 0.4'}
1867 +
1868 + which-builtin-type@1.2.1:
1869 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
1870 + engines: {node: '>= 0.4'}
1871 +
1872 + which-collection@1.0.2:
1873 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
1874 + engines: {node: '>= 0.4'}
1875 +
1876 + which-typed-array@1.1.22:
1877 + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
1878 + engines: {node: '>= 0.4'}
1879 +
1880 + which@2.0.2:
1881 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
1882 + engines: {node: '>= 8'}
1883 + hasBin: true
1884 +
1885 + word-wrap@1.2.5:
1886 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
1887 + engines: {node: '>=0.10.0'}
1888 +
1889 + wrappy@1.0.2:
1890 + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
1891 +
1892 + yocto-queue@0.1.0:
1893 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
1894 + engines: {node: '>=10'}
1895 +
1896 +snapshots:
1897 +
1898 + '@alloc/quick-lru@5.2.0': {}
1899 +
1900 + '@emnapi/core@1.10.0':
1901 + dependencies:
1902 + '@emnapi/wasi-threads': 1.2.1
1903 + tslib: 2.8.1
1904 + optional: true
1905 +
1906 + '@emnapi/runtime@1.10.0':
1907 + dependencies:
1908 + tslib: 2.8.1
1909 + optional: true
1910 +
1911 + '@emnapi/runtime@1.11.3':
1912 + dependencies:
1913 + tslib: 2.8.1
1914 + optional: true
1915 +
1916 + '@emnapi/wasi-threads@1.2.1':
1917 + dependencies:
1918 + tslib: 2.8.1
1919 + optional: true
1920 +
1921 + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)':
1922 + dependencies:
1923 + eslint: 8.57.1
1924 + eslint-visitor-keys: 3.4.3
1925 +
1926 + '@eslint-community/regexpp@4.12.2': {}
1927 +
1928 + '@eslint/eslintrc@2.1.4':
1929 + dependencies:
1930 + ajv: 6.15.0
1931 + debug: 4.4.3
1932 + espree: 9.6.1
1933 + globals: 13.24.0
1934 + ignore: 5.3.2
1935 + import-fresh: 3.3.1
1936 + js-yaml: 4.3.1
1937 + minimatch: 3.1.5
1938 + strip-json-comments: 3.1.1
1939 + transitivePeerDependencies:
1940 + - supports-color
1941 +
1942 + '@eslint/js@8.57.1': {}
1943 +
1944 + '@humanwhocodes/config-array@0.13.0':
1945 + dependencies:
1946 + '@humanwhocodes/object-schema': 2.0.3
1947 + debug: 4.4.3
1948 + minimatch: 3.1.5
1949 + transitivePeerDependencies:
1950 + - supports-color
1951 +
1952 + '@humanwhocodes/module-importer@1.0.1': {}
1953 +
1954 + '@humanwhocodes/object-schema@2.0.3': {}
1955 +
1956 + '@img/colour@1.1.0':
1957 + optional: true
1958 +
1959 + '@img/sharp-darwin-arm64@0.34.5':
1960 + optionalDependencies:
1961 + '@img/sharp-libvips-darwin-arm64': 1.2.4
1962 + optional: true
1963 +
1964 + '@img/sharp-darwin-x64@0.34.5':
1965 + optionalDependencies:
1966 + '@img/sharp-libvips-darwin-x64': 1.2.4
1967 + optional: true
1968 +
1969 + '@img/sharp-libvips-darwin-arm64@1.2.4':
1970 + optional: true
1971 +
1972 + '@img/sharp-libvips-darwin-x64@1.2.4':
1973 + optional: true
1974 +
1975 + '@img/sharp-libvips-linux-arm64@1.2.4':
1976 + optional: true
1977 +
1978 + '@img/sharp-libvips-linux-arm@1.2.4':
1979 + optional: true
1980 +
1981 + '@img/sharp-libvips-linux-ppc64@1.2.4':
1982 + optional: true
1983 +
1984 + '@img/sharp-libvips-linux-riscv64@1.2.4':
1985 + optional: true
1986 +
1987 + '@img/sharp-libvips-linux-s390x@1.2.4':
1988 + optional: true
1989 +
1990 + '@img/sharp-libvips-linux-x64@1.2.4':
1991 + optional: true
1992 +
1993 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
1994 + optional: true
1995 +
1996 + '@img/sharp-libvips-linuxmusl-x64@1.2.4':
1997 + optional: true
1998 +
1999 + '@img/sharp-linux-arm64@0.34.5':
2000 + optionalDependencies:
2001 + '@img/sharp-libvips-linux-arm64': 1.2.4
2002 + optional: true
2003 +
2004 + '@img/sharp-linux-arm@0.34.5':
2005 + optionalDependencies:
2006 + '@img/sharp-libvips-linux-arm': 1.2.4
2007 + optional: true
2008 +
2009 + '@img/sharp-linux-ppc64@0.34.5':
2010 + optionalDependencies:
2011 + '@img/sharp-libvips-linux-ppc64': 1.2.4
2012 + optional: true
2013 +
2014 + '@img/sharp-linux-riscv64@0.34.5':
2015 + optionalDependencies:
2016 + '@img/sharp-libvips-linux-riscv64': 1.2.4
2017 + optional: true
2018 +
2019 + '@img/sharp-linux-s390x@0.34.5':
2020 + optionalDependencies:
2021 + '@img/sharp-libvips-linux-s390x': 1.2.4
2022 + optional: true
2023 +
2024 + '@img/sharp-linux-x64@0.34.5':
2025 + optionalDependencies:
2026 + '@img/sharp-libvips-linux-x64': 1.2.4
2027 + optional: true
2028 +
2029 + '@img/sharp-linuxmusl-arm64@0.34.5':
2030 + optionalDependencies:
2031 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
2032 + optional: true
2033 +
2034 + '@img/sharp-linuxmusl-x64@0.34.5':
2035 + optionalDependencies:
2036 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4
2037 + optional: true
2038 +
2039 + '@img/sharp-wasm32@0.34.5':
2040 + dependencies:
2041 + '@emnapi/runtime': 1.11.3
2042 + optional: true
2043 +
2044 + '@img/sharp-win32-arm64@0.34.5':
2045 + optional: true
2046 +
2047 + '@img/sharp-win32-ia32@0.34.5':
2048 + optional: true
2049 +
2050 + '@img/sharp-win32-x64@0.34.5':
2051 + optional: true
2052 +
2053 + '@jridgewell/gen-mapping@0.3.13':
2054 + dependencies:
2055 + '@jridgewell/sourcemap-codec': 1.5.5
2056 + '@jridgewell/trace-mapping': 0.3.31
2057 +
2058 + '@jridgewell/resolve-uri@3.1.2': {}
2059 +
2060 + '@jridgewell/sourcemap-codec@1.5.5': {}
2061 +
2062 + '@jridgewell/trace-mapping@0.3.31':
2063 + dependencies:
2064 + '@jridgewell/resolve-uri': 3.1.2
2065 + '@jridgewell/sourcemap-codec': 1.5.5
2066 +
2067 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
2068 + dependencies:
2069 + '@emnapi/core': 1.10.0
2070 + '@emnapi/runtime': 1.10.0
2071 + '@tybys/wasm-util': 0.10.3
2072 + optional: true
2073 +
2074 + '@next/env@15.5.23': {}
2075 +
2076 + '@next/eslint-plugin-next@15.5.23':
2077 + dependencies:
2078 + fast-glob: 3.3.1
2079 +
2080 + '@next/swc-darwin-arm64@15.5.23':
2081 + optional: true
2082 +
2083 + '@next/swc-darwin-x64@15.5.23':
2084 + optional: true
2085 +
2086 + '@next/swc-linux-arm64-gnu@15.5.23':
2087 + optional: true
2088 +
2089 + '@next/swc-linux-arm64-musl@15.5.23':
2090 + optional: true
2091 +
2092 + '@next/swc-linux-x64-gnu@15.5.23':
2093 + optional: true
2094 +
2095 + '@next/swc-linux-x64-musl@15.5.23':
2096 + optional: true
2097 +
2098 + '@next/swc-win32-arm64-msvc@15.5.23':
2099 + optional: true
2100 +
2101 + '@next/swc-win32-x64-msvc@15.5.23':
2102 + optional: true
2103 +
2104 + '@nodelib/fs.scandir@2.1.5':
2105 + dependencies:
2106 + '@nodelib/fs.stat': 2.0.5
2107 + run-parallel: 1.2.0
2108 +
2109 + '@nodelib/fs.stat@2.0.5': {}
2110 +
2111 + '@nodelib/fs.walk@1.2.8':
2112 + dependencies:
2113 + '@nodelib/fs.scandir': 2.1.5
2114 + fastq: 1.20.1
2115 +
2116 + '@nolyfill/is-core-module@1.0.39': {}
2117 +
2118 + '@rtsao/scc@1.1.0': {}
2119 +
2120 + '@rushstack/eslint-patch@1.16.1': {}
2121 +
2122 + '@swc/helpers@0.5.15':
2123 + dependencies:
2124 + tslib: 2.8.1
2125 +
2126 + '@tybys/wasm-util@0.10.3':
2127 + dependencies:
2128 + tslib: 2.8.1
2129 + optional: true
2130 +
2131 + '@types/json5@0.0.29': {}
2132 +
2133 + '@types/node@22.20.1':
2134 + dependencies:
2135 + undici-types: 6.21.0
2136 +
2137 + '@types/react-dom@19.2.4(@types/react@19.2.18)':
2138 + dependencies:
2139 + '@types/react': 19.2.18
2140 +
2141 + '@types/react@19.2.18':
2142 + dependencies:
2143 + csstype: 3.2.3
2144 +
2145 + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
2146 + dependencies:
2147 + '@eslint-community/regexpp': 4.12.2
2148 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2149 + '@typescript-eslint/scope-manager': 8.67.0
2150 + '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2151 + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2152 + '@typescript-eslint/visitor-keys': 8.67.0
2153 + eslint: 8.57.1
2154 + ignore: 7.0.6
2155 + natural-compare: 1.4.0
2156 + ts-api-utils: 2.5.0(typescript@5.9.3)
2157 + typescript: 5.9.3
2158 + transitivePeerDependencies:
2159 + - supports-color
2160 +
2161 + '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2162 + dependencies:
2163 + '@typescript-eslint/scope-manager': 8.67.0
2164 + '@typescript-eslint/types': 8.67.0
2165 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2166 + '@typescript-eslint/visitor-keys': 8.67.0
2167 + debug: 4.4.3
2168 + eslint: 8.57.1
2169 + typescript: 5.9.3
2170 + transitivePeerDependencies:
2171 + - supports-color
2172 +
2173 + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)':
2174 + dependencies:
2175 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
2176 + '@typescript-eslint/types': 8.67.0
2177 + debug: 4.4.3
2178 + typescript: 5.9.3
2179 + transitivePeerDependencies:
2180 + - supports-color
2181 +
2182 + '@typescript-eslint/scope-manager@8.67.0':
2183 + dependencies:
2184 + '@typescript-eslint/types': 8.67.0
2185 + '@typescript-eslint/visitor-keys': 8.67.0
2186 +
2187 + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)':
2188 + dependencies:
2189 + typescript: 5.9.3
2190 +
2191 + '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2192 + dependencies:
2193 + '@typescript-eslint/types': 8.67.0
2194 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2195 + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2196 + debug: 4.4.3
2197 + eslint: 8.57.1
2198 + ts-api-utils: 2.5.0(typescript@5.9.3)
2199 + typescript: 5.9.3
2200 + transitivePeerDependencies:
2201 + - supports-color
2202 +
2203 + '@typescript-eslint/types@8.67.0': {}
2204 +
2205 + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)':
2206 + dependencies:
2207 + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3)
2208 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
2209 + '@typescript-eslint/types': 8.67.0
2210 + '@typescript-eslint/visitor-keys': 8.67.0
2211 + debug: 4.4.3
2212 + minimatch: 10.2.6
2213 + semver: 7.8.5
2214 + tinyglobby: 0.2.17
2215 + ts-api-utils: 2.5.0(typescript@5.9.3)
2216 + typescript: 5.9.3
2217 + transitivePeerDependencies:
2218 + - supports-color
2219 +
2220 + '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
2221 + dependencies:
2222 + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
2223 + '@typescript-eslint/scope-manager': 8.67.0
2224 + '@typescript-eslint/types': 8.67.0
2225 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
2226 + eslint: 8.57.1
2227 + typescript: 5.9.3
2228 + transitivePeerDependencies:
2229 + - supports-color
2230 +
2231 + '@typescript-eslint/visitor-keys@8.67.0':
2232 + dependencies:
2233 + '@typescript-eslint/types': 8.67.0
2234 + eslint-visitor-keys: 5.0.1
2235 +
2236 + '@ungap/structured-clone@1.3.3': {}
2237 +
2238 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
2239 + optional: true
2240 +
2241 + '@unrs/resolver-binding-android-arm64@1.12.2':
2242 + optional: true
2243 +
2244 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
2245 + optional: true
2246 +
2247 + '@unrs/resolver-binding-darwin-x64@1.12.2':
2248 + optional: true
2249 +
2250 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
2251 + optional: true
2252 +
2253 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
2254 + optional: true
2255 +
2256 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
2257 + optional: true
2258 +
2259 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
2260 + optional: true
2261 +
2262 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
2263 + optional: true
2264 +
2265 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
2266 + optional: true
2267 +
2268 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
2269 + optional: true
2270 +
2271 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
2272 + optional: true
2273 +
2274 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
2275 + optional: true
2276 +
2277 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
2278 + optional: true
2279 +
2280 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
2281 + optional: true
2282 +
2283 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
2284 + optional: true
2285 +
2286 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
2287 + optional: true
2288 +
2289 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
2290 + optional: true
2291 +
2292 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
2293 + dependencies:
2294 + '@emnapi/core': 1.10.0
2295 + '@emnapi/runtime': 1.10.0
2296 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
2297 + optional: true
2298 +
2299 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
2300 + optional: true
2301 +
2302 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
2303 + optional: true
2304 +
2305 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
2306 + optional: true
2307 +
2308 + acorn-jsx@5.3.2(acorn@8.18.0):
2309 + dependencies:
2310 + acorn: 8.18.0
2311 +
2312 + acorn@8.18.0: {}
2313 +
2314 + ajv@6.15.0:
2315 + dependencies:
2316 + fast-deep-equal: 3.1.3
2317 + fast-json-stable-stringify: 2.1.0
2318 + json-schema-traverse: 0.4.1
2319 + uri-js: 4.4.1
2320 +
2321 + ansi-regex@5.0.1: {}
2322 +
2323 + ansi-styles@4.3.0:
2324 + dependencies:
2325 + color-convert: 2.0.1
2326 +
2327 + any-promise@1.3.0: {}
2328 +
2329 + anymatch@3.1.3:
2330 + dependencies:
2331 + normalize-path: 3.0.0
2332 + picomatch: 2.3.2
2333 +
2334 + arg@5.0.2: {}
2335 +
2336 + argparse@2.0.1: {}
2337 +
2338 + aria-query@5.3.2: {}
2339 +
2340 + array-buffer-byte-length@1.0.2:
2341 + dependencies:
2342 + call-bound: 1.0.4
2343 + is-array-buffer: 3.0.5
2344 +
2345 + array-includes@3.1.9:
2346 + dependencies:
2347 + call-bind: 1.0.9
2348 + call-bound: 1.0.4
2349 + define-properties: 1.2.1
2350 + es-abstract: 1.24.2
2351 + es-object-atoms: 1.1.2
2352 + get-intrinsic: 1.3.0
2353 + is-string: 1.1.1
2354 + math-intrinsics: 1.1.0
2355 +
2356 + array.prototype.findlast@1.2.5:
2357 + dependencies:
2358 + call-bind: 1.0.9
2359 + define-properties: 1.2.1
2360 + es-abstract: 1.24.2
2361 + es-errors: 1.3.0
2362 + es-object-atoms: 1.1.2
2363 + es-shim-unscopables: 1.1.0
2364 +
2365 + array.prototype.findlastindex@1.2.6:
2366 + dependencies:
2367 + call-bind: 1.0.9
2368 + call-bound: 1.0.4
2369 + define-properties: 1.2.1
2370 + es-abstract: 1.24.2
2371 + es-errors: 1.3.0
2372 + es-object-atoms: 1.1.2
2373 + es-shim-unscopables: 1.1.0
2374 +
2375 + array.prototype.flat@1.3.3:
2376 + dependencies:
2377 + call-bind: 1.0.9
2378 + define-properties: 1.2.1
2379 + es-abstract: 1.24.2
2380 + es-shim-unscopables: 1.1.0
2381 +
2382 + array.prototype.flatmap@1.3.3:
2383 + dependencies:
2384 + call-bind: 1.0.9
2385 + define-properties: 1.2.1
2386 + es-abstract: 1.24.2
2387 + es-shim-unscopables: 1.1.0
2388 +
2389 + array.prototype.tosorted@1.1.4:
2390 + dependencies:
2391 + call-bind: 1.0.9
2392 + define-properties: 1.2.1
2393 + es-abstract: 1.24.2
2394 + es-errors: 1.3.0
2395 + es-shim-unscopables: 1.1.0
2396 +
2397 + arraybuffer.prototype.slice@1.0.4:
2398 + dependencies:
2399 + array-buffer-byte-length: 1.0.2
2400 + call-bind: 1.0.9
2401 + define-properties: 1.2.1
2402 + es-abstract: 1.24.2
2403 + es-errors: 1.3.0
2404 + get-intrinsic: 1.3.0
2405 + is-array-buffer: 3.0.5
2406 +
2407 + ast-types-flow@0.0.8: {}
2408 +
2409 + async-function@1.0.0: {}
2410 +
2411 + autoprefixer@10.5.4(postcss@8.5.26):
2412 + dependencies:
2413 + browserslist: 4.28.8
2414 + caniuse-lite: 1.0.30001809
2415 + fraction.js: 5.3.4
2416 + picocolors: 1.1.1
2417 + postcss: 8.5.26
2418 + postcss-value-parser: 4.2.0
2419 +
2420 + available-typed-arrays@1.0.7:
2421 + dependencies:
2422 + possible-typed-array-names: 1.1.0
2423 +
2424 + axe-core@4.13.0: {}
2425 +
2426 + axobject-query@4.1.0: {}
2427 +
2428 + balanced-match@1.0.2: {}
2429 +
2430 + balanced-match@4.0.4: {}
2431 +
2432 + baseline-browser-mapping@2.11.13: {}
2433 +
2434 + binary-extensions@2.3.0: {}
2435 +
2436 + brace-expansion@1.1.18:
2437 + dependencies:
2438 + balanced-match: 1.0.2
2439 + concat-map: 0.0.1
2440 +
2441 + brace-expansion@5.0.9:
2442 + dependencies:
2443 + balanced-match: 4.0.4
2444 +
2445 + braces@3.0.3:
2446 + dependencies:
2447 + fill-range: 7.1.1
2448 +
2449 + browserslist@4.28.8:
2450 + dependencies:
2451 + baseline-browser-mapping: 2.11.13
2452 + caniuse-lite: 1.0.30001809
2453 + electron-to-chromium: 1.5.405
2454 + node-releases: 2.0.53
2455 + update-browserslist-db: 1.3.1(browserslist@4.28.8)
2456 +
2457 + call-bind-apply-helpers@1.0.2:
2458 + dependencies:
2459 + es-errors: 1.3.0
2460 + function-bind: 1.1.2
2461 +
2462 + call-bind@1.0.9:
2463 + dependencies:
2464 + call-bind-apply-helpers: 1.0.2
2465 + es-define-property: 1.0.1
2466 + get-intrinsic: 1.3.0
2467 + set-function-length: 1.2.2
2468 +
2469 + call-bound@1.0.4:
2470 + dependencies:
2471 + call-bind-apply-helpers: 1.0.2
2472 + get-intrinsic: 1.3.0
2473 +
2474 + callsites@3.1.0: {}
2475 +
2476 + camelcase-css@2.0.1: {}
2477 +
2478 + caniuse-lite@1.0.30001809: {}
2479 +
2480 + chalk@4.1.2:
2481 + dependencies:
2482 + ansi-styles: 4.3.0
2483 + supports-color: 7.2.0
2484 +
2485 + chokidar@3.6.0:
2486 + dependencies:
2487 + anymatch: 3.1.3
2488 + braces: 3.0.3
2489 + glob-parent: 5.1.2
2490 + is-binary-path: 2.1.0
2491 + is-glob: 4.0.3
2492 + normalize-path: 3.0.0
2493 + readdirp: 3.6.0
2494 + optionalDependencies:
2495 + fsevents: 2.3.3
2496 +
2497 + class-variance-authority@0.7.1:
2498 + dependencies:
2499 + clsx: 2.1.1
2500 +
2501 + client-only@0.0.1: {}
2502 +
2503 + clsx@2.1.1: {}
2504 +
2505 + color-convert@2.0.1:
2506 + dependencies:
2507 + color-name: 1.1.4
2508 +
2509 + color-name@1.1.4: {}
2510 +
2511 + commander@4.1.1: {}
2512 +
2513 + concat-map@0.0.1: {}
2514 +
2515 + cross-spawn@7.0.6:
2516 + dependencies:
2517 + path-key: 3.1.1
2518 + shebang-command: 2.0.0
2519 + which: 2.0.2
2520 +
2521 + cssesc@3.0.0: {}
2522 +
2523 + csstype@3.2.3: {}
2524 +
2525 + damerau-levenshtein@1.0.8: {}
2526 +
2527 + data-view-buffer@1.0.2:
2528 + dependencies:
2529 + call-bound: 1.0.4
2530 + es-errors: 1.3.0
2531 + is-data-view: 1.0.2
2532 +
2533 + data-view-byte-length@1.0.2:
2534 + dependencies:
2535 + call-bound: 1.0.4
2536 + es-errors: 1.3.0
2537 + is-data-view: 1.0.2
2538 +
2539 + data-view-byte-offset@1.0.1:
2540 + dependencies:
2541 + call-bound: 1.0.4
2542 + es-errors: 1.3.0
2543 + is-data-view: 1.0.2
2544 +
2545 + debug@3.2.7:
2546 + dependencies:
2547 + ms: 2.1.3
2548 +
2549 + debug@4.4.3:
2550 + dependencies:
2551 + ms: 2.1.3
2552 +
2553 + deep-is@0.1.4: {}
2554 +
2555 + define-data-property@1.1.4:
2556 + dependencies:
2557 + es-define-property: 1.0.1
2558 + es-errors: 1.3.0
2559 + gopd: 1.2.0
2560 +
2561 + define-properties@1.2.1:
2562 + dependencies:
2563 + define-data-property: 1.1.4
2564 + has-property-descriptors: 1.0.2
2565 + object-keys: 1.1.1
2566 +
2567 + detect-libc@2.1.2:
2568 + optional: true
2569 +
2570 + didyoumean@1.2.2: {}
2571 +
2572 + dlv@1.1.3: {}
2573 +
2574 + doctrine@2.1.0:
2575 + dependencies:
2576 + esutils: 2.0.3
2577 +
2578 + doctrine@3.0.0:
2579 + dependencies:
2580 + esutils: 2.0.3
2581 +
2582 + dunder-proto@1.0.1:
2583 + dependencies:
2584 + call-bind-apply-helpers: 1.0.2
2585 + es-errors: 1.3.0
2586 + gopd: 1.2.0
2587 +
2588 + electron-to-chromium@1.5.405: {}
2589 +
2590 + emoji-regex@9.2.2: {}
2591 +
2592 + es-abstract-get@1.0.0:
2593 + dependencies:
2594 + es-errors: 1.3.0
2595 + es-object-atoms: 1.1.2
2596 + is-callable: 1.2.7
2597 + object-inspect: 1.13.4
2598 +
2599 + es-abstract@1.24.2:
2600 + dependencies:
2601 + array-buffer-byte-length: 1.0.2
2602 + arraybuffer.prototype.slice: 1.0.4
2603 + available-typed-arrays: 1.0.7
2604 + call-bind: 1.0.9
2605 + call-bound: 1.0.4
2606 + data-view-buffer: 1.0.2
2607 + data-view-byte-length: 1.0.2
2608 + data-view-byte-offset: 1.0.1
2609 + es-define-property: 1.0.1
2610 + es-errors: 1.3.0
2611 + es-object-atoms: 1.1.2
2612 + es-set-tostringtag: 2.1.0
2613 + es-to-primitive: 1.3.4
2614 + function.prototype.name: 1.2.0
2615 + get-intrinsic: 1.3.0
2616 + get-proto: 1.0.1
2617 + get-symbol-description: 1.1.0
2618 + globalthis: 1.0.4
2619 + gopd: 1.2.0
2620 + has-property-descriptors: 1.0.2
2621 + has-proto: 1.2.0
2622 + has-symbols: 1.1.0
2623 + hasown: 2.0.4
2624 + internal-slot: 1.1.0
2625 + is-array-buffer: 3.0.5
2626 + is-callable: 1.2.7
2627 + is-data-view: 1.0.2
2628 + is-negative-zero: 2.0.3
2629 + is-regex: 1.2.1
2630 + is-set: 2.0.3
2631 + is-shared-array-buffer: 1.0.4
2632 + is-string: 1.1.1
2633 + is-typed-array: 1.1.15
2634 + is-weakref: 1.1.1
2635 + math-intrinsics: 1.1.0
2636 + object-inspect: 1.13.4
2637 + object-keys: 1.1.1
2638 + object.assign: 4.1.7
2639 + own-keys: 1.0.2
2640 + regexp.prototype.flags: 1.5.4
2641 + safe-array-concat: 1.1.4
2642 + safe-push-apply: 1.0.0
2643 + safe-regex-test: 1.1.0
2644 + set-proto: 1.0.0
2645 + stop-iteration-iterator: 1.1.0
2646 + string.prototype.trim: 1.2.11
2647 + string.prototype.trimend: 1.0.10
2648 + string.prototype.trimstart: 1.0.8
2649 + typed-array-buffer: 1.0.3
2650 + typed-array-byte-length: 1.0.3
2651 + typed-array-byte-offset: 1.0.4
2652 + typed-array-length: 1.0.8
2653 + unbox-primitive: 1.1.0
2654 + which-typed-array: 1.1.22
2655 +
2656 + es-define-property@1.0.1: {}
2657 +
2658 + es-errors@1.3.0: {}
2659 +
2660 + es-iterator-helpers@1.4.0:
2661 + dependencies:
2662 + call-bind: 1.0.9
2663 + call-bound: 1.0.4
2664 + define-properties: 1.2.1
2665 + es-abstract: 1.24.2
2666 + es-errors: 1.3.0
2667 + es-set-tostringtag: 2.1.0
2668 + function-bind: 1.1.2
2669 + get-intrinsic: 1.3.0
2670 + globalthis: 1.0.4
2671 + gopd: 1.2.0
2672 + has-property-descriptors: 1.0.2
2673 + has-proto: 1.2.0
2674 + has-symbols: 1.1.0
2675 + internal-slot: 1.1.0
2676 + iterator.prototype: 1.1.5
2677 + math-intrinsics: 1.1.0
2678 +
2679 + es-object-atoms@1.1.2:
2680 + dependencies:
2681 + es-errors: 1.3.0
2682 +
2683 + es-set-tostringtag@2.1.0:
2684 + dependencies:
2685 + es-errors: 1.3.0
2686 + get-intrinsic: 1.3.0
2687 + has-tostringtag: 1.0.2
2688 + hasown: 2.0.4
2689 +
2690 + es-shim-unscopables@1.1.0:
2691 + dependencies:
2692 + hasown: 2.0.4
2693 +
2694 + es-to-primitive@1.3.4:
2695 + dependencies:
2696 + es-abstract-get: 1.0.0
2697 + es-define-property: 1.0.1
2698 + es-errors: 1.3.0
2699 + is-callable: 1.2.7
2700 + is-date-object: 1.1.0
2701 + is-symbol: 1.1.1
2702 +
2703 + escalade@3.2.0: {}
2704 +
2705 + escape-string-regexp@4.0.0: {}
2706 +
2707 + eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3):
2708 + dependencies:
2709 + '@next/eslint-plugin-next': 15.5.23
2710 + '@rushstack/eslint-patch': 1.16.1
2711 + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
2712 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2713 + eslint: 8.57.1
2714 + eslint-import-resolver-node: 0.3.10
2715 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
2716 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2717 + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
2718 + eslint-plugin-react: 7.37.5(eslint@8.57.1)
2719 + eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
2720 + optionalDependencies:
2721 + typescript: 5.9.3
2722 + transitivePeerDependencies:
2723 + - eslint-import-resolver-webpack
2724 + - eslint-plugin-import-x
2725 + - supports-color
2726 +
2727 + eslint-import-resolver-node@0.3.10:
2728 + dependencies:
2729 + debug: 3.2.7
2730 + is-core-module: 2.16.2
2731 + resolve: 2.0.0-next.7
2732 + transitivePeerDependencies:
2733 + - supports-color
2734 +
2735 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
2736 + dependencies:
2737 + '@nolyfill/is-core-module': 1.0.39
2738 + debug: 4.4.3
2739 + eslint: 8.57.1
2740 + get-tsconfig: 4.14.2
2741 + is-bun-module: 2.0.0
2742 + stable-hash: 0.0.5
2743 + tinyglobby: 0.2.17
2744 + unrs-resolver: 1.12.2
2745 + optionalDependencies:
2746 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2747 + transitivePeerDependencies:
2748 + - supports-color
2749 +
2750 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
2751 + dependencies:
2752 + debug: 3.2.7
2753 + optionalDependencies:
2754 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2755 + eslint: 8.57.1
2756 + eslint-import-resolver-node: 0.3.10
2757 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
2758 + transitivePeerDependencies:
2759 + - supports-color
2760 +
2761 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
2762 + dependencies:
2763 + '@rtsao/scc': 1.1.0
2764 + array-includes: 3.1.9
2765 + array.prototype.findlastindex: 1.2.6
2766 + array.prototype.flat: 1.3.3
2767 + array.prototype.flatmap: 1.3.3
2768 + debug: 3.2.7
2769 + doctrine: 2.1.0
2770 + eslint: 8.57.1
2771 + eslint-import-resolver-node: 0.3.10
2772 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
2773 + hasown: 2.0.4
2774 + is-core-module: 2.16.2
2775 + is-glob: 4.0.3
2776 + minimatch: 3.1.5
2777 + object.fromentries: 2.0.8
2778 + object.groupby: 1.0.3
2779 + object.values: 1.2.1
2780 + semver: 6.3.1
2781 + string.prototype.trimend: 1.0.10
2782 + tsconfig-paths: 3.15.0
2783 + optionalDependencies:
2784 + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
2785 + transitivePeerDependencies:
2786 + - eslint-import-resolver-typescript
2787 + - eslint-import-resolver-webpack
2788 + - supports-color
2789 +
2790 + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1):
2791 + dependencies:
2792 + aria-query: 5.3.2
2793 + array-includes: 3.1.9
2794 + array.prototype.flatmap: 1.3.3
2795 + ast-types-flow: 0.0.8
2796 + axe-core: 4.13.0
2797 + axobject-query: 4.1.0
2798 + damerau-levenshtein: 1.0.8
2799 + emoji-regex: 9.2.2
2800 + eslint: 8.57.1
2801 + hasown: 2.0.4
2802 + jsx-ast-utils: 3.3.5
2803 + language-tags: 1.0.9
2804 + minimatch: 3.1.5
2805 + object.fromentries: 2.0.8
2806 + safe-regex-test: 1.1.0
2807 + string.prototype.includes: 2.0.1
2808 +
2809 + eslint-plugin-react-hooks@5.2.0(eslint@8.57.1):
2810 + dependencies:
2811 + eslint: 8.57.1
2812 +
2813 + eslint-plugin-react@7.37.5(eslint@8.57.1):
2814 + dependencies:
2815 + array-includes: 3.1.9
2816 + array.prototype.findlast: 1.2.5
2817 + array.prototype.flatmap: 1.3.3
2818 + array.prototype.tosorted: 1.1.4
2819 + doctrine: 2.1.0
2820 + es-iterator-helpers: 1.4.0
2821 + eslint: 8.57.1
2822 + estraverse: 5.3.0
2823 + hasown: 2.0.4
2824 + jsx-ast-utils: 3.3.5
2825 + minimatch: 3.1.5
2826 + object.entries: 1.1.9
2827 + object.fromentries: 2.0.8
2828 + object.values: 1.2.1
2829 + prop-types: 15.8.1
2830 + resolve: 2.0.0-next.7
2831 + semver: 6.3.1
2832 + string.prototype.matchall: 4.0.12
2833 + string.prototype.repeat: 1.0.0
2834 +
2835 + eslint-scope@7.2.2:
2836 + dependencies:
2837 + esrecurse: 4.3.0
2838 + estraverse: 5.3.0
2839 +
2840 + eslint-visitor-keys@3.4.3: {}
2841 +
2842 + eslint-visitor-keys@5.0.1: {}
2843 +
2844 + eslint@8.57.1:
2845 + dependencies:
2846 + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
2847 + '@eslint-community/regexpp': 4.12.2
2848 + '@eslint/eslintrc': 2.1.4
2849 + '@eslint/js': 8.57.1
2850 + '@humanwhocodes/config-array': 0.13.0
2851 + '@humanwhocodes/module-importer': 1.0.1
2852 + '@nodelib/fs.walk': 1.2.8
2853 + '@ungap/structured-clone': 1.3.3
2854 + ajv: 6.15.0
2855 + chalk: 4.1.2
2856 + cross-spawn: 7.0.6
2857 + debug: 4.4.3
2858 + doctrine: 3.0.0
2859 + escape-string-regexp: 4.0.0
2860 + eslint-scope: 7.2.2
2861 + eslint-visitor-keys: 3.4.3
2862 + espree: 9.6.1
2863 + esquery: 1.7.0
2864 + esutils: 2.0.3
2865 + fast-deep-equal: 3.1.3
2866 + file-entry-cache: 6.0.1
2867 + find-up: 5.0.0
2868 + glob-parent: 6.0.2
2869 + globals: 13.24.0
2870 + graphemer: 1.4.0
2871 + ignore: 5.3.2
2872 + imurmurhash: 0.1.4
2873 + is-glob: 4.0.3
2874 + is-path-inside: 3.0.3
2875 + js-yaml: 4.3.1
2876 + json-stable-stringify-without-jsonify: 1.0.1
2877 + levn: 0.4.1
2878 + lodash.merge: 4.6.2
2879 + minimatch: 3.1.5
2880 + natural-compare: 1.4.0
2881 + optionator: 0.9.4
2882 + strip-ansi: 6.0.1
2883 + text-table: 0.2.0
2884 + transitivePeerDependencies:
2885 + - supports-color
2886 +
2887 + espree@9.6.1:
2888 + dependencies:
2889 + acorn: 8.18.0
2890 + acorn-jsx: 5.3.2(acorn@8.18.0)
2891 + eslint-visitor-keys: 3.4.3
2892 +
2893 + esquery@1.7.0:
2894 + dependencies:
2895 + estraverse: 5.3.0
2896 +
2897 + esrecurse@4.3.0:
2898 + dependencies:
2899 + estraverse: 5.3.0
2900 +
2901 + estraverse@5.3.0: {}
2902 +
2903 + esutils@2.0.3: {}
2904 +
2905 + fast-deep-equal@3.1.3: {}
2906 +
2907 + fast-glob@3.3.1:
2908 + dependencies:
2909 + '@nodelib/fs.stat': 2.0.5
2910 + '@nodelib/fs.walk': 1.2.8
2911 + glob-parent: 5.1.2
2912 + merge2: 1.4.1
2913 + micromatch: 4.0.8
2914 +
2915 + fast-glob@3.3.3:
2916 + dependencies:
2917 + '@nodelib/fs.stat': 2.0.5
2918 + '@nodelib/fs.walk': 1.2.8
2919 + glob-parent: 5.1.2
2920 + merge2: 1.4.1
2921 + micromatch: 4.0.8
2922 +
2923 + fast-json-stable-stringify@2.1.0: {}
2924 +
2925 + fast-levenshtein@2.0.6: {}
2926 +
2927 + fastq@1.20.1:
2928 + dependencies:
2929 + reusify: 1.1.0
2930 +
2931 + fdir@6.5.0(picomatch@4.0.5):
2932 + optionalDependencies:
2933 + picomatch: 4.0.5
2934 +
2935 + file-entry-cache@6.0.1:
2936 + dependencies:
2937 + flat-cache: 3.2.0
2938 +
2939 + fill-range@7.1.1:
2940 + dependencies:
2941 + to-regex-range: 5.0.1
2942 +
2943 + find-up@5.0.0:
2944 + dependencies:
2945 + locate-path: 6.0.0
2946 + path-exists: 4.0.0
2947 +
2948 + flat-cache@3.2.0:
2949 + dependencies:
2950 + flatted: 3.4.4
2951 + keyv: 4.5.4
2952 + rimraf: 3.0.2
2953 +
2954 + flatted@3.4.4: {}
2955 +
2956 + for-each@0.3.5:
2957 + dependencies:
2958 + is-callable: 1.2.7
2959 +
2960 + fraction.js@5.3.4: {}
2961 +
2962 + fs.realpath@1.0.0: {}
2963 +
2964 + fsevents@2.3.3:
2965 + optional: true
2966 +
2967 + function-bind@1.1.2: {}
2968 +
2969 + function.prototype.name@1.2.0:
2970 + dependencies:
2971 + call-bind: 1.0.9
2972 + call-bound: 1.0.4
2973 + es-define-property: 1.0.1
2974 + es-errors: 1.3.0
2975 + functions-have-names: 1.2.3
2976 + has-property-descriptors: 1.0.2
2977 + hasown: 2.0.4
2978 + is-callable: 1.2.7
2979 + is-document.all: 1.0.0
2980 +
2981 + functions-have-names@1.2.3: {}
2982 +
2983 + generator-function@2.0.1: {}
2984 +
2985 + get-intrinsic@1.3.0:
2986 + dependencies:
2987 + call-bind-apply-helpers: 1.0.2
2988 + es-define-property: 1.0.1
2989 + es-errors: 1.3.0
2990 + es-object-atoms: 1.1.2
2991 + function-bind: 1.1.2
2992 + get-proto: 1.0.1
2993 + gopd: 1.2.0
2994 + has-symbols: 1.1.0
2995 + hasown: 2.0.4
2996 + math-intrinsics: 1.1.0
2997 +
2998 + get-proto@1.0.1:
2999 + dependencies:
3000 + dunder-proto: 1.0.1
3001 + es-object-atoms: 1.1.2
3002 +
3003 + get-symbol-description@1.1.0:
3004 + dependencies:
3005 + call-bound: 1.0.4
3006 + es-errors: 1.3.0
3007 + get-intrinsic: 1.3.0
3008 +
3009 + get-tsconfig@4.14.2:
3010 + dependencies:
3011 + resolve-pkg-maps: 1.0.0
3012 +
3013 + glob-parent@5.1.2:
3014 + dependencies:
3015 + is-glob: 4.0.3
3016 +
3017 + glob-parent@6.0.2:
3018 + dependencies:
3019 + is-glob: 4.0.3
3020 +
3021 + glob@7.2.3:
3022 + dependencies:
3023 + fs.realpath: 1.0.0
3024 + inflight: 1.0.6
3025 + inherits: 2.0.4
3026 + minimatch: 3.1.5
3027 + once: 1.4.0
3028 + path-is-absolute: 1.0.1
3029 +
3030 + globals@13.24.0:
3031 + dependencies:
3032 + type-fest: 0.20.2
3033 +
3034 + globalthis@1.0.4:
3035 + dependencies:
3036 + define-properties: 1.2.1
3037 + gopd: 1.2.0
3038 +
3039 + gopd@1.2.0: {}
3040 +
3041 + graphemer@1.4.0: {}
3042 +
3043 + has-bigints@1.1.0: {}
3044 +
3045 + has-flag@4.0.0: {}
3046 +
3047 + has-property-descriptors@1.0.2:
3048 + dependencies:
3049 + es-define-property: 1.0.1
3050 +
3051 + has-proto@1.2.0:
3052 + dependencies:
3053 + dunder-proto: 1.0.1
3054 +
3055 + has-symbols@1.1.0: {}
3056 +
3057 + has-tostringtag@1.0.2:
3058 + dependencies:
3059 + has-symbols: 1.1.0
3060 +
3061 + hasown@2.0.4:
3062 + dependencies:
3063 + function-bind: 1.1.2
3064 +
3065 + ignore@5.3.2: {}
3066 +
3067 + ignore@7.0.6: {}
3068 +
3069 + import-fresh@3.3.1:
3070 + dependencies:
3071 + parent-module: 1.0.1
3072 + resolve-from: 4.0.0
3073 +
3074 + imurmurhash@0.1.4: {}
3075 +
3076 + inflight@1.0.6:
3077 + dependencies:
3078 + once: 1.4.0
3079 + wrappy: 1.0.2
3080 +
3081 + inherits@2.0.4: {}
3082 +
3083 + internal-slot@1.1.0:
3084 + dependencies:
3085 + es-errors: 1.3.0
3086 + hasown: 2.0.4
3087 + side-channel: 1.1.1
3088 +
3089 + is-array-buffer@3.0.5:
3090 + dependencies:
3091 + call-bind: 1.0.9
3092 + call-bound: 1.0.4
3093 + get-intrinsic: 1.3.0
3094 +
3095 + is-async-function@2.1.1:
3096 + dependencies:
3097 + async-function: 1.0.0
3098 + call-bound: 1.0.4
3099 + get-proto: 1.0.1
3100 + has-tostringtag: 1.0.2
3101 + safe-regex-test: 1.1.0
3102 +
3103 + is-bigint@1.1.0:
3104 + dependencies:
3105 + has-bigints: 1.1.0
3106 +
3107 + is-binary-path@2.1.0:
3108 + dependencies:
3109 + binary-extensions: 2.3.0
3110 +
3111 + is-boolean-object@1.2.2:
3112 + dependencies:
3113 + call-bound: 1.0.4
3114 + has-tostringtag: 1.0.2
3115 +
3116 + is-bun-module@2.0.0:
3117 + dependencies:
3118 + semver: 7.8.5
3119 +
3120 + is-callable@1.2.7: {}
3121 +
3122 + is-core-module@2.16.2:
3123 + dependencies:
3124 + hasown: 2.0.4
3125 +
3126 + is-data-view@1.0.2:
3127 + dependencies:
3128 + call-bound: 1.0.4
3129 + get-intrinsic: 1.3.0
3130 + is-typed-array: 1.1.15
3131 +
3132 + is-date-object@1.1.0:
3133 + dependencies:
3134 + call-bound: 1.0.4
3135 + has-tostringtag: 1.0.2
3136 +
3137 + is-document.all@1.0.0:
3138 + dependencies:
3139 + call-bound: 1.0.4
3140 +
3141 + is-extglob@2.1.1: {}
3142 +
3143 + is-finalizationregistry@1.1.1:
3144 + dependencies:
3145 + call-bound: 1.0.4
3146 +
3147 + is-generator-function@1.1.2:
3148 + dependencies:
3149 + call-bound: 1.0.4
3150 + generator-function: 2.0.1
3151 + get-proto: 1.0.1
3152 + has-tostringtag: 1.0.2
3153 + safe-regex-test: 1.1.0
3154 +
3155 + is-glob@4.0.3:
3156 + dependencies:
3157 + is-extglob: 2.1.1
3158 +
3159 + is-map@2.0.3: {}
3160 +
3161 + is-negative-zero@2.0.3: {}
3162 +
3163 + is-number-object@1.1.1:
3164 + dependencies:
3165 + call-bound: 1.0.4
3166 + has-tostringtag: 1.0.2
3167 +
3168 + is-number@7.0.0: {}
3169 +
3170 + is-path-inside@3.0.3: {}
3171 +
3172 + is-regex@1.2.1:
3173 + dependencies:
3174 + call-bound: 1.0.4
3175 + gopd: 1.2.0
3176 + has-tostringtag: 1.0.2
3177 + hasown: 2.0.4
3178 +
3179 + is-set@2.0.3: {}
3180 +
3181 + is-shared-array-buffer@1.0.4:
3182 + dependencies:
3183 + call-bound: 1.0.4
3184 +
3185 + is-string@1.1.1:
3186 + dependencies:
3187 + call-bound: 1.0.4
3188 + has-tostringtag: 1.0.2
3189 +
3190 + is-symbol@1.1.1:
3191 + dependencies:
3192 + call-bound: 1.0.4
3193 + has-symbols: 1.1.0
3194 + safe-regex-test: 1.1.0
3195 +
3196 + is-typed-array@1.1.15:
3197 + dependencies:
3198 + which-typed-array: 1.1.22
3199 +
3200 + is-weakmap@2.0.2: {}
3201 +
3202 + is-weakref@1.1.1:
3203 + dependencies:
3204 + call-bound: 1.0.4
3205 +
3206 + is-weakset@2.0.4:
3207 + dependencies:
3208 + call-bound: 1.0.4
3209 + get-intrinsic: 1.3.0
3210 +
3211 + isarray@2.0.5: {}
3212 +
3213 + isexe@2.0.0: {}
3214 +
3215 + iterator.prototype@1.1.5:
3216 + dependencies:
3217 + define-data-property: 1.1.4
3218 + es-object-atoms: 1.1.2
3219 + get-intrinsic: 1.3.0
3220 + get-proto: 1.0.1
3221 + has-symbols: 1.1.0
3222 + set-function-name: 2.0.2
3223 +
3224 + jiti@1.21.7: {}
3225 +
3226 + js-tokens@4.0.0: {}
3227 +
3228 + js-yaml@4.3.1:
3229 + dependencies:
3230 + argparse: 2.0.1
3231 +
3232 + json-buffer@3.0.1: {}
3233 +
3234 + json-schema-traverse@0.4.1: {}
3235 +
3236 + json-stable-stringify-without-jsonify@1.0.1: {}
3237 +
3238 + json5@1.0.2:
3239 + dependencies:
3240 + minimist: 1.2.8
3241 +
3242 + jsx-ast-utils@3.3.5:
3243 + dependencies:
3244 + array-includes: 3.1.9
3245 + array.prototype.flat: 1.3.3
3246 + object.assign: 4.1.7
3247 + object.values: 1.2.1
3248 +
3249 + keyv@4.5.4:
3250 + dependencies:
3251 + json-buffer: 3.0.1
3252 +
3253 + language-subtag-registry@0.3.23: {}
3254 +
3255 + language-tags@1.0.9:
3256 + dependencies:
3257 + language-subtag-registry: 0.3.23
3258 +
3259 + levn@0.4.1:
3260 + dependencies:
3261 + prelude-ls: 1.2.1
3262 + type-check: 0.4.0
3263 +
3264 + lilconfig@3.1.3: {}
3265 +
3266 + lines-and-columns@1.2.4: {}
3267 +
3268 + locate-path@6.0.0:
3269 + dependencies:
3270 + p-locate: 5.0.0
3271 +
3272 + lodash.merge@4.6.2: {}
3273 +
3274 + loose-envify@1.4.0:
3275 + dependencies:
3276 + js-tokens: 4.0.0
3277 +
3278 + math-intrinsics@1.1.0: {}
3279 +
3280 + merge2@1.4.1: {}
3281 +
3282 + micromatch@4.0.8:
3283 + dependencies:
3284 + braces: 3.0.3
3285 + picomatch: 2.3.2
3286 +
3287 + minimatch@10.2.6:
3288 + dependencies:
3289 + brace-expansion: 5.0.9
3290 +
3291 + minimatch@3.1.5:
3292 + dependencies:
3293 + brace-expansion: 1.1.18
3294 +
3295 + minimist@1.2.8: {}
3296 +
3297 + ms@2.1.3: {}
3298 +
3299 + mz@2.7.0:
3300 + dependencies:
3301 + any-promise: 1.3.0
3302 + object-assign: 4.1.1
3303 + thenify-all: 1.6.0
3304 +
3305 + nanoid@3.3.18: {}
3306 +
3307 + napi-postinstall@0.3.4: {}
3308 +
3309 + natural-compare@1.4.0: {}
3310 +
3311 + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
3312 + dependencies:
3313 + '@next/env': 15.5.23
3314 + '@swc/helpers': 0.5.15
3315 + caniuse-lite: 1.0.30001809
3316 + postcss: 8.4.31
3317 + react: 19.2.8
3318 + react-dom: 19.2.8(react@19.2.8)
3319 + styled-jsx: 5.1.6(react@19.2.8)
3320 + optionalDependencies:
3321 + '@next/swc-darwin-arm64': 15.5.23
3322 + '@next/swc-darwin-x64': 15.5.23
3323 + '@next/swc-linux-arm64-gnu': 15.5.23
3324 + '@next/swc-linux-arm64-musl': 15.5.23
3325 + '@next/swc-linux-x64-gnu': 15.5.23
3326 + '@next/swc-linux-x64-musl': 15.5.23
3327 + '@next/swc-win32-arm64-msvc': 15.5.23
3328 + '@next/swc-win32-x64-msvc': 15.5.23
3329 + sharp: 0.34.5
3330 + transitivePeerDependencies:
3331 + - '@babel/core'
3332 + - babel-plugin-macros
3333 +
3334 + node-exports-info@1.6.2:
3335 + dependencies:
3336 + array.prototype.flatmap: 1.3.3
3337 + es-errors: 1.3.0
3338 + object.entries: 1.1.9
3339 + semver: 6.3.1
3340 +
3341 + node-releases@2.0.53: {}
3342 +
3343 + normalize-path@3.0.0: {}
3344 +
3345 + object-assign@4.1.1: {}
3346 +
3347 + object-hash@3.0.0: {}
3348 +
3349 + object-inspect@1.13.4: {}
3350 +
3351 + object-keys@1.1.1: {}
3352 +
3353 + object.assign@4.1.7:
3354 + dependencies:
3355 + call-bind: 1.0.9
3356 + call-bound: 1.0.4
3357 + define-properties: 1.2.1
3358 + es-object-atoms: 1.1.2
3359 + has-symbols: 1.1.0
3360 + object-keys: 1.1.1
3361 +
3362 + object.entries@1.1.9:
3363 + dependencies:
3364 + call-bind: 1.0.9
3365 + call-bound: 1.0.4
3366 + define-properties: 1.2.1
3367 + es-object-atoms: 1.1.2
3368 +
3369 + object.fromentries@2.0.8:
3370 + dependencies:
3371 + call-bind: 1.0.9
3372 + define-properties: 1.2.1
3373 + es-abstract: 1.24.2
3374 + es-object-atoms: 1.1.2
3375 +
3376 + object.groupby@1.0.3:
3377 + dependencies:
3378 + call-bind: 1.0.9
3379 + define-properties: 1.2.1
3380 + es-abstract: 1.24.2
3381 +
3382 + object.values@1.2.1:
3383 + dependencies:
3384 + call-bind: 1.0.9
3385 + call-bound: 1.0.4
3386 + define-properties: 1.2.1
3387 + es-object-atoms: 1.1.2
3388 +
3389 + once@1.4.0:
3390 + dependencies:
3391 + wrappy: 1.0.2
3392 +
3393 + optionator@0.9.4:
3394 + dependencies:
3395 + deep-is: 0.1.4
3396 + fast-levenshtein: 2.0.6
3397 + levn: 0.4.1
3398 + prelude-ls: 1.2.1
3399 + type-check: 0.4.0
3400 + word-wrap: 1.2.5
3401 +
3402 + own-keys@1.0.2:
3403 + dependencies:
3404 + call-bound: 1.0.4
3405 + get-intrinsic: 1.3.0
3406 + object-keys: 1.1.1
3407 + safe-push-apply: 1.0.0
3408 +
3409 + p-limit@3.1.0:
3410 + dependencies:
3411 + yocto-queue: 0.1.0
3412 +
3413 + p-locate@5.0.0:
3414 + dependencies:
3415 + p-limit: 3.1.0
3416 +
3417 + parent-module@1.0.1:
3418 + dependencies:
3419 + callsites: 3.1.0
3420 +
3421 + path-exists@4.0.0: {}
3422 +
3423 + path-is-absolute@1.0.1: {}
3424 +
3425 + path-key@3.1.1: {}
3426 +
3427 + path-parse@1.0.7: {}
3428 +
3429 + picocolors@1.1.1: {}
3430 +
3431 + picomatch@2.3.2: {}
3432 +
3433 + picomatch@4.0.5: {}
3434 +
3435 + pify@2.3.0: {}
3436 +
3437 + pirates@4.0.7: {}
3438 +
3439 + possible-typed-array-names@1.1.0: {}
3440 +
3441 + postcss-import@15.1.0(postcss@8.5.26):
3442 + dependencies:
3443 + postcss: 8.5.26
3444 + postcss-value-parser: 4.2.0
3445 + read-cache: 1.0.0
3446 + resolve: 1.22.12
3447 +
3448 + postcss-js@4.1.0(postcss@8.5.26):
3449 + dependencies:
3450 + camelcase-css: 2.0.1
3451 + postcss: 8.5.26
3452 +
3453 + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26):
3454 + dependencies:
3455 + lilconfig: 3.1.3
3456 + optionalDependencies:
3457 + jiti: 1.21.7
3458 + postcss: 8.5.26
3459 +
3460 + postcss-nested@6.2.0(postcss@8.5.26):
3461 + dependencies:
3462 + postcss: 8.5.26
3463 + postcss-selector-parser: 6.1.4
3464 +
3465 + postcss-selector-parser@6.1.4:
3466 + dependencies:
3467 + cssesc: 3.0.0
3468 + util-deprecate: 1.0.2
3469 +
3470 + postcss-value-parser@4.2.0: {}
3471 +
3472 + postcss@8.4.31:
3473 + dependencies:
3474 + nanoid: 3.3.18
3475 + picocolors: 1.1.1
3476 + source-map-js: 1.2.1
3477 +
3478 + postcss@8.5.26:
3479 + dependencies:
3480 + nanoid: 3.3.18
3481 + picocolors: 1.1.1
3482 + source-map-js: 1.2.1
3483 +
3484 + prelude-ls@1.2.1: {}
3485 +
3486 + prop-types@15.8.1:
3487 + dependencies:
3488 + loose-envify: 1.4.0
3489 + object-assign: 4.1.1
3490 + react-is: 16.13.1
3491 +
3492 + punycode@2.3.1: {}
3493 +
3494 + queue-microtask@1.2.3: {}
3495 +
3496 + react-dom@19.2.8(react@19.2.8):
3497 + dependencies:
3498 + react: 19.2.8
3499 + scheduler: 0.27.0
3500 +
3501 + react-is@16.13.1: {}
3502 +
3503 + react@19.2.8: {}
3504 +
3505 + read-cache@1.0.0:
3506 + dependencies:
3507 + pify: 2.3.0
3508 +
3509 + readdirp@3.6.0:
3510 + dependencies:
3511 + picomatch: 2.3.2
3512 +
3513 + reflect.getprototypeof@1.0.10:
3514 + dependencies:
3515 + call-bind: 1.0.9
3516 + define-properties: 1.2.1
3517 + es-abstract: 1.24.2
3518 + es-errors: 1.3.0
3519 + es-object-atoms: 1.1.2
3520 + get-intrinsic: 1.3.0
3521 + get-proto: 1.0.1
3522 + which-builtin-type: 1.2.1
3523 +
3524 + regexp.prototype.flags@1.5.4:
3525 + dependencies:
3526 + call-bind: 1.0.9
3527 + define-properties: 1.2.1
3528 + es-errors: 1.3.0
3529 + get-proto: 1.0.1
3530 + gopd: 1.2.0
3531 + set-function-name: 2.0.2
3532 +
3533 + resolve-from@4.0.0: {}
3534 +
3535 + resolve-pkg-maps@1.0.0: {}
3536 +
3537 + resolve@1.22.12:
3538 + dependencies:
3539 + es-errors: 1.3.0
3540 + is-core-module: 2.16.2
3541 + path-parse: 1.0.7
3542 + supports-preserve-symlinks-flag: 1.0.0
3543 +
3544 + resolve@2.0.0-next.7:
3545 + dependencies:
3546 + es-errors: 1.3.0
3547 + is-core-module: 2.16.2
3548 + node-exports-info: 1.6.2
3549 + object-keys: 1.1.1
3550 + path-parse: 1.0.7
3551 + supports-preserve-symlinks-flag: 1.0.0
3552 +
3553 + reusify@1.1.0: {}
3554 +
3555 + rimraf@3.0.2:
3556 + dependencies:
3557 + glob: 7.2.3
3558 +
3559 + run-parallel@1.2.0:
3560 + dependencies:
3561 + queue-microtask: 1.2.3
3562 +
3563 + safe-array-concat@1.1.4:
3564 + dependencies:
3565 + call-bind: 1.0.9
3566 + call-bound: 1.0.4
3567 + get-intrinsic: 1.3.0
3568 + has-symbols: 1.1.0
3569 + isarray: 2.0.5
3570 +
3571 + safe-push-apply@1.0.0:
3572 + dependencies:
3573 + es-errors: 1.3.0
3574 + isarray: 2.0.5
3575 +
3576 + safe-regex-test@1.1.0:
3577 + dependencies:
3578 + call-bound: 1.0.4
3579 + es-errors: 1.3.0
3580 + is-regex: 1.2.1
3581 +
3582 + scheduler@0.27.0: {}
3583 +
3584 + semver@6.3.1: {}
3585 +
3586 + semver@7.8.5: {}
3587 +
3588 + set-function-length@1.2.2:
3589 + dependencies:
3590 + define-data-property: 1.1.4
3591 + es-errors: 1.3.0
3592 + function-bind: 1.1.2
3593 + get-intrinsic: 1.3.0
3594 + gopd: 1.2.0
3595 + has-property-descriptors: 1.0.2
3596 +
3597 + set-function-name@2.0.2:
3598 + dependencies:
3599 + define-data-property: 1.1.4
3600 + es-errors: 1.3.0
3601 + functions-have-names: 1.2.3
3602 + has-property-descriptors: 1.0.2
3603 +
3604 + set-proto@1.0.0:
3605 + dependencies:
3606 + dunder-proto: 1.0.1
3607 + es-errors: 1.3.0
3608 + es-object-atoms: 1.1.2
3609 +
3610 + sharp@0.34.5:
3611 + dependencies:
3612 + '@img/colour': 1.1.0
3613 + detect-libc: 2.1.2
3614 + semver: 7.8.5
3615 + optionalDependencies:
3616 + '@img/sharp-darwin-arm64': 0.34.5
3617 + '@img/sharp-darwin-x64': 0.34.5
3618 + '@img/sharp-libvips-darwin-arm64': 1.2.4
3619 + '@img/sharp-libvips-darwin-x64': 1.2.4
3620 + '@img/sharp-libvips-linux-arm': 1.2.4
3621 + '@img/sharp-libvips-linux-arm64': 1.2.4
3622 + '@img/sharp-libvips-linux-ppc64': 1.2.4
3623 + '@img/sharp-libvips-linux-riscv64': 1.2.4
3624 + '@img/sharp-libvips-linux-s390x': 1.2.4
3625 + '@img/sharp-libvips-linux-x64': 1.2.4
3626 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
3627 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4
3628 + '@img/sharp-linux-arm': 0.34.5
3629 + '@img/sharp-linux-arm64': 0.34.5
3630 + '@img/sharp-linux-ppc64': 0.34.5
3631 + '@img/sharp-linux-riscv64': 0.34.5
3632 + '@img/sharp-linux-s390x': 0.34.5
3633 + '@img/sharp-linux-x64': 0.34.5
3634 + '@img/sharp-linuxmusl-arm64': 0.34.5
3635 + '@img/sharp-linuxmusl-x64': 0.34.5
3636 + '@img/sharp-wasm32': 0.34.5
3637 + '@img/sharp-win32-arm64': 0.34.5
3638 + '@img/sharp-win32-ia32': 0.34.5
3639 + '@img/sharp-win32-x64': 0.34.5
3640 + optional: true
3641 +
3642 + shebang-command@2.0.0:
3643 + dependencies:
3644 + shebang-regex: 3.0.0
3645 +
3646 + shebang-regex@3.0.0: {}
3647 +
3648 + side-channel-list@1.0.1:
3649 + dependencies:
3650 + es-errors: 1.3.0
3651 + object-inspect: 1.13.4
3652 +
3653 + side-channel-map@1.0.1:
3654 + dependencies:
3655 + call-bound: 1.0.4
3656 + es-errors: 1.3.0
3657 + get-intrinsic: 1.3.0
3658 + object-inspect: 1.13.4
3659 +
3660 + side-channel-weakmap@1.0.2:
3661 + dependencies:
3662 + call-bound: 1.0.4
3663 + es-errors: 1.3.0
3664 + get-intrinsic: 1.3.0
3665 + object-inspect: 1.13.4
3666 + side-channel-map: 1.0.1
3667 +
3668 + side-channel@1.1.1:
3669 + dependencies:
3670 + es-errors: 1.3.0
3671 + object-inspect: 1.13.4
3672 + side-channel-list: 1.0.1
3673 + side-channel-map: 1.0.1
3674 + side-channel-weakmap: 1.0.2
3675 +
3676 + source-map-js@1.2.1: {}
3677 +
3678 + stable-hash@0.0.5: {}
3679 +
3680 + stop-iteration-iterator@1.1.0:
3681 + dependencies:
3682 + es-errors: 1.3.0
3683 + internal-slot: 1.1.0
3684 +
3685 + string.prototype.includes@2.0.1:
3686 + dependencies:
3687 + call-bind: 1.0.9
3688 + define-properties: 1.2.1
3689 + es-abstract: 1.24.2
3690 +
3691 + string.prototype.matchall@4.0.12:
3692 + dependencies:
3693 + call-bind: 1.0.9
3694 + call-bound: 1.0.4
3695 + define-properties: 1.2.1
3696 + es-abstract: 1.24.2
3697 + es-errors: 1.3.0
3698 + es-object-atoms: 1.1.2
3699 + get-intrinsic: 1.3.0
3700 + gopd: 1.2.0
3701 + has-symbols: 1.1.0
3702 + internal-slot: 1.1.0
3703 + regexp.prototype.flags: 1.5.4
3704 + set-function-name: 2.0.2
3705 + side-channel: 1.1.1
3706 +
3707 + string.prototype.repeat@1.0.0:
3708 + dependencies:
3709 + define-properties: 1.2.1
3710 + es-abstract: 1.24.2
3711 +
3712 + string.prototype.trim@1.2.11:
3713 + dependencies:
3714 + call-bind: 1.0.9
3715 + call-bound: 1.0.4
3716 + define-data-property: 1.1.4
3717 + define-properties: 1.2.1
3718 + es-abstract: 1.24.2
3719 + es-object-atoms: 1.1.2
3720 + has-property-descriptors: 1.0.2
3721 + safe-regex-test: 1.1.0
3722 +
3723 + string.prototype.trimend@1.0.10:
3724 + dependencies:
3725 + call-bind: 1.0.9
3726 + call-bound: 1.0.4
3727 + define-properties: 1.2.1
3728 + es-object-atoms: 1.1.2
3729 +
3730 + string.prototype.trimstart@1.0.8:
3731 + dependencies:
3732 + call-bind: 1.0.9
3733 + define-properties: 1.2.1
3734 + es-object-atoms: 1.1.2
3735 +
3736 + strip-ansi@6.0.1:
3737 + dependencies:
3738 + ansi-regex: 5.0.1
3739 +
3740 + strip-bom@3.0.0: {}
3741 +
3742 + strip-json-comments@3.1.1: {}
3743 +
3744 + styled-jsx@5.1.6(react@19.2.8):
3745 + dependencies:
3746 + client-only: 0.0.1
3747 + react: 19.2.8
3748 +
3749 + sucrase@3.35.1:
3750 + dependencies:
3751 + '@jridgewell/gen-mapping': 0.3.13
3752 + commander: 4.1.1
3753 + lines-and-columns: 1.2.4
3754 + mz: 2.7.0
3755 + pirates: 4.0.7
3756 + tinyglobby: 0.2.17
3757 + ts-interface-checker: 0.1.13
3758 +
3759 + supports-color@7.2.0:
3760 + dependencies:
3761 + has-flag: 4.0.0
3762 +
3763 + supports-preserve-symlinks-flag@1.0.0: {}
3764 +
3765 + tailwind-merge@2.6.1: {}
3766 +
3767 + tailwindcss@3.4.19:
3768 + dependencies:
3769 + '@alloc/quick-lru': 5.2.0
3770 + arg: 5.0.2
3771 + chokidar: 3.6.0
3772 + didyoumean: 1.2.2
3773 + dlv: 1.1.3
3774 + fast-glob: 3.3.3
3775 + glob-parent: 6.0.2
3776 + is-glob: 4.0.3
3777 + jiti: 1.21.7
3778 + lilconfig: 3.1.3
3779 + micromatch: 4.0.8
3780 + normalize-path: 3.0.0
3781 + object-hash: 3.0.0
3782 + picocolors: 1.1.1
3783 + postcss: 8.5.26
3784 + postcss-import: 15.1.0(postcss@8.5.26)
3785 + postcss-js: 4.1.0(postcss@8.5.26)
3786 + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)
3787 + postcss-nested: 6.2.0(postcss@8.5.26)
3788 + postcss-selector-parser: 6.1.4
3789 + resolve: 1.22.12
3790 + sucrase: 3.35.1
3791 + transitivePeerDependencies:
3792 + - tsx
3793 + - yaml
3794 +
3795 + text-table@0.2.0: {}
3796 +
3797 + thenify-all@1.6.0:
3798 + dependencies:
3799 + thenify: 3.3.1
3800 +
3801 + thenify@3.3.1:
3802 + dependencies:
3803 + any-promise: 1.3.0
3804 +
3805 + tinyglobby@0.2.17:
3806 + dependencies:
3807 + fdir: 6.5.0(picomatch@4.0.5)
3808 + picomatch: 4.0.5
3809 +
3810 + to-regex-range@5.0.1:
3811 + dependencies:
3812 + is-number: 7.0.0
3813 +
3814 + ts-api-utils@2.5.0(typescript@5.9.3):
3815 + dependencies:
3816 + typescript: 5.9.3
3817 +
3818 + ts-interface-checker@0.1.13: {}
3819 +
3820 + tsconfig-paths@3.15.0:
3821 + dependencies:
3822 + '@types/json5': 0.0.29
3823 + json5: 1.0.2
3824 + minimist: 1.2.8
3825 + strip-bom: 3.0.0
3826 +
3827 + tslib@2.8.1: {}
3828 +
3829 + type-check@0.4.0:
3830 + dependencies:
3831 + prelude-ls: 1.2.1
3832 +
3833 + type-fest@0.20.2: {}
3834 +
3835 + typed-array-buffer@1.0.3:
3836 + dependencies:
3837 + call-bound: 1.0.4
3838 + es-errors: 1.3.0
3839 + is-typed-array: 1.1.15
3840 +
3841 + typed-array-byte-length@1.0.3:
3842 + dependencies:
3843 + call-bind: 1.0.9
3844 + for-each: 0.3.5
3845 + gopd: 1.2.0
3846 + has-proto: 1.2.0
3847 + is-typed-array: 1.1.15
3848 +
3849 + typed-array-byte-offset@1.0.4:
3850 + dependencies:
3851 + available-typed-arrays: 1.0.7
3852 + call-bind: 1.0.9
3853 + for-each: 0.3.5
3854 + gopd: 1.2.0
3855 + has-proto: 1.2.0
3856 + is-typed-array: 1.1.15
3857 + reflect.getprototypeof: 1.0.10
3858 +
3859 + typed-array-length@1.0.8:
3860 + dependencies:
3861 + call-bind: 1.0.9
3862 + for-each: 0.3.5
3863 + gopd: 1.2.0
3864 + is-typed-array: 1.1.15
3865 + possible-typed-array-names: 1.1.0
3866 + reflect.getprototypeof: 1.0.10
3867 +
3868 + typescript@5.9.3: {}
3869 +
3870 + unbox-primitive@1.1.0:
3871 + dependencies:
3872 + call-bound: 1.0.4
3873 + has-bigints: 1.1.0
3874 + has-symbols: 1.1.0
3875 + which-boxed-primitive: 1.1.1
3876 +
3877 + undici-types@6.21.0: {}
3878 +
3879 + unrs-resolver@1.12.2:
3880 + dependencies:
3881 + napi-postinstall: 0.3.4
3882 + optionalDependencies:
3883 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
3884 + '@unrs/resolver-binding-android-arm64': 1.12.2
3885 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
3886 + '@unrs/resolver-binding-darwin-x64': 1.12.2
3887 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
3888 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
3889 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
3890 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
3891 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
3892 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
3893 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
3894 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
3895 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
3896 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
3897 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
3898 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
3899 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
3900 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
3901 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
3902 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
3903 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
3904 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
3905 +
3906 + update-browserslist-db@1.3.1(browserslist@4.28.8):
3907 + dependencies:
3908 + browserslist: 4.28.8
3909 + escalade: 3.2.0
3910 + picocolors: 1.1.1
3911 +
3912 + uri-js@4.4.1:
3913 + dependencies:
3914 + punycode: 2.3.1
3915 +
3916 + util-deprecate@1.0.2: {}
3917 +
3918 + which-boxed-primitive@1.1.1:
3919 + dependencies:
3920 + is-bigint: 1.1.0
3921 + is-boolean-object: 1.2.2
3922 + is-number-object: 1.1.1
3923 + is-string: 1.1.1
3924 + is-symbol: 1.1.1
3925 +
3926 + which-builtin-type@1.2.1:
3927 + dependencies:
3928 + call-bound: 1.0.4
3929 + function.prototype.name: 1.2.0
3930 + has-tostringtag: 1.0.2
3931 + is-async-function: 2.1.1
3932 + is-date-object: 1.1.0
3933 + is-finalizationregistry: 1.1.1
3934 + is-generator-function: 1.1.2
3935 + is-regex: 1.2.1
3936 + is-weakref: 1.1.1
3937 + isarray: 2.0.5
3938 + which-boxed-primitive: 1.1.1
3939 + which-collection: 1.0.2
3940 + which-typed-array: 1.1.22
3941 +
3942 + which-collection@1.0.2:
3943 + dependencies:
3944 + is-map: 2.0.3
3945 + is-set: 2.0.3
3946 + is-weakmap: 2.0.2
3947 + is-weakset: 2.0.4
3948 +
3949 + which-typed-array@1.1.22:
3950 + dependencies:
3951 + available-typed-arrays: 1.0.7
3952 + call-bind: 1.0.9
3953 + call-bound: 1.0.4
3954 + for-each: 0.3.5
3955 + get-proto: 1.0.1
3956 + gopd: 1.2.0
3957 + has-tostringtag: 1.0.2
3958 +
3959 + which@2.0.2:
3960 + dependencies:
3961 + isexe: 2.0.0
3962 +
3963 + word-wrap@1.2.5: {}
3964 +
3965 + wrappy@1.0.2: {}
3966 +
3967 + yocto-queue@0.1.0: {}
added M2M32crouve-ka/pnpm-workspace.yaml +8 −0
@@ -0,0 +1,8 @@
1 +# Trouve-KA — pnpm workspace
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +packages:
5 + - "apps/web"
6 +onlyBuiltDependencies:
7 + - sharp
8 + - unrs-resolver
added M2M32crouve-ka/pyproject.toml +85 −0
@@ -0,0 +1,85 @@
1 +# Trouve-KA — Python monorepo build configuration
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +[project]
6 +name = "trouveka"
7 +version = "0.1.0"
8 +description = "Trouve-KA — moteur de recherche web indépendant, Québec-first"
9 +requires-python = ">=3.12"
10 +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }]
11 +dependencies = [
12 + "fastapi>=0.115",
13 + "uvicorn[standard]>=0.30",
14 + "httpx>=0.27",
15 + "brotli>=1.1",
16 + "selectolax>=0.3.21",
17 + "protego>=0.3.1",
18 + "asyncpg>=0.29",
19 + "redis>=5.0",
20 + "opensearch-py[async]>=2.6",
21 + "pydantic>=2.7",
22 + "pydantic-settings>=2.3",
23 + "langdetect>=1.0.9",
24 + "python-dateutil>=2.9",
25 +]
26 +
27 +[project.optional-dependencies]
28 +dev = [
29 + "pytest>=8",
30 + "pytest-asyncio>=0.23",
31 + "ruff>=0.5",
32 +]
33 +
34 +[build-system]
35 +requires = ["setuptools>=69"]
36 +build-backend = "setuptools.build_meta"
37 +
38 +# The "trouveka" namespace package is assembled from the monorepo layout:
39 +# packages/* hold shared libraries, services/* hold pipeline services,
40 +# apps/api holds the FastAPI application (see CLAUDE.md §2).
41 +[tool.setuptools]
42 +packages = [
43 + "trouveka.config",
44 + "trouveka.logging",
45 + "trouveka.types",
46 + "trouveka.shared",
47 + "trouveka.database",
48 + "trouveka.queue",
49 + "trouveka.search_core",
50 + "trouveka.crawler",
51 + "trouveka.frontier",
52 + "trouveka.parser",
53 + "trouveka.classifier",
54 + "trouveka.indexer",
55 + "trouveka.ranking",
56 + "trouveka.scheduler",
57 + "trouveka.enrichment",
58 + "trouveka.api",
59 +]
60 +
61 +[tool.setuptools.package-dir]
62 +"trouveka.config" = "packages/config"
63 +"trouveka.logging" = "packages/logging"
64 +"trouveka.types" = "packages/types"
65 +"trouveka.shared" = "packages/shared"
66 +"trouveka.database" = "packages/database"
67 +"trouveka.queue" = "packages/queue"
68 +"trouveka.search_core" = "packages/search-core"
69 +"trouveka.crawler" = "services/crawler"
70 +"trouveka.frontier" = "services/frontier"
71 +"trouveka.parser" = "services/parser"
72 +"trouveka.classifier" = "services/classifier"
73 +"trouveka.indexer" = "services/indexer"
74 +"trouveka.ranking" = "services/ranking"
75 +"trouveka.scheduler" = "services/scheduler"
76 +"trouveka.enrichment" = "services/enrichment"
77 +"trouveka.api" = "apps/api"
78 +
79 +[tool.pytest.ini_options]
80 +asyncio_mode = "auto"
81 +testpaths = ["tests"]
82 +
83 +[tool.ruff]
84 +line-length = 110
85 +target-version = "py312"
added M2M32crouve-ka/scripts/bootstrap-seeds/seed.sh +14 −0
@@ -0,0 +1,14 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — chargement des seeds dans le frontier
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -euo pipefail
6 +cd "$(dirname "$0")/../.."
7 +
8 +if [ -x .venv/bin/python ]; then
9 + PY=.venv/bin/python
10 +else
11 + PY=python3
12 +fi
13 +
14 +exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}"
added M2M32crouve-ka/scripts/bootstrap-seeds/seeds.txt +80 −0
@@ -0,0 +1,80 @@
1 +# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire.
5 +
6 +# --- Gouvernement du Québec ---
7 +https://www.quebec.ca/
8 +https://www.assnat.qc.ca/
9 +https://www.revenuquebec.ca/
10 +https://www.ramq.gouv.qc.ca/
11 +https://saaq.gouv.qc.ca/
12 +https://www.cnesst.gouv.qc.ca/
13 +https://www.hydroquebec.com/
14 +https://www.investquebec.com/
15 +https://www.transitionenergetique.gouv.qc.ca/
16 +
17 +# --- Municipalités ---
18 +https://montreal.ca/
19 +https://www.ville.quebec.qc.ca/
20 +https://www.laval.ca/
21 +https://www.gatineau.ca/
22 +https://www.sherbrooke.ca/
23 +https://www.longueuil.quebec/
24 +https://www.trois-rivieres.ca/
25 +https://ville.saguenay.ca/
26 +https://www.levis.ca/
27 +https://www.terrebonne.ca/
28 +https://www.drummondville.ca/
29 +https://www.rimouski.ca/
30 +
31 +# --- Universités et cégeps ---
32 +https://www.ulaval.ca/
33 +https://www.umontreal.ca/
34 +https://www.mcgill.ca/
35 +https://uqam.ca/
36 +https://www.usherbrooke.ca/
37 +https://www.concordia.ca/
38 +https://www.polymtl.ca/
39 +https://www.etsmtl.ca/
40 +https://www.hec.ca/
41 +https://www.uqac.ca/
42 +https://www.uqtr.ca/
43 +https://www.uqar.ca/
44 +https://uqo.ca/
45 +https://www.inrs.ca/
46 +https://www.cegepsquebec.ca/
47 +
48 +# --- Médias ---
49 +https://www.lapresse.ca/
50 +https://www.ledevoir.com/
51 +https://www.journaldemontreal.com/
52 +https://www.journaldequebec.com/
53 +https://ici.radio-canada.ca/
54 +https://www.tvanouvelles.ca/
55 +https://www.lesoleil.com/
56 +https://www.ledroit.com/
57 +https://www.latribune.ca/
58 +https://www.lenouvelliste.ca/
59 +https://www.noovo.info/
60 +https://www.lactualite.com/
61 +
62 +# --- Affaires, annuaires, associations ---
63 +https://www.registreentreprises.gouv.qc.ca/
64 +https://www.desjardins.com/
65 +https://www.fccq.ca/
66 +https://www.ccmm.ca/
67 +https://www.pagesjaunes.ca/
68 +https://quebec.craigslist.org/
69 +https://www.cqcd.org/
70 +https://www.manufacturiersquebec.ca/
71 +
72 +# --- Tourisme et culture ---
73 +https://www.bonjourquebec.com/
74 +https://www.mtl.org/
75 +https://www.quebec-cite.com/
76 +https://www.tourisme-charlevoix.com/
77 +https://www.tourismegaspesie.com/
78 +https://www.sepaq.com/
79 +https://www.lavitrine.com/
80 +https://www.banq.qc.ca/
added M2M32crouve-ka/scripts/check-headers.py +38 −0
@@ -0,0 +1,38 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1)
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Vérifie que chaque fichier source porte le header auteur obligatoire."""
7 +
8 +import pathlib
9 +import sys
10 +
11 +ROOT = pathlib.Path(__file__).resolve().parents[1]
12 +EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"}
13 +SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"}
14 +SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"}
15 +REQUIRED = "Author: Simon-Pierre Boucher"
16 +
17 +
18 +def main() -> int:
19 + missing: list[pathlib.Path] = []
20 + for path in ROOT.rglob("*"):
21 + if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES:
22 + continue
23 + if any(part in SKIP_DIRS for part in path.parts):
24 + continue
25 + head = path.read_text(encoding="utf-8", errors="replace")[:600]
26 + if REQUIRED not in head:
27 + missing.append(path.relative_to(ROOT))
28 + if missing:
29 + print(f"✗ {len(missing)} fichier(s) sans header auteur :")
30 + for p in missing:
31 + print(f" - {p}")
32 + return 1
33 + print("✓ Tous les fichiers source portent le header auteur.")
34 + return 0
35 +
36 +
37 +if __name__ == "__main__":
38 + sys.exit(main())
added M2M32crouve-ka/scripts/cleanup-garbage.py +63 −0
@@ -0,0 +1,63 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — purge des documents mal décodés de l'index
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé)
7 +et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement).
8 +
9 +Usage : python -m scripts... non — exécuter depuis la racine :
10 + .venv/bin/python scripts/cleanup-garbage.py # dev local
11 + (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py)
12 +"""
13 +
14 +import asyncio
15 +
16 +from trouveka.config import get_settings
17 +from trouveka.database import Database
18 +from trouveka.parser import looks_like_garbage
19 +from trouveka.search_core import SearchCore
20 +
21 +
22 +async def main() -> None:
23 + s = get_settings()
24 + db = Database(s.database_url, pool_min=1, pool_max=3)
25 + await db.connect()
26 + search = SearchCore(s.search_url, s.search_index)
27 +
28 + removed = 0
29 + scanned = 0
30 + # Scroll de tout l'index par pages de 200
31 + body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]}
32 + resp = await search.client.search(index=s.search_index, body=body, scroll="2m")
33 + scroll_id = resp.get("_scroll_id")
34 + try:
35 + while True:
36 + hits = resp["hits"]["hits"]
37 + if not hits:
38 + break
39 + for hit in hits:
40 + scanned += 1
41 + src = hit["_source"]
42 + text = " ".join([src.get("title") or "", src.get("description") or "",
43 + (src.get("body") or "")[:20_000]])
44 + if looks_like_garbage(text, threshold=0.02):
45 + await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404])
46 + await db.requeue_url(src["url"])
47 + await db.pool.execute(
48 + "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)",
49 + src["url"],
50 + )
51 + removed += 1
52 + print(f"purgé : {src['url']}")
53 + resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m")
54 + finally:
55 + if scroll_id:
56 + await search.client.clear_scroll(scroll_id=scroll_id)
57 + await search.close()
58 + await db.close()
59 + print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.")
60 +
61 +
62 +if __name__ == "__main__":
63 + asyncio.run(main())
added M2M32crouve-ka/scripts/eval/ranking-eval.yaml +29 −0
@@ -0,0 +1,29 @@
1 +# Trouve-KA — dataset d'évaluation du ranking (§16)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible.
5 +cases:
6 + - query: "université québec"
7 + expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca]
8 + top_k: 5
9 + - query: "plombier gatineau"
10 + expect_any_of: [gatineau.ca, pagesjaunes.ca]
11 + top_k: 10
12 + - query: "subvention thermopompe"
13 + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca]
14 + top_k: 10
15 + - query: "heat pump rebate"
16 + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca]
17 + top_k: 10
18 + - query: "impôt revenu"
19 + expect_any_of: [revenuquebec.ca, quebec.ca]
20 + top_k: 5
21 + - query: "tourisme gaspésie"
22 + expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com]
23 + top_k: 5
24 + - query: "nouvelles montréal"
25 + expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org]
26 + top_k: 10
27 + - query: "permis de conduire"
28 + expect_any_of: [saaq.gouv.qc.ca, quebec.ca]
29 + top_k: 5
added M2M32crouve-ka/scripts/eval/run-eval.py +65 −0
@@ -0,0 +1,65 @@
1 +#!/usr/bin/env python3
2 +# Trouve-KA — évaluation du ranking contre le dataset (§16)
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +
6 +"""Exécute le dataset d'évaluation contre l'API de recherche.
7 +
8 +Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080]
9 +Un cas passe si au moins un domaine attendu apparaît dans le top-k.
10 +L'index grandit en continu : les scores s'améliorent avec la couverture.
11 +"""
12 +
13 +import argparse
14 +import json
15 +import pathlib
16 +import re
17 +import urllib.parse
18 +import urllib.request
19 +
20 +HERE = pathlib.Path(__file__).parent
21 +
22 +
23 +def load_cases() -> list[dict]:
24 + """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML)."""
25 + cases, current = [], None
26 + for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines():
27 + if m := re.match(r'\s*- query: "(.*)"', line):
28 + current = {"query": m.group(1)}
29 + cases.append(current)
30 + elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line):
31 + current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")]
32 + elif m := re.match(r"\s*top_k: (\d+)", line):
33 + current["top_k"] = int(m.group(1))
34 + return cases
35 +
36 +
37 +def main() -> int:
38 + ap = argparse.ArgumentParser()
39 + ap.add_argument("--api", default="http://localhost:8080")
40 + args = ap.parse_args()
41 +
42 + passed = 0
43 + cases = load_cases()
44 + for case in cases:
45 + q = urllib.parse.quote(case["query"])
46 + url = f"{args.api}/api/search?q={q}&limit={case['top_k']}"
47 + try:
48 + with urllib.request.urlopen(url, timeout=10) as resp:
49 + data = json.load(resp)
50 + except Exception as exc:
51 + print(f"✗ {case['query']!r} — API injoignable : {exc}")
52 + continue
53 + domains = [r["domain"] for r in data["results"]]
54 + hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None)
55 + if hit:
56 + passed += 1
57 + print(f"✓ {case['query']!r}{hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)")
58 + else:
59 + print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}")
60 + print(f"\n{passed}/{len(cases)} cas réussis")
61 + return 0 if passed == len(cases) else 1
62 +
63 +
64 +if __name__ == "__main__":
65 + raise SystemExit(main())
added M2M32crouve-ka/scripts/health-check/check.sh +27 −0
@@ -0,0 +1,27 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — vérification de santé du stack
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -uo pipefail
6 +
7 +API_URL="${API_URL:-http://localhost:8080}"
8 +SEARCH_URL="${SEARCH_URL:-http://localhost:9200}"
9 +WEB_URL="${WEB_URL:-http://localhost:3000}"
10 +FAIL=0
11 +
12 +check() {
13 + local name="$1" url="$2"
14 + if curl -sf --max-time 5 "$url" > /dev/null; then
15 + echo "✓ $name ($url)"
16 + else
17 + echo "✗ $name ($url)"
18 + FAIL=1
19 + fi
20 +}
21 +
22 +check "API santé" "$API_URL/api/health"
23 +check "API statut" "$API_URL/api/status"
24 +check "OpenSearch" "$SEARCH_URL/_cluster/health"
25 +check "Web" "$WEB_URL/"
26 +
27 +exit $FAIL
added M2M32crouve-ka/scripts/start-crawler/start.sh +14 −0
@@ -0,0 +1,14 @@
1 +#!/usr/bin/env bash
2 +# Trouve-KA — démarrage local d'un worker de crawl
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +set -euo pipefail
6 +cd "$(dirname "$0")/../.."
7 +
8 +if [ -x .venv/bin/python ]; then
9 + PY=.venv/bin/python
10 +else
11 + PY=python3
12 +fi
13 +
14 +exec "$PY" -m trouveka.crawler.worker
added M2M32crouve-ka/services/classifier/__init__.py +15 −0
@@ -0,0 +1,15 @@
1 +# Trouve-KA — classification Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Détection de pertinence québécoise (CLAUDE.md §7).
6 +
7 +Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score
8 +(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être
9 +pertinent sans que le domaine le soit.
10 +"""
11 +
12 +from .scorer import score_page
13 +from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS
14 +
15 +__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"]
added M2M32crouve-ka/services/classifier/gazetteer.py +75 −0
@@ -0,0 +1,75 @@
1 +# Trouve-KA — gazetteer québécois
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Toponymes, régions et signaux institutionnels du Québec.
6 +
7 +Liste de départ volontairement conservatrice : des noms non ambigus d'abord.
8 +Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent
9 +un poids réduit dans le scorer.
10 +"""
11 +
12 +# Municipalités et lieux à fort signal (poids plein)
13 +QUEBEC_TOPONYMS: set[str] = {
14 + "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke",
15 + "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis",
16 + "longueuil", "terrebonne", "brossard", "repentigny", "drummondville",
17 + "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan",
18 + "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe",
19 + "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay",
20 + "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or",
21 + "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière",
22 + "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe",
23 + "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel",
24 + "beloeil", "mascouche", "varennes", "chambly", "saint-eustache",
25 + "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele",
26 + "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois",
27 + "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière",
28 + "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue",
29 + "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec",
30 + "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches",
31 + "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean",
32 + "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine",
33 + "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount",
34 + "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval",
35 + "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges",
36 +}
37 +
38 +# Toponymes ambigus (existent ailleurs) — poids réduit
39 +AMBIGUOUS_TOPONYMS: set[str] = {
40 + "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton",
41 + "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey",
42 +}
43 +
44 +QUEBEC_REGIONS: set[str] = {
45 + "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides",
46 + "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie",
47 + "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik",
48 +}
49 +
50 +# Institutions et organisations québécoises fortement identifiantes
51 +QUEBEC_ORGS: set[str] = {
52 + "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec",
53 + "desjardins", "gouvernement du québec", "gouvernement du quebec",
54 + "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec",
55 + "saq", "sqdc", "caq", "société de transport de montréal", "stm",
56 + "réseau de transport de la capitale", "rtc", "exo", "via rail",
57 + "université laval", "universite laval", "université de montréal",
58 + "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat",
59 + "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal",
60 + "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets",
61 + "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec",
62 + "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec",
63 + "surete du quebec", "loto-québec", "loto-quebec", "investissement québec",
64 + "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq",
65 + "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir",
66 + "journal de montréal", "journal de montreal", "journal de québec",
67 + "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec",
68 + "tourisme québec", "tourisme quebec",
69 +}
70 +
71 +# Indicatifs téléphoniques du Québec (signal faible)
72 +QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"}
73 +
74 +# TLD / suffixes de domaine à signal fort
75 +STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca")
added M2M32crouve-ka/services/classifier/scorer.py +115 −0
@@ -0,0 +1,115 @@
1 +# Trouve-KA — scoreur Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Calcul de page_quebec_score ∈ [0, 1].
6 +
7 +Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes
8 +postaux QC, indicatifs téléphoniques, organisations connues, langue française,
9 +mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul
10 +ne prouve rien); le score sature progressivement via une somme amortie.
11 +"""
12 +
13 +import re
14 +
15 +from trouveka.types import ParsedPage, QuebecSignals
16 +
17 +from .gazetteer import (
18 + AMBIGUOUS_TOPONYMS,
19 + QUEBEC_AREA_CODES,
20 + QUEBEC_ORGS,
21 + QUEBEC_TOPONYMS,
22 + STRONG_DOMAIN_SUFFIXES,
23 +)
24 +
25 +# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1)
26 +_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE)
27 +_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b")
28 +_PROVINCE_RE = re.compile(
29 + r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)",
30 + re.IGNORECASE,
31 +)
32 +_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])"
33 +
34 +
35 +def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]:
36 + found: list[str] = []
37 + total = 0
38 + for term in terms:
39 + pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE)
40 + n = len(pattern.findall(text))
41 + if n:
42 + found.append(term)
43 + total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise
44 + if total >= cap:
45 + break
46 + return min(total, cap), found
47 +
48 +
49 +def score_page(page: ParsedPage, domain: str) -> QuebecSignals:
50 + """Score Québec d'une page. Déterministe, sans LLM, économique (§12)."""
51 + signals = QuebecSignals()
52 + reasons: list[str] = []
53 + points = 0.0
54 +
55 + text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]])
56 + text_with_hints = text + " " + " ".join(page.structured_hints)
57 + lower = text_with_hints.lower()
58 +
59 + # 1. Domaine (signal fort mais pas suffisant seul)
60 + host = domain.lower()
61 + if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES):
62 + points += 4.0
63 + reasons.append("tld_quebec")
64 +
65 + # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps)
66 + head_text = " ".join([page.title, page.description, " ".join(page.headings)])
67 + head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6)
68 + body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8)
69 + if head_hits:
70 + points += 1.2 * head_hits
71 + reasons.append("toponymes_titre")
72 + if body_hits:
73 + points += 0.4 * body_hits
74 + reasons.append("toponymes_corps")
75 + signals.locations = sorted({*head_names, *body_names})[:12]
76 +
77 + # 3. Toponymes ambigus — poids réduit
78 + amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3)
79 + if amb_hits:
80 + points += 0.15 * amb_hits
81 + reasons.append("toponymes_ambigus")
82 + signals.locations = sorted({*signals.locations, *amb_names})[:12]
83 +
84 + # 4. Codes postaux QC (signal fort : preuve d'adresse physique)
85 + postal_hits = len(set(_POSTAL_RE.findall(text_with_hints)))
86 + if postal_hits:
87 + points += min(postal_hits, 3) * 1.5
88 + reasons.append("code_postal_qc")
89 +
90 + # 5. Mention structurée de la province (adresses, footers)
91 + if _PROVINCE_RE.search(text_with_hints):
92 + points += 1.5
93 + reasons.append("province_quebec")
94 +
95 + # 6. Indicatifs téléphoniques (signal faible)
96 + area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES}
97 + if area_codes:
98 + points += min(len(area_codes), 2) * 0.6
99 + reasons.append("indicatif_qc")
100 +
101 + # 7. Organisations québécoises connues
102 + org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6)
103 + if org_hits:
104 + points += 0.8 * org_hits
105 + reasons.append("organisations_qc")
106 +
107 + # 8. Langue : le français augmente la probabilité sans la prouver
108 + if page.language == "fr":
109 + points += 0.8
110 + reasons.append("francais")
111 +
112 + # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95
113 + signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0
114 + signals.reasons = reasons
115 + return signals
added M2M32crouve-ka/services/crawler/__init__.py +12 −0
@@ -0,0 +1,12 @@
1 +# Trouve-KA — crawler
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt,
6 +fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index."""
7 +
8 +from .robots import RobotsCache
9 +from .fetcher import Fetcher
10 +from .traps import looks_like_trap
11 +
12 +__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"]
added M2M32crouve-ka/services/crawler/fetcher.py +148 −0
@@ -0,0 +1,148 @@
1 +# Trouve-KA — fetcher HTTP sécurisé
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille,
6 +gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified).
7 +
8 +Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur.
9 +"""
10 +
11 +import time
12 +from urllib.parse import urlsplit
13 +
14 +import httpx
15 +
16 +from trouveka.config import Settings
17 +from trouveka.shared import canonicalize_url, is_safe_url
18 +from trouveka.types import ErrorCode, FetchResult
19 +
20 +ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain")
21 +
22 +
23 +class Fetcher:
24 + def __init__(self, client: httpx.AsyncClient, settings: Settings):
25 + self._client = client
26 + self._s = settings
27 +
28 + async def fetch(
29 + self, url: str, *, etag: str | None = None, last_modified: str | None = None
30 + ) -> FetchResult:
31 + """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut)."""
32 + start = time.monotonic()
33 + chain: list[str] = []
34 + current = url
35 +
36 + for _hop in range(self._s.max_redirects + 1):
37 + if not is_safe_url(current):
38 + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain)
39 +
40 + # Accept-Encoding volontairement absent : httpx annonce lui-même les
41 + # encodages qu'il sait décoder (br seulement si brotli est installé).
42 + # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca).
43 + headers = {
44 + "User-Agent": self._s.crawler_user_agent,
45 + "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1",
46 + "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7",
47 + }
48 + if etag and current == url:
49 + headers["If-None-Match"] = etag
50 + if last_modified and current == url:
51 + headers["If-Modified-Since"] = last_modified
52 +
53 + try:
54 + async with self._client.stream(
55 + "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False
56 + ) as resp:
57 + if resp.status_code in (301, 302, 303, 307, 308):
58 + location = resp.headers.get("location")
59 + if not location:
60 + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code)
61 + next_url = canonicalize_url(location, base=current)
62 + if not next_url:
63 + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code)
64 + chain.append(next_url)
65 + current = next_url
66 + continue
67 +
68 + if resp.status_code == 304:
69 + return FetchResult(
70 + url=url, final_url=current, status_code=304,
71 + duration_ms=self._ms(start), redirect_chain=chain,
72 + )
73 + if 400 <= resp.status_code < 500:
74 + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code)
75 + if resp.status_code >= 500:
76 + return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code)
77 +
78 + # X-Robots-Tag : noindex au niveau HTTP
79 + x_robots = (resp.headers.get("x-robots-tag") or "").lower()
80 +
81 + raw_ct = resp.headers.get("content-type") or ""
82 + content_type = raw_ct.split(";")[0].strip().lower()
83 + charset = None
84 + if "charset=" in raw_ct.lower():
85 + charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None
86 + if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES):
87 + return self._fail(
88 + url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code
89 + )
90 +
91 + declared = resp.headers.get("content-length")
92 + if declared and int(declared) > self._s.max_response_bytes:
93 + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code)
94 +
95 + body = b""
96 + async for part in resp.aiter_bytes():
97 + body += part
98 + if len(body) > self._s.max_response_bytes:
99 + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code)
100 +
101 + result = FetchResult(
102 + url=url,
103 + final_url=current,
104 + status_code=resp.status_code,
105 + content_type=content_type or None,
106 + charset=charset,
107 + body=body,
108 + etag=resp.headers.get("etag"),
109 + last_modified=resp.headers.get("last-modified"),
110 + duration_ms=self._ms(start),
111 + redirect_chain=chain,
112 + )
113 + if "noindex" in x_robots:
114 + result.error_code = ErrorCode.ROBOTS_DENIED
115 + return result
116 +
117 + except httpx.TimeoutException:
118 + return self._fail(url, current, ErrorCode.TIMEOUT, start, chain)
119 + except httpx.ConnectError as exc:
120 + code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS
121 + return self._fail(url, current, code, start, chain)
122 + except httpx.HTTPError:
123 + return self._fail(url, current, ErrorCode.CONNECTION, start, chain)
124 +
125 + return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain)
126 +
127 + @staticmethod
128 + def _ms(start: float) -> int:
129 + return int((time.monotonic() - start) * 1000)
130 +
131 + def _fail(
132 + self,
133 + url: str,
134 + final_url: str,
135 + code: ErrorCode,
136 + start: float,
137 + chain: list[str],
138 + status: int | None = None,
139 + ) -> FetchResult:
140 + return FetchResult(
141 + url=url, final_url=final_url, status_code=status, error_code=code,
142 + duration_ms=self._ms(start), redirect_chain=chain,
143 + )
144 +
145 +
146 +def scheme_host(url: str) -> str:
147 + parts = urlsplit(url)
148 + return f"{parts.scheme}://{parts.netloc}"
added M2M32crouve-ka/services/crawler/robots.py +84 −0
@@ -0,0 +1,84 @@
1 +# Trouve-KA — gestion robots.txt
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy).
6 +
7 +Les règles sont persistées en base (domains.robots_body) et rafraîchies
8 +après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur
9 +serveur = prudence, on refuse temporairement.
10 +"""
11 +
12 +from datetime import UTC, datetime, timedelta
13 +
14 +import httpx
15 +from protego import Protego
16 +
17 +from trouveka.logging import get_logger
18 +
19 +log = get_logger("crawler.robots")
20 +
21 +ROBOTS_TTL = timedelta(hours=24)
22 +_MAX_ROBOTS_BYTES = 512_000
23 +
24 +
25 +class RobotsCache:
26 + def __init__(self, db, client: httpx.AsyncClient, user_agent: str):
27 + self._db = db
28 + self._client = client
29 + self._ua = user_agent
30 + self._parsers: dict[int, tuple[Protego | None, datetime]] = {}
31 +
32 + async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]:
33 + """Retourne (body, status) : ok | not_found | error."""
34 + try:
35 + resp = await self._client.get(
36 + f"{scheme_host}/robots.txt",
37 + headers={"User-Agent": self._ua},
38 + timeout=15,
39 + follow_redirects=True,
40 + )
41 + except httpx.HTTPError:
42 + return None, "error"
43 + if resp.status_code == 200:
44 + return resp.text[:_MAX_ROBOTS_BYTES], "ok"
45 + if 400 <= resp.status_code < 500:
46 + return None, "not_found" # 4xx = pas de robots → tout permis (standard)
47 + return None, "error"
48 +
49 + async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]:
50 + """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms)."""
51 + cached = self._parsers.get(domain_id)
52 + now = datetime.now(UTC)
53 + if cached and now - cached[1] < ROBOTS_TTL:
54 + parser = cached[0]
55 + return parser, "ok" if parser else "not_found", None
56 +
57 + row = await self._db.get_domain_by_id(domain_id)
58 + if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL:
59 + body, status = row["robots_body"], row["robots_status"]
60 + else:
61 + body, status = await self._fetch_robots(scheme_host)
62 + delay_ms = None
63 + if body:
64 + delay = Protego.parse(body).crawl_delay(self._ua)
65 + delay_ms = int(delay * 1000) if delay else None
66 + await self._db.save_robots(domain_id, body, status, delay_ms)
67 +
68 + parser = Protego.parse(body) if body else None
69 + self._parsers[domain_id] = (parser, now)
70 + if len(self._parsers) > 5000: # borne mémoire du cache local
71 + self._parsers.pop(next(iter(self._parsers)))
72 + return parser, status, row["crawl_delay_ms"] if row else None
73 +
74 + async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]:
75 + """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence)."""
76 + parser, status, delay_ms = await self.get_parser(domain_id, scheme_host)
77 + if status == "error":
78 + return False, None
79 + if parser is None:
80 + return True, None
81 + delay = parser.crawl_delay(self._ua)
82 + if delay is None and delay_ms:
83 + delay = delay_ms / 1000
84 + return parser.can_fetch(url, self._ua), delay
added M2M32crouve-ka/services/crawler/seed.py +57 −0
@@ -0,0 +1,57 @@
1 +# Trouve-KA — chargeur de seeds
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Charge les seeds dans le frontier avec priorité maximale.
6 +
7 +Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt]
8 +"""
9 +
10 +import asyncio
11 +import pathlib
12 +import sys
13 +
14 +from trouveka.config import get_settings
15 +from trouveka.database import Database, run_migrations
16 +from trouveka.logging import get_logger
17 +from trouveka.shared import canonicalize_url, extract_domain
18 +
19 +log = get_logger("crawler.seed")
20 +
21 +DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt"
22 +
23 +
24 +async def load_seeds(path: pathlib.Path) -> int:
25 + settings = get_settings()
26 + applied = await run_migrations(settings.database_url)
27 + if applied:
28 + log.info("migrations appliquées", extra={"ctx": {"files": applied}})
29 +
30 + db = Database(settings.database_url, pool_min=1, pool_max=3)
31 + await db.connect()
32 + added = 0
33 + try:
34 + for line in path.read_text(encoding="utf-8").splitlines():
35 + line = line.strip()
36 + if not line or line.startswith("#"):
37 + continue
38 + url = canonicalize_url(line)
39 + domain = extract_domain(url) if url else None
40 + if not url or not domain:
41 + log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}})
42 + continue
43 + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):
44 + added += 1
45 + finally:
46 + await db.close()
47 + log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}})
48 + return added
49 +
50 +
51 +def main() -> None:
52 + path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS
53 + asyncio.run(load_seeds(path))
54 +
55 +
56 +if __name__ == "__main__":
57 + main()
added M2M32crouve-ka/services/crawler/traps.py +59 −0
@@ -0,0 +1,59 @@
1 +# Trouve-KA — détection de pièges de crawl
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs,
6 +explosions de facettes, pagination infinie, chemins répétitifs."""
7 +
8 +import re
9 +from urllib.parse import parse_qsl, urlsplit
10 +
11 +_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"}
12 +_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$")
13 +_LONG_NUMBER_RE = re.compile(r"\d{10,}")
14 +
15 +
16 +def looks_like_trap(
17 + url: str,
18 + *,
19 + max_query_params: int = 8,
20 + max_path_segments: int = 12,
21 + max_url_length: int = 1024,
22 +) -> bool:
23 + """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier."""
24 + if len(url) > max_url_length:
25 + return True
26 + try:
27 + parts = urlsplit(url)
28 + except ValueError:
29 + return True
30 +
31 + params = parse_qsl(parts.query, keep_blank_values=True)
32 + if len(params) > max_query_params:
33 + return True
34 + keys = {k.lower() for k, _ in params}
35 + if keys & _SESSION_PARAMS:
36 + return True
37 + # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…)
38 + raw_keys = [k.lower() for k, _ in params]
39 + if any(raw_keys.count(k) > 3 for k in set(raw_keys)):
40 + return True
41 +
42 + segments = [s for s in parts.path.split("/") if s]
43 + if len(segments) > max_path_segments:
44 + return True
45 + # Segment répété (boucles : /a/b/a/b/a/b)
46 + if any(segments.count(s) > 3 for s in set(segments)):
47 + return True
48 + # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17
49 + if _CALENDAR_RE.search(parts.path):
50 + year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path)
51 + if year_match and not (1995 <= int(year_match.group(1)) <= 2027):
52 + return True
53 + # Pagination excessive
54 + for key, value in params:
55 + if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500:
56 + return True
57 + if _LONG_NUMBER_RE.search(parts.query):
58 + return True
59 + return False
added M2M32crouve-ka/services/crawler/worker.py +447 −0
@@ -0,0 +1,447 @@
1 +# Trouve-KA — worker de crawl (pipeline complet)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline
6 +fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3).
7 +
8 +Chaque page traitée avec succès est cherchable en quelques secondes.
9 +L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais.
10 +N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte).
11 +"""
12 +
13 +import asyncio
14 +import os
15 +import random
16 +import signal
17 +import uuid
18 +from datetime import UTC, datetime, timedelta
19 +
20 +import httpx
21 +
22 +from trouveka.classifier import score_page
23 +from trouveka.config import get_settings
24 +from trouveka.database import Database
25 +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay
26 +from trouveka.indexer import build_search_document
27 +from trouveka.logging import get_logger
28 +from trouveka.parser import looks_like_garbage, parse_html
29 +from trouveka.queue import Coordination
30 +from trouveka.search_core import SearchCore
31 +from trouveka.shared import canonicalize_url, content_hash, extract_domain
32 +from trouveka.types import ErrorCode, Outcome
33 +
34 +from .fetcher import Fetcher, scheme_host
35 +from .robots import RobotsCache
36 +from .traps import looks_like_trap
37 +
38 +log = get_logger("crawler.worker")
39 +
40 +TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS}
41 +MAX_RETRIES = 3
42 +
43 +
44 +class CrawlerWorker:
45 + def __init__(self) -> None:
46 + self.s = get_settings()
47 + self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}"
48 + self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max)
49 + self.coord = Coordination(self.s.redis_url)
50 + self.search = SearchCore(self.s.search_url, self.s.search_index)
51 + self.client = httpx.AsyncClient(
52 + limits=httpx.Limits(
53 + max_connections=self.s.max_global_concurrency,
54 + max_keepalive_connections=self.s.max_global_concurrency,
55 + ),
56 + http2=False,
57 + )
58 + self.fetcher = Fetcher(self.client, self.s)
59 + self.robots: RobotsCache | None = None
60 + self.stop_event = asyncio.Event()
61 + self._domain_cache: dict[int, dict] = {}
62 + # Cache domaine→row pour la découverte : évite un SELECT par lien sortant
63 + # (une page peut avoir 300 liens, souvent vers les mêmes domaines).
64 + self._domain_by_name: dict[str, tuple[object, datetime]] = {}
65 +
66 + # ------------------------------------------------------------------ cycle de vie
67 +
68 + async def start(self) -> None:
69 + await self.db.connect()
70 + self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent)
71 + await self.search.ensure_index()
72 + log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}})
73 +
74 + loop = asyncio.get_running_loop()
75 + for sig in (signal.SIGINT, signal.SIGTERM):
76 + loop.add_signal_handler(sig, self.stop_event.set)
77 +
78 + # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère.
79 + # (Un gather par lot créait du head-of-line blocking : une page lente
80 + # bloquait tout le lot avant la réclamation suivante.)
81 + in_flight: set[asyncio.Task] = set()
82 + idle_sleep = 1.0
83 +
84 + async def run_one(item) -> None:
85 + try:
86 + await self.process_item(item)
87 + except Exception:
88 + log.exception(
89 + "échec inattendu du pipeline",
90 + extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}},
91 + )
92 + await self.db.release_item(
93 + item["url_id"], status="failed", error_code="parse_failed"
94 + )
95 +
96 + while not self.stop_event.is_set():
97 + if await self.coord.is_paused():
98 + await asyncio.sleep(3)
99 + continue
100 + free = self.s.max_global_concurrency - len(in_flight)
101 + if free <= 0:
102 + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED)
103 + continue
104 + batch = await self.db.claim_batch(self.worker_id, limit=free)
105 + if not batch:
106 + if in_flight:
107 + await asyncio.wait(
108 + in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep
109 + )
110 + else:
111 + await asyncio.sleep(idle_sleep)
112 + idle_sleep = min(idle_sleep * 1.5, 15)
113 + continue
114 + idle_sleep = 1.0
115 + for item in batch:
116 + task = asyncio.create_task(run_one(item))
117 + in_flight.add(task)
118 + task.add_done_callback(in_flight.discard)
119 +
120 + if in_flight:
121 + await asyncio.gather(*in_flight, return_exceptions=True)
122 + await self.shutdown()
123 +
124 + async def shutdown(self) -> None:
125 + await self.client.aclose()
126 + await self.search.close()
127 + await self.coord.close()
128 + await self.db.close()
129 + log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}})
130 +
131 + # ------------------------------------------------------------------ helpers
132 +
133 + async def _domain(self, domain_id: int) -> dict:
134 + cached = self._domain_cache.get(domain_id)
135 + if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60:
136 + return cached
137 + row = await self.db.get_domain_by_id(domain_id)
138 + entry = dict(row) | {"_at": datetime.now(UTC)}
139 + self._domain_cache[domain_id] = entry
140 + if len(self._domain_cache) > 5000:
141 + self._domain_cache.pop(next(iter(self._domain_cache)))
142 + return entry
143 +
144 + async def _defer(self, url_id: int, seconds: float) -> None:
145 + """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte).
146 +
147 + Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte
148 + (sinon elles reviennent toutes en tête de file au même instant et churnent).
149 + """
150 + await self.db.release_item(
151 + url_id,
152 + status="pending",
153 + next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)),
154 + )
155 +
156 + async def _domain_row_by_name(self, domain: str):
157 + """Row domaine (ou None si inconnu), avec cache TTL 60 s."""
158 + cached = self._domain_by_name.get(domain)
159 + now = datetime.now(UTC)
160 + if cached and (now - cached[1]).total_seconds() < 60:
161 + return cached[0]
162 + row = await self.db.get_domain(domain)
163 + self._domain_by_name[domain] = (row, now)
164 + if len(self._domain_by_name) > 20_000:
165 + self._domain_by_name.pop(next(iter(self._domain_by_name)))
166 + return row
167 +
168 + def _previous_delay_hours(self, item) -> float | None:
169 + if item["last_crawled_at"] and item["next_crawl_at"]:
170 + delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600
171 + return max(delta, 0.1)
172 + return None
173 +
174 + # ------------------------------------------------------------------ pipeline
175 +
176 + async def process_item(self, item) -> None:
177 + url: str = item["url"]
178 + url_id: int = item["url_id"]
179 + domain_row = await self._domain(item["domain_id"])
180 + domain: str = domain_row["domain"]
181 +
182 + if domain_row["blocked"]:
183 + await self.db.release_item(url_id, status="blocked")
184 + return
185 +
186 + host = scheme_host(url)
187 +
188 + # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse)
189 + allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host)
190 + if not allowed:
191 + await self.db.record_attempt(
192 + url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED,
193 + outcome=Outcome.ROBOTS_BLOCKED,
194 + )
195 + await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED)
196 + return
197 +
198 + # Politesse par hôte, tous workers confondus
199 + delay = max(robots_delay or 0, self.s.default_host_delay)
200 + if not await self.coord.acquire_host_slot(domain, delay):
201 + await self._defer(url_id, delay + 0.5)
202 + return
203 +
204 + # Cache HTTP conditionnel
205 + existing = await self.db.get_document(url_id)
206 + result = await self.fetcher.fetch(
207 + url,
208 + etag=existing["etag"] if existing else None,
209 + last_modified=existing["last_modified"] if existing else None,
210 + )
211 +
212 + # --- 304 : inchangé
213 + if result.status_code == 304:
214 + await self._finish_unchanged(item, existing)
215 + return
216 +
217 + # --- erreurs
218 + if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None:
219 + await self._finish_error(item, result)
220 + return
221 +
222 + # --- redirection vers une autre URL canonique : suivre la cible
223 + final = canonicalize_url(result.final_url) or result.final_url
224 + if final != url:
225 + final_domain = extract_domain(final)
226 + if final_domain and not looks_like_trap(final):
227 + await self.db.enqueue_url(
228 + final, final_domain, priority=item["priority"], depth=item["depth"],
229 + source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain,
230 + )
231 + await self.db.set_canonical(url_id, final)
232 + await self.db.record_attempt(
233 + url_id, status_code=result.status_code, error_code=ErrorCode.OK,
234 + outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms,
235 + )
236 + await self.db.release_item(url_id, status="done")
237 + return
238 +
239 + # --- parse
240 + try:
241 + page = parse_html(
242 + url, result.body, max_links=self.s.max_links_per_page, charset=result.charset
243 + )
244 + except Exception:
245 + await self.db.record_attempt(
246 + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED,
247 + outcome=Outcome.ERROR, duration_ms=result.duration_ms,
248 + )
249 + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED)
250 + return
251 +
252 + # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé
253 + if looks_like_garbage(page.title) or looks_like_garbage(page.body):
254 + await self.db.record_attempt(
255 + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED,
256 + outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80],
257 + )
258 + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED)
259 + return
260 +
261 + # --- score Québec (immédiat, déterministe)
262 + signals = score_page(page, domain)
263 +
264 + # --- détection de changement
265 + chash = content_hash(page.title, page.body)
266 + if existing and existing["content_hash"] == chash:
267 + await self._finish_unchanged(item, existing, status_code=result.status_code)
268 + await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité
269 + return
270 +
271 + # --- doublon exact sur une autre URL
272 + duplicate = await self.db.find_duplicate(chash, url_id)
273 + if duplicate:
274 + await self.db.record_attempt(
275 + url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE,
276 + outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms,
277 + title=page.title, quebec_score=signals.score,
278 + )
279 + await self.db.release_item(
280 + url_id, status="done",
281 + next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours),
282 + )
283 + return
284 +
285 + indexable = (
286 + not page.noindex
287 + and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex
288 + and signals.score >= self.s.min_quebec_score_to_index
289 + and len(page.body) >= self.s.min_body_length
290 + )
291 +
292 + if indexable:
293 + # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s -------
294 + canonical_target = page.canonical_url or url
295 + doc = build_search_document(
296 + page, signals,
297 + domain=domain,
298 + domain_quebec_score=float(domain_row["quebec_score"]),
299 + authority_score=float(domain_row["authority_score"]),
300 + )
301 + await self.search.index_document(doc)
302 + await self.db.upsert_document(
303 + url_id,
304 + content_hash=chash,
305 + etag=result.etag,
306 + last_modified=result.last_modified,
307 + title=page.title,
308 + description=page.description,
309 + language=page.language,
310 + page_quebec_score=signals.score,
311 + published_at=page.published_at,
312 + changed=existing is not None,
313 + )
314 + if page.canonical_url and page.canonical_url != url:
315 + await self.db.set_canonical(url_id, canonical_target)
316 + # Enrichissement asynchrone (jamais bloquant)
317 + await self.coord.enqueue_enrichment(
318 + {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2}
319 + )
320 + outcome = Outcome.INDEXED
321 + else:
322 + outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED
323 +
324 + await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language)
325 + self._domain_cache.pop(item["domain_id"], None)
326 +
327 + await self.db.record_attempt(
328 + url_id, status_code=result.status_code, error_code=ErrorCode.OK,
329 + outcome=outcome, content_hash=chash, num_bytes=len(result.body),
330 + duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score,
331 + )
332 + recrawl = next_recrawl_delay(
333 + changed=True, previous_delay_hours=self._previous_delay_hours(item),
334 + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours,
335 + default_hours=self.s.default_recrawl_hours,
336 + )
337 + await self.db.release_item(
338 + url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl,
339 + )
340 +
341 + await self._discover_links(item, page, signals)
342 + log.info(
343 + "page traitée",
344 + extra={"ctx": {
345 + "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"],
346 + "url": url, "outcome": str(outcome), "quebec_score": signals.score,
347 + "links": len(page.links), "ms": result.duration_ms,
348 + }},
349 + )
350 +
351 + # ------------------------------------------------------------------ issues terminales
352 +
353 + async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None:
354 + recrawl = next_recrawl_delay(
355 + changed=False, previous_delay_hours=self._previous_delay_hours(item),
356 + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours,
357 + default_hours=self.s.default_recrawl_hours,
358 + )
359 + await self.db.record_attempt(
360 + item["url_id"], status_code=status_code, error_code=ErrorCode.OK,
361 + outcome=Outcome.UNCHANGED,
362 + content_hash=existing["content_hash"] if existing else None,
363 + )
364 + await self.db.release_item(
365 + item["url_id"], status="pending",
366 + next_crawl_at=datetime.now(UTC) + recrawl,
367 + )
368 +
369 + async def _finish_error(self, item, result) -> None:
370 + url_id = item["url_id"]
371 + await self.db.record_attempt(
372 + url_id, status_code=result.status_code, error_code=result.error_code,
373 + outcome=Outcome.ERROR, duration_ms=result.duration_ms,
374 + )
375 + transient = result.error_code in TRANSIENT_ERRORS
376 + if transient and item["retries"] < MAX_RETRIES:
377 + await self.db.release_item(
378 + url_id, status="pending", error_code=result.error_code,
379 + next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]),
380 + increment_retries=True,
381 + )
382 + else:
383 + await self.db.release_item(url_id, status="failed", error_code=result.error_code)
384 +
385 + # ------------------------------------------------------------------ découverte
386 +
387 + async def _discover_links(self, item, page, signals) -> None:
388 + """Boucle de découverte (§8) : liens sortants → scoring → frontier."""
389 + if item["depth"] >= self.s.max_crawl_depth:
390 + return
391 + domain_row = await self._domain(item["domain_id"])
392 + source_domain = domain_row["domain"]
393 + source_quebec = max(float(domain_row["quebec_score"]), signals.score)
394 +
395 + outlink_domains: dict[int, int] = {}
396 + enqueued = 0
397 + for link in page.links:
398 + if link.nofollow or looks_like_trap(
399 + link.url,
400 + max_query_params=self.s.max_query_params,
401 + max_path_segments=self.s.max_path_segments,
402 + ):
403 + continue
404 + target_domain = extract_domain(link.url)
405 + if not target_domain:
406 + continue
407 + same_domain = target_domain == source_domain
408 + target_row = await self._domain_row_by_name(target_domain)
409 + is_new = target_row is None
410 + target_quebec = float(target_row["quebec_score"]) if target_row else 0.0
411 + # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec
412 + effective_quebec = target_quebec if not is_new else source_quebec * 0.7
413 + if same_domain:
414 + effective_quebec = max(effective_quebec, source_quebec)
415 +
416 + priority = compute_priority(
417 + domain_quebec_score=effective_quebec,
418 + authority_score=float(target_row["authority_score"]) if target_row else 0.0,
419 + link_signal=0.5 if not same_domain else 0.2,
420 + is_new_domain=is_new,
421 + depth=item["depth"] + 1,
422 + )
423 + # Économie de crawl : ignorer les cibles au signal Québec quasi nul
424 + if priority < 0.1:
425 + continue
426 + url_id = await self.db.enqueue_url(
427 + link.url, target_domain,
428 + priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"],
429 + max_urls_per_domain=self.s.max_urls_per_domain,
430 + )
431 + if url_id:
432 + enqueued += 1
433 + if not same_domain:
434 + target_id = (target_row["id"] if target_row
435 + else (await self.db.upsert_domain(target_domain)))
436 + outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1
437 +
438 + if outlink_domains:
439 + await self.db.record_domain_links(item["domain_id"], outlink_domains)
440 +
441 +
442 +def main() -> None:
443 + asyncio.run(CrawlerWorker().start())
444 +
445 +
446 +if __name__ == "__main__":
447 + main()
added M2M32crouve-ka/services/enrichment/__init__.py +14 −0
@@ -0,0 +1,14 @@
1 +# Trouve-KA — enrichissement asynchrone
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4).
6 +
7 +Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour
8 +les documents déjà cherchables (scores de domaine, fraîcheur, plus tard
9 +embeddings et entités).
10 +"""
11 +
12 +from .worker import EnrichmentWorker
13 +
14 +__all__ = ["EnrichmentWorker"]
added M2M32crouve-ka/services/enrichment/worker.py +82 −0
@@ -0,0 +1,82 @@
1 +# Trouve-KA — worker d'enrichissement
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams).
6 +
7 +Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour
8 +dans le document indexé (le domaine apprend au fil du crawl, les documents déjà
9 +indexés en profitent rétroactivement).
10 +Étapes futures : embeddings, entités, classification thématique — même canal,
11 +même contrat : mise à jour partielle du document, jamais bloquante.
12 +"""
13 +
14 +import asyncio
15 +import os
16 +import signal
17 +import uuid
18 +
19 +from trouveka.config import get_settings
20 +from trouveka.database import Database
21 +from trouveka.logging import get_logger
22 +from trouveka.queue import Coordination
23 +from trouveka.search_core import SearchCore
24 +
25 +log = get_logger("enrichment")
26 +
27 +GROUP = "enrichers"
28 +
29 +
30 +class EnrichmentWorker:
31 + def __init__(self) -> None:
32 + self.s = get_settings()
33 + self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}"
34 + self.db = Database(self.s.database_url, pool_min=1, pool_max=3)
35 + self.coord = Coordination(self.s.redis_url)
36 + self.search = SearchCore(self.s.search_url, self.s.search_index)
37 + self.stop_event = asyncio.Event()
38 +
39 + async def enrich(self, payload: dict) -> None:
40 + domain_row = await self.db.get_domain_by_id(int(payload["domain_id"]))
41 + if not domain_row:
42 + return
43 + await self.search.update_document(
44 + payload["url"],
45 + {
46 + "domain_quebec_score": round(float(domain_row["quebec_score"]), 4),
47 + "authority_score": round(float(domain_row["authority_score"]), 4),
48 + },
49 + )
50 +
51 + async def start(self) -> None:
52 + await self.db.connect()
53 + loop = asyncio.get_running_loop()
54 + for sig in (signal.SIGINT, signal.SIGTERM):
55 + loop.add_signal_handler(sig, self.stop_event.set)
56 + log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}})
57 +
58 + while not self.stop_event.is_set():
59 + try:
60 + messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000)
61 + for msg_id, payload in messages:
62 + try:
63 + await self.enrich(payload)
64 + except Exception:
65 + log.exception("échec enrichissement", extra={"ctx": payload})
66 + finally:
67 + await self.coord.ack_enrichment(GROUP, msg_id)
68 + except Exception:
69 + log.exception("erreur boucle enrichissement (on continue)")
70 + await asyncio.sleep(2)
71 +
72 + await self.search.close()
73 + await self.coord.close()
74 + await self.db.close()
75 +
76 +
77 +def main() -> None:
78 + asyncio.run(EnrichmentWorker().start())
79 +
80 +
81 +if __name__ == "__main__":
82 + main()
added M2M32crouve-ka/services/frontier/__init__.py +9 −0
@@ -0,0 +1,9 @@
1 +# Trouve-KA — politique du frontier
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5)."""
6 +
7 +from .policy import compute_priority, next_recrawl_delay, retry_delay
8 +
9 +__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"]
added M2M32crouve-ka/services/frontier/policy.py +65 −0
@@ -0,0 +1,65 @@
1 +# Trouve-KA — priorités et recrawl adaptatif
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Fonction de priorité du frontier et scheduling de recrawl.
6 +
7 +P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3)
8 +Les poids sont des constantes nommées, destinées à être calibrées par mesures.
9 +"""
10 +
11 +from datetime import timedelta
12 +
13 +# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés
14 +W_QUEBEC = 0.40
15 +W_AUTHORITY = 0.15
16 +W_FRESHNESS = 0.10
17 +W_LINKS = 0.10
18 +W_NOVELTY = 0.15
19 +W_DEPTH = 0.05
20 +W_SPAM = 0.30
21 +
22 +
23 +def compute_priority(
24 + *,
25 + domain_quebec_score: float,
26 + authority_score: float = 0.0,
27 + freshness_hint: float = 0.0,
28 + link_signal: float = 0.0,
29 + is_new_domain: bool = False,
30 + depth: int = 0,
31 + spam_signal: float = 0.0,
32 + is_seed: bool = False,
33 +) -> float:
34 + """Priorité ∈ [0, 1]. Les seeds démarrent au maximum."""
35 + if is_seed:
36 + return 1.0
37 + p = (
38 + W_QUEBEC * domain_quebec_score
39 + + W_AUTHORITY * min(authority_score, 1.0)
40 + + W_FRESHNESS * min(freshness_hint, 1.0)
41 + + W_LINKS * min(link_signal, 1.0)
42 + + W_NOVELTY * (1.0 if is_new_domain else 0.3)
43 + - W_DEPTH * min(depth, 10) / 10.0
44 + - W_SPAM * min(spam_signal, 1.0)
45 + )
46 + return max(0.0, min(1.0, round(p, 4)))
47 +
48 +
49 +def next_recrawl_delay(
50 + *,
51 + changed: bool,
52 + previous_delay_hours: float | None,
53 + min_hours: float = 1.0,
54 + max_hours: float = 24 * 30.0,
55 + default_hours: float = 24.0,
56 +) -> timedelta:
57 + """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2."""
58 + prev = previous_delay_hours or default_hours
59 + hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0)
60 + return timedelta(hours=hours)
61 +
62 +
63 +def retry_delay(retries: int) -> timedelta:
64 + """Backoff exponentiel plafonné pour les erreurs transitoires."""
65 + return timedelta(minutes=min(15 * (2**retries), 60 * 24))
added M2M32crouve-ka/services/indexer/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — indexeur
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4).
6 +
7 +L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3
8 +(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle.
9 +"""
10 +
11 +from .build import build_search_document
12 +
13 +__all__ = ["build_search_document"]
added M2M32crouve-ka/services/indexer/build.py +66 −0
@@ -0,0 +1,66 @@
1 +# Trouve-KA — construction du document d'index
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1)."""
6 +
7 +from datetime import UTC, datetime
8 +from typing import Any
9 +
10 +from trouveka.types import ParsedPage, QuebecSignals
11 +
12 +# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2)
13 +_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca")
14 +_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"}
15 +_NEWS_DOMAINS = {
16 + "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com",
17 + "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com",
18 + "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca",
19 + "noovo.info", "24heures.ca", "montrealgazette.com",
20 +}
21 +_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca",
22 + ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca")
23 +
24 +
25 +def categorize_domain(domain: str) -> list[str]:
26 + d = domain.lower()
27 + cats: list[str] = []
28 + if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d:
29 + cats.append("government")
30 + if d in _NEWS_DOMAINS:
31 + cats.append("news")
32 + if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"):
33 + cats.append("education")
34 + return cats
35 +
36 +
37 +def build_search_document(
38 + page: ParsedPage,
39 + signals: QuebecSignals,
40 + *,
41 + domain: str,
42 + domain_quebec_score: float,
43 + authority_score: float = 0.0,
44 +) -> dict[str, Any]:
45 + return {
46 + "url": page.url,
47 + "canonical_url": page.canonical_url or page.url,
48 + "domain": domain,
49 + "title": page.title,
50 + "description": page.description,
51 + "body": page.body[:100_000],
52 + "headings": page.headings,
53 + "language": page.language,
54 + "page_quebec_score": signals.score,
55 + "domain_quebec_score": round(domain_quebec_score, 4),
56 + "locations": signals.locations,
57 + "organizations": [], # enrichissement étape 2
58 + "people": [], # enrichissement étape 2
59 + "categories": categorize_domain(domain),
60 + "published_at": page.published_at.isoformat() if page.published_at else None,
61 + "crawled_at": datetime.now(UTC).isoformat(),
62 + "authority_score": round(authority_score, 4),
63 + "freshness_score": 0.0, # enrichissement étape 3
64 + "quality_score": 0.0, # enrichissement étape 3
65 + "spam_score": 0.0, # enrichissement étape 3
66 + }
added M2M32crouve-ka/services/parser/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — extraction de contenu
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Extraction de contenu des pages crawlées (CLAUDE.md §6).
6 +
7 +Chaque page est traitée comme non fiable : texte extrait puis assaini,
8 +URLs re-canonicalisées, MIME types jamais crus sur parole.
9 +"""
10 +
11 +from .html_parser import parse_html, decode_html, looks_like_garbage
12 +
13 +__all__ = ["parse_html", "decode_html", "looks_like_garbage"]
added M2M32crouve-ka/services/parser/html_parser.py +246 −0
@@ -0,0 +1,246 @@
1 +# Trouve-KA — parseur HTML
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Extraction HTML avec selectolax (rapide, tolérant).
6 +
7 +Extrait : titre, meta description, corps principal (boilerplate retiré),
8 +headings, liens + anchors + nofollow, canonical, langue, dates de publication,
9 +auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec.
10 +"""
11 +
12 +import json
13 +import re
14 +from datetime import datetime
15 +
16 +from dateutil import parser as dateparser
17 +from langdetect import DetectorFactory, LangDetectException, detect
18 +from selectolax.parser import HTMLParser
19 +
20 +from trouveka.shared import canonicalize_url
21 +from trouveka.types import ExtractedLink, ParsedPage
22 +
23 +DetectorFactory.seed = 42 # détection de langue déterministe
24 +
25 +# Éléments retirés avant extraction du corps (navigation, pub, scripts…)
26 +_STRIP_SELECTORS = (
27 + "script", "style", "noscript", "template", "svg", "iframe", "form",
28 + "nav", "header", "footer", "aside",
29 + "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]",
30 + "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]",
31 +)
32 +
33 +_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content")
34 +
35 +_WS = re.compile(r"\s+")
36 +_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
37 +
38 +
39 +def _clean(text: str | None) -> str:
40 + if not text:
41 + return ""
42 + return _WS.sub(" ", _CTRL.sub(" ", text)).strip()
43 +
44 +
45 +def _parse_date(value: str | None) -> datetime | None:
46 + if not value:
47 + return None
48 + try:
49 + dt = dateparser.parse(value)
50 + except (ValueError, OverflowError, TypeError):
51 + return None
52 + if dt and dt.year >= 1990:
53 + return dt
54 + return None
55 +
56 +
57 +def _meta(tree: HTMLParser, *names: str) -> str | None:
58 + for name in names:
59 + for attr in ("name", "property", "itemprop"):
60 + node = tree.css_first(f'meta[{attr}="{name}"]')
61 + if node:
62 + content = node.attributes.get("content")
63 + if content and content.strip():
64 + return content.strip()
65 + return None
66 +
67 +
68 +def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]:
69 + """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished."""
70 + hints: list[str] = []
71 + published: datetime | None = None
72 + for node in tree.css('script[type="application/ld+json"]')[:max_blocks]:
73 + raw = node.text()
74 + if not raw or len(raw) > 100_000:
75 + continue
76 + try:
77 + data = json.loads(raw)
78 + except (json.JSONDecodeError, ValueError):
79 + continue
80 + stack = [data]
81 + while stack:
82 + item = stack.pop()
83 + if isinstance(item, list):
84 + stack.extend(item[:20])
85 + elif isinstance(item, dict):
86 + for key in ("name", "legalName", "addressLocality", "addressRegion",
87 + "postalCode", "streetAddress", "telephone"):
88 + val = item.get(key)
89 + if isinstance(val, str) and len(val) < 200:
90 + hints.append(val)
91 + if published is None:
92 + published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated"))
93 + stack.extend(v for v in item.values() if isinstance(v, (dict, list)))
94 + if len(hints) > 60:
95 + break
96 + return hints[:60], published
97 +
98 +
99 +_META_CHARSET_RE = re.compile(
100 + rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE
101 +)
102 +_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"}
103 +
104 +
105 +def decode_html(body: bytes, declared_charset: str | None = None) -> str:
106 + """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252.
107 +
108 + Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder
109 + aveuglément en UTF-8 produit du charabia qui finirait dans l'index.
110 + """
111 + candidates: list[str] = []
112 + if declared_charset:
113 + candidates.append(declared_charset.lower())
114 + meta = _META_CHARSET_RE.search(body[:4096])
115 + if meta:
116 + candidates.append(meta.group(1).decode("ascii", errors="ignore").lower())
117 + candidates.extend(["utf-8", "cp1252"])
118 + for charset in candidates:
119 + charset = _CHARSET_ALIASES.get(charset, charset)
120 + try:
121 + return body.decode(charset)
122 + except (UnicodeDecodeError, LookupError):
123 + continue
124 + return body.decode("utf-8", errors="replace")
125 +
126 +
127 +def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool:
128 + """True si le texte contient trop de caractères de remplacement/contrôle
129 + (contenu binaire ou mal décodé — ne doit jamais être indexé)."""
130 + if not text:
131 + return False
132 + sample = text[:20_000]
133 + bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r"))
134 + return bad / len(sample) > threshold
135 +
136 +
137 +def parse_html(
138 + url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None
139 +) -> ParsedPage:
140 + """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse."""
141 + if isinstance(html, bytes):
142 + html = decode_html(html, charset)
143 + tree = HTMLParser(html)
144 +
145 + # Directives robots de la page
146 + robots_meta = (_meta(tree, "robots", "googlebot") or "").lower()
147 + noindex = "noindex" in robots_meta
148 + nofollow_page = "nofollow" in robots_meta
149 +
150 + # Canonical
151 + canonical = None
152 + link_canonical = tree.css_first('link[rel="canonical"]')
153 + if link_canonical:
154 + canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url)
155 +
156 + # Titre / description
157 + title = _clean(tree.css_first("title").text() if tree.css_first("title") else None)
158 + og_title = _meta(tree, "og:title")
159 + if not title and og_title:
160 + title = _clean(og_title)
161 + description = _clean(_meta(tree, "description", "og:description") or "")
162 +
163 + # Langue déclarée
164 + declared_lang = None
165 + html_node = tree.css_first("html")
166 + if html_node:
167 + lang_attr = (html_node.attributes.get("lang") or "").strip().lower()
168 + if lang_attr:
169 + declared_lang = lang_attr[:2]
170 +
171 + # Dates / auteur
172 + published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date"))
173 + modified = _parse_date(_meta(tree, "article:modified_time", "dateModified"))
174 + author = _meta(tree, "author", "article:author")
175 +
176 + # Indices structurés (avant strip : JSON-LD est dans <script>)
177 + structured_hints, jsonld_published = _extract_jsonld_hints(tree)
178 + published = published or jsonld_published
179 +
180 + # Liens (avant strip : les liens de nav comptent pour la découverte)
181 + links: list[ExtractedLink] = []
182 + seen: set[str] = set()
183 + for node in tree.css("a[href]"):
184 + if len(links) >= max_links:
185 + break
186 + href = node.attributes.get("href") or ""
187 + if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):
188 + continue
189 + normalized = canonicalize_url(href, base=url)
190 + if not normalized or normalized in seen:
191 + continue
192 + seen.add(normalized)
193 + rel = (node.attributes.get("rel") or "").lower()
194 + links.append(
195 + ExtractedLink(
196 + url=normalized,
197 + anchor=_clean(node.text())[:200],
198 + nofollow="nofollow" in rel or nofollow_page,
199 + )
200 + )
201 +
202 + # Headings
203 + headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]]
204 + headings = [h for h in headings if h]
205 +
206 + # Corps principal : strip du boilerplate, puis zone principale si identifiable
207 + for selector in _STRIP_SELECTORS:
208 + for node in tree.css(selector):
209 + node.decompose()
210 + body_node = None
211 + for selector in _MAIN_SELECTORS:
212 + body_node = tree.css_first(selector)
213 + if body_node:
214 + break
215 + if body_node is None:
216 + body_node = tree.css_first("body") or tree.root
217 + body = _clean(body_node.text(separator=" ", deep=True))[:200_000]
218 +
219 + # Langue : déclaration HTML validée/complétée par détection statistique
220 + language = declared_lang if declared_lang in ("fr", "en") else None
221 + sample = body[:4000] or title
222 + if sample and len(sample) > 40:
223 + try:
224 + detected = detect(sample)
225 + if detected in ("fr", "en"):
226 + # La détection l'emporte si elle contredit une déclaration douteuse
227 + language = detected if language is None or detected != language else language
228 + except LangDetectException:
229 + pass
230 +
231 + return ParsedPage(
232 + url=url,
233 + canonical_url=canonical,
234 + title=title[:500],
235 + description=description[:1000],
236 + body=body,
237 + headings=headings,
238 + language=language,
239 + links=links,
240 + published_at=published,
241 + modified_at=modified,
242 + author=_clean(author)[:200] if author else None,
243 + noindex=noindex,
244 + nofollow_page=nofollow_page,
245 + structured_hints=structured_hints,
246 + )
added M2M32crouve-ka/services/ranking/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# Trouve-KA — ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Ranking Québec-first (CLAUDE.md §9).
6 +
7 +Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité
8 +Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe.
9 +"""
10 +
11 +from .query import analyze_query, build_search_body
12 +
13 +__all__ = ["analyze_query", "build_search_body"]
added M2M32crouve-ka/services/ranking/query.py +146 −0
@@ -0,0 +1,146 @@
1 +# Trouve-KA — pipeline de requête et ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch.
6 +
7 +BM25 (multi_match bilingue) au cœur, function_score par-dessus :
8 +Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9).
9 +"""
10 +
11 +import re
12 +import unicodedata
13 +from typing import Any
14 +
15 +from trouveka.classifier import QUEBEC_TOPONYMS
16 +
17 +# Poids de ranking — à calibrer avec le dataset d'évaluation (§16)
18 +W_PAGE_QUEBEC = 1.6
19 +W_DOMAIN_QUEBEC = 1.0
20 +W_AUTHORITY = 0.8
21 +W_FRESHNESS = 0.5
22 +W_LOCALITY = 2.0
23 +
24 +_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour",
25 + "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"}
26 +_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"}
27 +
28 +
29 +def _fold(text: str) -> str:
30 + return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn")
31 +
32 +
33 +def analyze_query(q: str) -> dict[str, Any]:
34 + """Normalisation, détection de langue heuristique, extraction de lieux québécois."""
35 + normalized = re.sub(r"\s+", " ", q).strip()[:200]
36 + tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)]
37 +
38 + fr_hits = sum(1 for t in tokens if t in _FR_HINTS)
39 + en_hits = sum(1 for t in tokens if t in _EN_HINTS)
40 + has_accents = normalized != _fold(normalized)
41 + language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None)
42 +
43 + # Lieux : tokens simples + bigrammes contre le gazetteer
44 + folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS}
45 + locations: list[str] = []
46 + candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [
47 + f"{a} {b}" for a, b in zip(tokens, tokens[1:])
48 + ]
49 + for cand in candidates:
50 + folded = _fold(cand)
51 + if folded in folded_gazetteer:
52 + locations.append(folded_gazetteer[folded])
53 + return {"query": normalized, "language": language, "locations": sorted(set(locations))}
54 +
55 +
56 +def build_search_body(
57 + q: str,
58 + *,
59 + page: int = 1,
60 + limit: int = 10,
61 + language: str | None = None,
62 + category: str | None = None,
63 + quebec_only: bool = False,
64 + freshness: str | None = None,
65 +) -> tuple[dict[str, Any], dict[str, Any]]:
66 + """Construit le corps de requête OpenSearch. Retourne (body, analyse)."""
67 + analysis = analyze_query(q)
68 + normalized = analysis["query"]
69 +
70 + # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne
71 + text_query: dict[str, Any] = {
72 + "multi_match": {
73 + "query": normalized,
74 + "type": "most_fields",
75 + "fields": [
76 + "title^4", "title.en^4",
77 + "headings^2", "headings.en^2",
78 + "description^2", "description.en^2",
79 + "body", "body.en",
80 + ],
81 + "fuzziness": "AUTO",
82 + "prefix_length": 2,
83 + }
84 + }
85 +
86 + filters: list[dict[str, Any]] = []
87 + if language in ("fr", "en"):
88 + filters.append({"term": {"language": language}})
89 + if category:
90 + filters.append({"term": {"categories": category}})
91 + if quebec_only:
92 + filters.append({
93 + "bool": {
94 + "should": [
95 + {"range": {"page_quebec_score": {"gte": 0.45}}},
96 + {"range": {"domain_quebec_score": {"gte": 0.6}}},
97 + ],
98 + "minimum_should_match": 1,
99 + }
100 + })
101 + if freshness in ("day", "week", "month", "year"):
102 + filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}})
103 +
104 + functions: list[dict[str, Any]] = [
105 + {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}},
106 + {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}},
107 + {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}},
108 + {
109 + "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}},
110 + "weight": W_FRESHNESS,
111 + },
112 + ]
113 + # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite
114 + if analysis["locations"]:
115 + functions.append({
116 + "filter": {"terms": {"locations": analysis["locations"]}},
117 + "weight": W_LOCALITY,
118 + })
119 +
120 + body = {
121 + "from": max(page - 1, 0) * limit,
122 + "size": limit,
123 + "query": {
124 + "function_score": {
125 + "query": {"bool": {"must": [text_query], "filter": filters}},
126 + "functions": functions,
127 + "score_mode": "sum",
128 + "boost_mode": "sum",
129 + }
130 + },
131 + "highlight": {
132 + "pre_tags": ["<em>"],
133 + "post_tags": ["</em>"],
134 + "fields": {
135 + "body": {"fragment_size": 180, "number_of_fragments": 2},
136 + "description": {"fragment_size": 180, "number_of_fragments": 1},
137 + },
138 + "encoder": "html",
139 + },
140 + "_source": [
141 + "url", "canonical_url", "domain", "title", "description", "language",
142 + "page_quebec_score", "domain_quebec_score", "categories", "published_at",
143 + ],
144 + "track_total_hits": True,
145 + }
146 + return body, analysis
added M2M32crouve-ka/services/scheduler/__init__.py +9 −0
@@ -0,0 +1,9 @@
1 +# Trouve-KA — scheduler
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier."""
6 +
7 +from .loop import SchedulerLoop
8 +
9 +__all__ = ["SchedulerLoop"]
added M2M32crouve-ka/services/scheduler/loop.py +81 −0
@@ -0,0 +1,81 @@
1 +# Trouve-KA — boucle de maintenance
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Scheduler : tâches périodiques légères.
6 +
7 +- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13.
8 +- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés
9 + par le score Québec des domaines source) — version simple, PageRank-like plus tard.
10 +"""
11 +
12 +import asyncio
13 +import signal
14 +
15 +from trouveka.config import get_settings
16 +from trouveka.database import Database
17 +from trouveka.logging import get_logger
18 +
19 +log = get_logger("scheduler")
20 +
21 +STALE_RESET_INTERVAL = 60 # secondes
22 +AUTHORITY_INTERVAL = 15 * 60 # secondes
23 +
24 +
25 +class SchedulerLoop:
26 + def __init__(self) -> None:
27 + self.s = get_settings()
28 + self.db = Database(self.s.database_url, pool_min=1, pool_max=3)
29 + self.stop_event = asyncio.Event()
30 +
31 + async def recompute_authority(self) -> None:
32 + """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources."""
33 + await self.db.pool.execute(
34 + """
35 + WITH weighted AS (
36 + SELECT dl.to_domain_id AS id,
37 + sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w,
38 + count(DISTINCT dl.from_domain_id) AS in_domains
39 + FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id
40 + GROUP BY dl.to_domain_id
41 + )
42 + UPDATE domains SET
43 + authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)),
44 + inlink_domains = w.in_domains
45 + FROM weighted w WHERE domains.id = w.id
46 + """
47 + )
48 + log.info("autorité de domaine recalculée")
49 +
50 + async def start(self) -> None:
51 + await self.db.connect()
52 + loop = asyncio.get_running_loop()
53 + for sig in (signal.SIGINT, signal.SIGTERM):
54 + loop.add_signal_handler(sig, self.stop_event.set)
55 + log.info("scheduler démarré")
56 +
57 + elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat
58 + while not self.stop_event.is_set():
59 + try:
60 + reset = await self.db.reset_stale_items(older_than_minutes=30)
61 + if reset:
62 + log.info("items abandonnés relancés", extra={"ctx": {"count": reset}})
63 + if elapsed_authority >= AUTHORITY_INTERVAL:
64 + await self.recompute_authority()
65 + elapsed_authority = 0
66 + except Exception:
67 + log.exception("erreur scheduler (on continue)")
68 + try:
69 + await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL)
70 + except TimeoutError:
71 + pass
72 + elapsed_authority += STALE_RESET_INTERVAL
73 + await self.db.close()
74 +
75 +
76 +def main() -> None:
77 + asyncio.run(SchedulerLoop().start())
78 +
79 +
80 +if __name__ == "__main__":
81 + main()
added M2M32crouve-ka/tests/test_fetcher.py +110 −0
@@ -0,0 +1,110 @@
1 +# Trouve-KA — tests du fetcher (web de fixtures via MockTransport)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +import functools
6 +
7 +import httpx
8 +import pytest
9 +
10 +import trouveka.crawler.fetcher as fetcher_mod
11 +from trouveka.config import Settings
12 +from trouveka.crawler.fetcher import Fetcher
13 +from trouveka.shared import is_safe_url
14 +from trouveka.types import ErrorCode
15 +
16 +
17 +@pytest.fixture(autouse=True)
18 +def no_dns(monkeypatch):
19 + # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la
20 + # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution.
21 + monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False))
22 +
23 +
24 +def make_fetcher(handler, **overrides) -> Fetcher:
25 + settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides)
26 + client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
27 + return Fetcher(client, settings)
28 +
29 +
30 +async def test_simple_fetch():
31 + def handler(request):
32 + return httpx.Response(200, html="<html><title>ok</title></html>",
33 + headers={"content-type": "text/html; charset=utf-8"})
34 +
35 + result = await make_fetcher(handler).fetch("https://site.qc.ca/page")
36 + assert result.status_code == 200
37 + assert result.error_code == ErrorCode.OK
38 + assert b"ok" in result.body
39 +
40 +
41 +async def test_redirect_followed_with_ssrf_recheck():
42 + def handler(request):
43 + if request.url.path == "/a":
44 + return httpx.Response(301, headers={"location": "/b"})
45 + return httpx.Response(200, html="fini", headers={"content-type": "text/html"})
46 +
47 + result = await make_fetcher(handler).fetch("https://site.qc.ca/a")
48 + assert result.status_code == 200
49 + assert result.final_url == "https://site.qc.ca/b"
50 + assert result.redirect_chain == ["https://site.qc.ca/b"]
51 +
52 +
53 +async def test_redirect_to_private_ip_blocked():
54 + def handler(request):
55 + return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"})
56 +
57 + result = await make_fetcher(handler).fetch("https://site.qc.ca/evil")
58 + assert result.error_code == ErrorCode.SSRF_BLOCKED
59 +
60 +
61 +async def test_too_many_redirects():
62 + def handler(request):
63 + n = int(request.url.path.strip("/") or 0)
64 + return httpx.Response(301, headers={"location": f"/{n + 1}"})
65 +
66 + result = await make_fetcher(handler).fetch("https://site.qc.ca/0")
67 + assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS
68 +
69 +
70 +async def test_body_size_limit():
71 + def handler(request):
72 + return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"})
73 +
74 + result = await make_fetcher(handler).fetch("https://site.qc.ca/gros")
75 + assert result.error_code == ErrorCode.TOO_LARGE
76 +
77 +
78 +async def test_unsupported_content_type():
79 + def handler(request):
80 + return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"})
81 +
82 + result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf")
83 + assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT
84 +
85 +
86 +async def test_conditional_304():
87 + def handler(request):
88 + assert request.headers.get("if-none-match") == 'W/"abc"'
89 + return httpx.Response(304)
90 +
91 + result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"')
92 + assert result.status_code == 304
93 +
94 +
95 +async def test_4xx_and_5xx_classified():
96 + async def run(status):
97 + def handler(request):
98 + return httpx.Response(status)
99 + return await make_fetcher(handler).fetch("https://site.qc.ca/x")
100 +
101 + assert (await run(404)).error_code == ErrorCode.HTTP_4XX
102 + assert (await run(503)).error_code == ErrorCode.HTTP_5XX
103 +
104 +
105 +async def test_ssrf_blocked_upfront():
106 + def handler(request): # pragma: no cover — ne doit jamais être appelé
107 + raise AssertionError("fetch ne doit pas partir")
108 +
109 + result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin")
110 + assert result.error_code == ErrorCode.SSRF_BLOCKED
added M2M32crouve-ka/tests/test_frontier_policy.py +44 −0
@@ -0,0 +1,44 @@
1 +# Trouve-KA — tests de la politique du frontier
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay
6 +
7 +
8 +def test_seed_priority_is_max():
9 + assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0
10 +
11 +
12 +def test_priority_bounded():
13 + assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0,
14 + link_signal=1.0, is_new_domain=True) <= 1.0
15 + assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0
16 +
17 +
18 +def test_quebec_dominates_priority():
19 + high_q = compute_priority(domain_quebec_score=0.9, depth=2)
20 + low_q = compute_priority(domain_quebec_score=0.1, depth=2)
21 + assert high_q > low_q + 0.2
22 +
23 +
24 +def test_recrawl_backoff_doubles_when_unchanged():
25 + d = next_recrawl_delay(changed=False, previous_delay_hours=24.0)
26 + assert d.total_seconds() == 48 * 3600
27 +
28 +
29 +def test_recrawl_halves_when_changed():
30 + d = next_recrawl_delay(changed=True, previous_delay_hours=24.0)
31 + assert d.total_seconds() == 12 * 3600
32 +
33 +
34 +def test_recrawl_bounded():
35 + fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0)
36 + slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720)
37 + assert fast.total_seconds() == 3600
38 + assert slow.total_seconds() == 720 * 3600
39 +
40 +
41 +def test_retry_delay_grows_and_caps():
42 + assert retry_delay(0).total_seconds() == 15 * 60
43 + assert retry_delay(1).total_seconds() == 30 * 60
44 + assert retry_delay(10).total_seconds() == 24 * 3600
added M2M32crouve-ka/tests/test_parser.py +92 −0
@@ -0,0 +1,92 @@
1 +# Trouve-KA — tests d'extraction HTML
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.parser import decode_html, looks_like_garbage, parse_html
6 +
7 +FIXTURE = """<!DOCTYPE html>
8 +<html lang="fr">
9 +<head>
10 + <title>Plomberie XYZ — Plombier à Gatineau</title>
11 + <meta name="description" content="Service de plomberie en Outaouais depuis 1998.">
12 + <link rel="canonical" href="https://plomberiexyz.qc.ca/services">
13 + <meta property="article:published_time" content="2024-06-12T10:00:00-04:00">
14 + <script type="application/ld+json">
15 + {"@type": "Plumber", "name": "Plomberie XYZ",
16 + "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}}
17 + </script>
18 +</head>
19 +<body>
20 + <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav>
21 + <main>
22 + <h1>Plombier Gatineau</h1>
23 + <h2>Nos services</h2>
24 + <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p>
25 + <a href="/tarifs?utm_source=nav">Tarifs</a>
26 + <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a>
27 + <a href="mailto:info@x.ca">Courriel</a>
28 + <a href="javascript:void(0)">JS</a>
29 + </main>
30 + <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer>
31 + <script>trackingStuff();</script>
32 +</body>
33 +</html>"""
34 +
35 +
36 +def test_full_extraction():
37 + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE)
38 + assert page.title == "Plomberie XYZ — Plombier à Gatineau"
39 + assert "Outaouais" in page.description
40 + assert page.canonical_url == "https://plomberiexyz.qc.ca/services"
41 + assert page.language == "fr"
42 + assert "Plombier Gatineau" in page.headings
43 + assert page.published_at and page.published_at.year == 2024
44 + # Corps : contenu principal présent, nav/script exclus
45 + assert "Nous desservons Gatineau" in page.body
46 + assert "Menu qui ne doit pas" not in page.body
47 + assert "trackingStuff" not in page.body
48 + # JSON-LD → indices structurés
49 + assert "Gatineau" in page.structured_hints
50 +
51 +
52 +def test_links_normalized_and_flagged():
53 + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE)
54 + urls = {link.url for link in page.links}
55 + assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu
56 + nofollow = {link.url: link.nofollow for link in page.links}
57 + assert nofollow["https://autresite.qc.ca/page"] is True
58 + assert not any(u.startswith(("mailto:", "javascript:")) for u in urls)
59 +
60 +
61 +def test_noindex_detected():
62 + html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>'
63 + page = parse_html("https://x.qc.ca/", html)
64 + assert page.noindex is True
65 + assert page.nofollow_page is True
66 +
67 +
68 +def test_garbage_html_does_not_crash():
69 + page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>")
70 + assert page.url == "https://x.qc.ca/"
71 +
72 +
73 +def test_decode_latin1_quebec_site():
74 + # Vieux site québécois en ISO-8859-1 : les accents doivent survivre
75 + body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252")
76 + assert "Éducation à Québec" in decode_html(body)
77 + page = parse_html("https://vieux-site.qc.ca/", body)
78 + assert page.title == "Éducation à Québec"
79 +
80 +
81 +def test_decode_http_charset_priority():
82 + body = "<html><title>Montréal</title></html>".encode("cp1252")
83 + assert "Montréal" in decode_html(body, "iso-8859-1")
84 +
85 +
86 +def test_binary_garbage_detected():
87 + # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia
88 + binary = bytes(range(256)) * 40
89 + text = binary.decode("utf-8", errors="replace")
90 + assert looks_like_garbage(text)
91 + assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.")
92 + assert not looks_like_garbage("")
added M2M32crouve-ka/tests/test_quebec_scoring.py +88 −0
@@ -0,0 +1,88 @@
1 +# Trouve-KA — tests du scoring Québec
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.classifier import score_page
6 +from trouveka.types import ParsedPage
7 +
8 +
9 +def _page(**kwargs) -> ParsedPage:
10 + defaults = {"url": "https://example.com/page", "title": "", "body": ""}
11 + return ParsedPage(**(defaults | kwargs))
12 +
13 +
14 +def test_quebec_business_scores_high():
15 + page = _page(
16 + url="https://plomberiexyz.qc.ca/services",
17 + title="Plomberie XYZ — Plombier à Gatineau",
18 + description="Service de plomberie résidentielle en Outaouais",
19 + body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. "
20 + "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.",
21 + headings=["Plombier Gatineau", "Nos services"],
22 + language="fr",
23 + )
24 + signals = score_page(page, "plomberiexyz.qc.ca")
25 + assert signals.score > 0.7
26 + assert "gatineau" in signals.locations
27 + assert "tld_quebec" in signals.reasons
28 + assert "code_postal_qc" in signals.reasons
29 +
30 +
31 +def test_ontario_business_scores_low():
32 + page = _page(
33 + url="https://ottawaplumbing.ca/",
34 + title="Ottawa Plumbing Services",
35 + description="Plumbing services in Ottawa, Ontario",
36 + body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.",
37 + headings=["Plumbing Ottawa"],
38 + language="en",
39 + )
40 + signals = score_page(page, "ottawaplumbing.ca")
41 + assert signals.score < 0.2
42 +
43 +
44 +def test_nyt_article_about_montreal_gets_page_signal():
45 + # Un article international SUR Montréal : le domaine n'est pas québécois,
46 + # mais la page a un signal réel (page_quebec_score > 0, sans être maximal).
47 + page = _page(
48 + url="https://www.nytimes.com/2026/01/01/travel/montreal.html",
49 + title="36 Hours in Montreal",
50 + description="What to do in Montreal, Quebec's largest city",
51 + body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, "
52 + "the city offers poutine, festivals and more. Nearby Laval and Longueuil...",
53 + headings=["36 Hours in Montreal"],
54 + language="en",
55 + )
56 + signals = score_page(page, "nytimes.com")
57 + assert 0.2 < signals.score < 0.8
58 + assert "montreal" in signals.locations
59 +
60 +
61 +def test_generic_english_page_scores_near_zero():
62 + page = _page(
63 + url="https://techblog.com/post",
64 + title="Understanding Rust lifetimes",
65 + body="Rust lifetimes are a way to express the scope of references in your program. " * 20,
66 + language="en",
67 + )
68 + signals = score_page(page, "techblog.com")
69 + assert signals.score < 0.1
70 +
71 +
72 +def test_french_alone_is_not_proof():
73 + # Une page française de France ne doit pas passer le seuil sur la langue seule
74 + page = _page(
75 + url="https://lemonde.fr/article",
76 + title="Actualités françaises",
77 + body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10,
78 + language="fr",
79 + )
80 + signals = score_page(page, "lemonde.fr")
81 + assert signals.score < 0.3
82 +
83 +
84 +def test_score_saturates_below_one():
85 + body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec"
86 + page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr")
87 + signals = score_page(page, "x.qc.ca")
88 + assert 0.8 < signals.score <= 1.0
added M2M32crouve-ka/tests/test_ranking.py +41 −0
@@ -0,0 +1,41 @@
1 +# Trouve-KA — tests du pipeline de requête et du ranking
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.ranking import analyze_query, build_search_body
6 +
7 +
8 +def test_language_detection_heuristics():
9 + assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr"
10 + assert analyze_query("best heat pump rebate program")["language"] == "en"
11 +
12 +
13 +def test_location_extraction_with_accents():
14 + a = analyze_query("plombier gatineau")
15 + assert "gatineau" in a["locations"]
16 + b = analyze_query("subvention Trois-Rivières")
17 + assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"])
18 +
19 +
20 +def test_body_structure_bm25_core():
21 + body, analysis = build_search_body("plombier Gatineau", page=2, limit=10)
22 + assert body["from"] == 10 and body["size"] == 10
23 + fs = body["query"]["function_score"]
24 + fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"]
25 + assert "title^4" in fields and "body" in fields and "body.en" in fields
26 + # Localité : fonction de boost présente quand un lieu est détecté
27 + locality = [f for f in fs["functions"] if "filter" in f]
28 + assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"]
29 +
30 +
31 +def test_filters():
32 + body, _ = build_search_body("test", language="fr", category="government", quebec_only=True)
33 + filters = body["query"]["function_score"]["query"]["bool"]["filter"]
34 + assert {"term": {"language": "fr"}} in filters
35 + assert {"term": {"categories": "government"}} in filters
36 + assert any("bool" in f for f in filters) # quebec_only
37 +
38 +
39 +def test_no_locality_function_without_location():
40 + body, _ = build_search_body("recette de tourtière")
41 + assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f]
added M2M32crouve-ka/tests/test_ssrf.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — tests de prévention SSRF
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.shared import is_safe_ip, is_safe_url
6 +
7 +
8 +def test_blocks_loopback_and_private():
9 + for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"):
10 + assert not is_safe_ip(ip), ip
11 +
12 +
13 +def test_blocks_link_local_and_metadata():
14 + assert not is_safe_ip("169.254.169.254")
15 + assert not is_safe_ip("169.254.1.1")
16 + assert not is_safe_ip("fe80::1")
17 +
18 +
19 +def test_allows_public_ips():
20 + assert is_safe_ip("142.226.10.10")
21 + assert is_safe_ip("2607:f8b0::1")
22 +
23 +
24 +def test_blocks_localhost_and_schemes():
25 + assert not is_safe_url("http://localhost/admin", resolve=False)
26 + assert not is_safe_url("http://foo.localhost/x", resolve=False)
27 + assert not is_safe_url("http://metadata.google.internal/", resolve=False)
28 + assert not is_safe_url("file:///etc/passwd", resolve=False)
29 + assert not is_safe_url("gopher://x.ca", resolve=False)
30 +
31 +
32 +def test_blocks_ip_literal_urls():
33 + assert not is_safe_url("http://127.0.0.1:8080/", resolve=False)
34 + assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False)
35 + assert not is_safe_url("http://[::1]/", resolve=False)
36 +
37 +
38 +def test_allows_normal_url_without_resolution():
39 + assert is_safe_url("https://www.quebec.ca/", resolve=False)
added M2M32crouve-ka/tests/test_traps.py +39 −0
@@ -0,0 +1,39 @@
1 +# Trouve-KA — tests de détection de pièges
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.crawler.traps import looks_like_trap
6 +
7 +
8 +def test_session_ids():
9 + assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123")
10 + assert looks_like_trap("https://x.ca/p?sid=9f8e7d")
11 +
12 +
13 +def test_too_many_params():
14 + url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12))
15 + assert looks_like_trap(url)
16 +
17 +
18 +def test_repeated_facet_param():
19 + assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e")
20 +
21 +
22 +def test_deep_paths_and_loops():
23 + assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15))
24 + assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b")
25 +
26 +
27 +def test_far_future_calendar():
28 + assert looks_like_trap("https://x.ca/events/2085/05/17")
29 + assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12")
30 +
31 +
32 +def test_excessive_pagination():
33 + assert looks_like_trap("https://x.ca/liste?page=9999")
34 + assert not looks_like_trap("https://x.ca/liste?page=3")
35 +
36 +
37 +def test_normal_urls_pass():
38 + assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire")
39 + assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre")
added M2M32crouve-ka/tests/test_urls.py +53 −0
@@ -0,0 +1,53 @@
1 +# Trouve-KA — tests de canonicalisation d'URL
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.shared import canonicalize_url, display_url, extract_domain
6 +
7 +
8 +def test_removes_fragment_and_default_port():
9 + assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services"
10 + assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/"
11 +
12 +
13 +def test_preserves_non_default_port():
14 + assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x"
15 +
16 +
17 +def test_lowercases_host_but_preserves_path_case():
18 + assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF"
19 +
20 +
21 +def test_strips_tracking_params_only():
22 + url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1"
23 + assert canonicalize_url(url) == "https://ledevoir.com/a?id=42"
24 +
25 +
26 +def test_preserves_unknown_params_and_order():
27 + # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé
28 + assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1"
29 + assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2"
30 +
31 +
32 +def test_rejects_non_http_schemes():
33 + assert canonicalize_url("file:///etc/passwd") is None
34 + assert canonicalize_url("javascript:alert(1)") is None
35 + assert canonicalize_url("ftp://x.ca/f") is None
36 +
37 +
38 +def test_relative_resolution():
39 + assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b"
40 +
41 +
42 +def test_collapses_duplicate_slashes():
43 + assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b"
44 +
45 +
46 +def test_extract_domain_strips_www():
47 + assert extract_domain("https://www.quebec.ca/services") == "quebec.ca"
48 + assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca"
49 +
50 +
51 +def test_display_url_breadcrumb():
52 + crumb = display_url("https://www.quebec.ca/services/permis/conduire")
53 + assert crumb.startswith("quebec.ca › services › permis")
added README.md +224 −0
@@ -0,0 +1,224 @@
1 +# Trouve-KA — Cherche le Québec.
2 +
3 +![Statut](https://img.shields.io/badge/statut-en_production-1c5c41?style=flat-square)
4 +![Site](https://img.shields.io/badge/site-www.trouve--ka.com-141814?style=flat-square)
5 +![Pages indexées](https://img.shields.io/badge/pages_index%C3%A9es-59 000%2B_et_%C3%A7a_monte-b9cfee?style=flat-square&labelColor=141814)
6 +![Domaines](https://img.shields.io/badge/domaines_qu%C3%A9b%C3%A9cois-7 900%2B-b9cfee?style=flat-square&labelColor=141814)
7 +![Recherche](https://img.shields.io/badge/latence_recherche-20--95_ms-1c5c41?style=flat-square)
8 +![Python](https://img.shields.io/badge/Python-3.12-3776ab?style=flat-square&logo=python&logoColor=white)
9 +![Next.js](https://img.shields.io/badge/Next.js-15-000000?style=flat-square&logo=nextdotjs)
10 +![OpenSearch](https://img.shields.io/badge/OpenSearch-2.17-005eb8?style=flat-square&logo=opensearch&logoColor=white)
11 +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169e1?style=flat-square&logo=postgresql&logoColor=white)
12 +![Redis](https://img.shields.io/badge/Redis-7-dc382d?style=flat-square&logo=redis&logoColor=white)
13 +![Tests](https://img.shields.io/badge/tests-57_passing-1c5c41?style=flat-square)
14 +![par Groupe KA](https://img.shields.io/badge/par-Groupe_KA-b9cfee?style=flat-square&labelColor=141814)
15 +
16 +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre
17 +index, son propre ranking, son API et son application web publique. Pas un métamoteur :
18 +aucune dépendance à Google, Bing ou Brave pour les résultats.
19 +
20 +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.**
21 +
22 +En production : **https://www.trouve-ka.com** — un moteur de recherche par
23 +[Groupe KA](https://www.groupe-ka.com).
24 +
25 +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai
26 +
27 +---
28 +
29 +## Aperçu
30 +
31 +| | |
32 +|---|---|
33 +| ![Accueil](docs/screenshots/accueil.png) | ![Résultats](docs/screenshots/resultats.png) |
34 +| **Accueil** — la boîte de recherche, le compteur vivant, le flux « KA bot scrappe en ce moment » au footer | **Résultats** — pastilles éditoriales (◆ gradué selon le score Québec), vignettes og:image, snippets surlignés, 21 ms |
35 +| ![Galerie d'images](docs/screenshots/images.png) | ![État du moteur](docs/screenshots/statut.png) |
36 +| **Onglet Images** — galerie des pages avec image représentative (hotlink + attribution, jamais de crawl d'images) | **/status** — métriques publiques réelles, auto-refresh 10 s |
37 +
38 +## Métriques réelles (2026-08-13, jour 1 — ~6 h après le premier crawl)
39 +
40 +| Métrique | Valeur |
41 +|---|---|
42 +| Pages indexées et cherchables | **59 073** (+~35 000/heure) |
43 +| Domaines québécois découverts | **7 898** (à partir de 64 seeds) |
44 +| URLs découvertes en attente (frontier) | **433 999** |
45 +| Pages fetchées / heure | **76 198** |
46 +| Latence de recherche (mesurée en prod) | **20–95 ms** (cible p50 < 100 ms ✓) |
47 +| Délai fetch → cherchable | **~2–4 secondes** |
48 +| Nodes de crawl | **3** (M2M32 ×5 workers Docker, M2M32b, M2M32c) |
49 +| Taille de l'index | ~14 Ko/document (791 Mo à 55 k docs) |
50 +| Tests unitaires | 57 ✓ (canonicalisation, SSRF, robots, scoring Québec, pièges, fetcher, ranking) |
51 +
52 +*Chaque chiffre ci-dessus vient des vraies tables (`crawl_attempts`, `frontier_items`) et de
53 +l'index réel — aucun compteur simulé (règle § fake du projet).*
54 +
55 +---
56 +
57 +## Ce que c'est
58 +
59 +```
60 +Crawler → Frontier → Fetcher → Parser → Classification Québec
61 +→ Déduplication → Indexer → Index → Ranking → API → Web App
62 +```
63 +
64 +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement,
65 +municipalités, universités, médias), suit les liens sortants, juge la pertinence
66 +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`),
67 +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes,
68 +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités,
69 +embeddings) arrive après, en asynchrone, sans jamais bloquer.
70 +
71 +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et
72 +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres
73 +comme frontier, etc.).
74 +
75 +## Stack
76 +
77 +| Couche | Choix |
78 +|---|---|
79 +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) |
80 +| API | FastAPI (Python 3.12) |
81 +| Pipeline | Python 3.12 async — httpx, selectolax, Protego |
82 +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) |
83 +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) |
84 +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) |
85 +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) |
86 +
87 +## Layout du monorepo
88 +
89 +```
90 +apps/web # moteur public + dashboard /admin (Next.js)
91 +apps/api # FastAPI (trouveka.api)
92 +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment
93 +packages/ # config, database, logging, queue, search-core, shared, types
94 +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok)
95 +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py
96 +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking
97 +```
98 +
99 +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout
100 +(voir `pyproject.toml`).
101 +
102 +## Démarrage rapide (dev)
103 +
104 +```bash
105 +git clone && cd trouve-ka
106 +cp .env.example .env
107 +docker compose up -d postgres redis opensearch # infra
108 +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python
109 +pnpm install
110 +
111 +pnpm crawl:seed # migrations + 60+ seeds québécoises
112 +bash scripts/start-crawler/start.sh & # le crawl démarre
113 +.venv/bin/uvicorn trouveka.api.main:app --port 8080 &
114 +pnpm dev # → http://localhost:3000
115 +# → des résultats apparaissent en quelques secondes
116 +```
117 +
118 +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`,
119 +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes).
120 +
121 +Stack complet en containers : `docker compose up -d --build` (le service `migrate`
122 +applique les migrations, `crawler-worker` se scale avec
123 +`docker compose up -d --scale crawler-worker=3`).
124 +
125 +## Comment ça marche
126 +
127 +### Crawl et politesse
128 +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`,
129 + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth.
130 +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés.
131 +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté),
132 + quel que soit le nombre de workers.
133 +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque
134 + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges
135 + (session IDs, calendriers infinis, facettes explosives, pagination sans fin).
136 +
137 +### Indexation incrémentale
138 +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté
139 +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu +
140 +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2).
141 +
142 +### Détection Québec
143 +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec),
144 +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes
145 +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ,
146 +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve).
147 +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12).
148 +
149 +### Ranking
150 +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes
151 +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine
152 +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents
153 +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul.
154 +
155 +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080`
156 +(dataset dans `scripts/eval/ranking-eval.yaml`).
157 +
158 +## API
159 +
160 +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=`
161 +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler)
162 +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion)
163 +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live,
164 + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier
165 +
166 +## Tests
167 +
168 +```bash
169 +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking
170 +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI)
171 +bash scripts/health-check/check.sh # santé du stack
172 +```
173 +
174 +## Déploiement m2m32 + ngrok (www.trouve-ka.com)
175 +
176 +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié,
177 +domaine `www.trouve-ka.com` réservé dans le compte ngrok.
178 +
179 +```bash
180 +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh
181 +```
182 +
183 +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds
184 +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check.
185 +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues
186 +côté client, cookies/CORS sans surprise derrière le tunnel.
187 +
188 +Opérations courantes sur le node :
189 +
190 +```bash
191 +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct
192 +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=5'
193 +```
194 +
195 +### Crawl distribué (M2M32b, M2M32c)
196 +
197 +Des workers satellites tournent en natif sur d'autres nodes du cluster et se
198 +coordonnent **sans orchestrateur** via le frontier Postgres (`FOR UPDATE SKIP LOCKED`)
199 +et les verrous de politesse Redis (tous nodes confondus : jamais plus d'une requête
200 +par site par seconde). Communication par l'Ethernet LAN interne (`192.168.2.x`).
201 +
202 +```bash
203 +# Sur un node satellite (uv + python 3.12, .env pointé sur le node central) :
204 +cd ~/trouve-ka && pm2 start .venv/bin/python --name trouveka-crawler \
205 + --interpreter none -- -m trouveka.crawler.worker
206 +```
207 +
208 +⚠️ macOS Sequoia bloque l'accès « réseau local » des processus launchd : lancer les
209 +workers satellites via pm2/nohup depuis une session SSH, pas via LaunchAgent/Daemon.
210 +
211 +## Observabilité
212 +
213 +Logs JSON structurés par service, métriques réelles via `/api/status` et
214 +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences
215 +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md).
216 +
217 +## Règles du dépôt
218 +
219 +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par
220 + `scripts/check-headers.py`.
221 +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés;
222 + les fixtures vivent dans `tests/` uniquement.
223 +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source.
224 + Trouve-KA renvoie vers les éditeurs originaux.
added apps/api/__init__.py +5 −0
@@ -0,0 +1,5 @@
1 +# Trouve-KA — application API
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée)."""
added apps/api/main.py +305 −0
@@ -0,0 +1,305 @@
1 +# Trouve-KA — API FastAPI
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""API de Trouve-KA.
6 +
7 +Publique : /api/search, /api/status, /api/submit, /api/health
8 +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth.
9 +
10 +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même;
11 +si Postgres tombe, la recherche répond quand même (analytics sautées).
12 +"""
13 +
14 +import html
15 +import re
16 +import time
17 +from contextlib import asynccontextmanager
18 +from typing import Annotated
19 +
20 +from fastapi import Depends, FastAPI, Header, HTTPException, Query
21 +from fastapi.middleware.cors import CORSMiddleware
22 +from pydantic import BaseModel
23 +
24 +from trouveka.config import get_settings
25 +from trouveka.database import Database
26 +from trouveka.logging import get_logger
27 +from trouveka.queue import Coordination
28 +from trouveka.ranking import build_search_body
29 +from trouveka.search_core import SearchCore
30 +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url
31 +
32 +log = get_logger("api")
33 +settings = get_settings()
34 +
35 +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max)
36 +coord = Coordination(settings.redis_url)
37 +search = SearchCore(settings.search_url, settings.search_index)
38 +
39 +
40 +@asynccontextmanager
41 +async def lifespan(_app: FastAPI):
42 + await db.connect()
43 + await search.ensure_index()
44 + yield
45 + await search.close()
46 + await coord.close()
47 + await db.close()
48 +
49 +
50 +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan)
51 +
52 +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile
53 +# en prod, mais pratique en dev local (web sur :3000, API sur :8080).
54 +app.add_middleware(
55 + CORSMiddleware,
56 + allow_origins=["http://localhost:3000", settings.public_url],
57 + allow_methods=["GET", "POST"],
58 + allow_headers=["*", "X-Admin-Token"],
59 +)
60 +
61 +
62 +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None:
63 + if not x_admin_token or x_admin_token != settings.admin_token:
64 + raise HTTPException(status_code=401, detail="Jeton admin invalide")
65 +
66 +
67 +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>")
68 +
69 +
70 +def _safe_snippet(fragments: list[str]) -> str:
71 + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch."""
72 + snippet = _TAG_RE.sub("", " … ".join(fragments))[:400]
73 + # La troncature peut couper une balise en deux (« …Plombier</em ») : on nettoie
74 + snippet = re.sub(r"<[^>]*$", "", snippet)
75 + if snippet.count("<em>") > snippet.count("</em>"):
76 + snippet += "</em>"
77 + return snippet
78 +
79 +
80 +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"}
81 +
82 +
83 +# ---------------------------------------------------------------------- publique
84 +
85 +@app.get("/api/health")
86 +async def health():
87 + return {"ok": True, "search_ok": await search.ping()}
88 +
89 +
90 +@app.get("/api/search")
91 +async def api_search(
92 + q: str = Query(..., min_length=1, max_length=200),
93 + page: int = Query(1, ge=1, le=100),
94 + limit: int = Query(10, ge=1, le=50),
95 + language: str | None = Query(None, pattern="^(fr|en)$"),
96 + location: str | None = None,
97 + category: str | None = Query(None, max_length=40),
98 + quebec_only: bool = False,
99 + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"),
100 + images: bool = False,
101 +):
102 + started = time.monotonic()
103 + query_text = f"{q} {location}" if location else q
104 + body, analysis = build_search_body(
105 + query_text, page=page, limit=limit, language=language,
106 + category=category, quebec_only=quebec_only, freshness=freshness,
107 + images_only=images,
108 + )
109 + try:
110 + res = await search.search(body)
111 + except Exception:
112 + log.exception("recherche échouée", extra={"ctx": {"q": q}})
113 + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible")
114 +
115 + took_ms = int((time.monotonic() - started) * 1000)
116 + results = []
117 + for hit in res["hits"]["hits"]:
118 + src = hit["_source"]
119 + highlight = hit.get("highlight", {})
120 + fragments = highlight.get("body") or highlight.get("description") or []
121 + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400]
122 + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS]
123 + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6:
124 + badges.insert(0, "Québec")
125 + results.append({
126 + "title": src.get("title") or src["url"],
127 + "url": src["url"],
128 + "display_url": display_url(src["url"]),
129 + "snippet": snippet,
130 + "domain": src["domain"],
131 + "language": src.get("language"),
132 + "quebec_score": src.get("page_quebec_score", 0),
133 + "badges": badges,
134 + "published_at": src.get("published_at"),
135 + "image": src.get("image_url"),
136 + })
137 +
138 + total = res["hits"]["total"]["value"]
139 + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes
140 + try:
141 + await db.record_search_query(q, analysis["language"], total, took_ms)
142 + except Exception:
143 + log.exception("analytics de recherche sautées")
144 +
145 + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results}
146 +
147 +
148 +@app.get("/api/status")
149 +async def api_status():
150 + snapshot: dict = {}
151 + try:
152 + snapshot = await db.status_snapshot()
153 + except Exception:
154 + log.exception("statut PG indisponible")
155 + search_ok = await search.ping()
156 + try:
157 + paused = await coord.is_paused()
158 + except Exception:
159 + paused = False
160 + return {
161 + "pages_indexed": snapshot.get("pages_indexed", 0),
162 + "domains_count": snapshot.get("domains_count", 0),
163 + "indexed_last_hour": snapshot.get("indexed_last_hour", 0),
164 + "fetched_last_hour": snapshot.get("fetched_last_hour", 0),
165 + "errors_last_hour": snapshot.get("errors_last_hour", 0),
166 + "frontier_pending": snapshot.get("frontier_pending", 0),
167 + "frontier_in_progress": snapshot.get("frontier_in_progress", 0),
168 + "crawler_state": "paused" if paused else "running",
169 + "search_ok": search_ok,
170 + }
171 +
172 +
173 +@app.get("/api/live")
174 +async def api_live():
175 + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer.
176 +
177 + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne.
178 + """
179 + try:
180 + events = await db.recent_events(1)
181 + except Exception:
182 + return {"event": None}
183 + if not events:
184 + return {"event": None}
185 + e = events[0]
186 + return {
187 + "event": {
188 + "at": e["at"],
189 + "url": e["url"],
190 + "domain": extract_domain(e["url"]),
191 + "outcome": e["outcome"],
192 + }
193 + }
194 +
195 +
196 +class SubmitBody(BaseModel):
197 + url: str
198 +
199 +
200 +@app.post("/api/submit")
201 +async def api_submit(payload: SubmitBody):
202 + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide."""
203 + if not is_http_url(payload.url):
204 + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)")
205 + url = canonicalize_url(payload.url)
206 + domain = extract_domain(url) if url else None
207 + if not url or not domain:
208 + raise HTTPException(status_code=422, detail="URL invalide")
209 + await db.add_submission(url)
210 + await db.enqueue_url(url, domain, priority=0.7, depth=0)
211 + return {
212 + "accepted": True,
213 + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.",
214 + }
215 +
216 +
217 +# ---------------------------------------------------------------------- admin
218 +
219 +class SeedsBody(BaseModel):
220 + urls: list[str]
221 +
222 +
223 +class RecrawlBody(BaseModel):
224 + url: str | None = None
225 + domain: str | None = None
226 +
227 +
228 +class DomainBody(BaseModel):
229 + domain: str
230 +
231 +
232 +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)])
233 +async def admin_overview():
234 + overview = await db.admin_overview()
235 + overview["paused"] = await coord.is_paused()
236 + try:
237 + overview["enrich_backlog"] = await coord.enrich_backlog()
238 + except Exception:
239 + overview["enrich_backlog"] = None
240 + return overview
241 +
242 +
243 +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)])
244 +async def admin_recent(limit: int = Query(50, ge=1, le=200)):
245 + return {"events": await db.recent_events(limit)}
246 +
247 +
248 +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)])
249 +async def admin_pause():
250 + await coord.pause_crawler()
251 + return {"paused": True}
252 +
253 +
254 +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)])
255 +async def admin_resume():
256 + await coord.resume_crawler()
257 + return {"paused": False}
258 +
259 +
260 +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)])
261 +async def admin_seeds(payload: SeedsBody):
262 + added = 0
263 + for raw in payload.urls[:500]:
264 + url = canonicalize_url(raw.strip())
265 + domain = extract_domain(url) if url else None
266 + if url and domain:
267 + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):
268 + added += 1
269 + return {"added": added}
270 +
271 +
272 +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)])
273 +async def admin_recrawl(payload: RecrawlBody):
274 + if payload.url:
275 + url = canonicalize_url(payload.url)
276 + ok = await db.requeue_url(url) if url else False
277 + return {"requeued": 1 if ok else 0}
278 + if payload.domain:
279 + return {"requeued": await db.requeue_domain(payload.domain.lower())}
280 + raise HTTPException(status_code=422, detail="url ou domain requis")
281 +
282 +
283 +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)])
284 +async def admin_block_domain(payload: DomainBody):
285 + await db.block_domain(payload.domain.lower())
286 + return {"blocked": payload.domain.lower()}
287 +
288 +
289 +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)])
290 +async def admin_frontier(
291 + domain: str | None = None,
292 + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"),
293 + limit: int = Query(100, ge=1, le=500),
294 +):
295 + return {"items": await db.frontier_inspect(domain, status, limit)}
296 +
297 +
298 +def main() -> None:
299 + import uvicorn
300 +
301 + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1)
302 +
303 +
304 +if __name__ == "__main__":
305 + main()
added apps/web/.eslintrc.json +6 −0
@@ -0,0 +1,6 @@
1 +{
2 + "extends": "next/core-web-vitals",
3 + "rules": {
4 + "react/no-unescaped-entities": "off"
5 + }
6 +}
added apps/web/.gitignore +7 −0
@@ -0,0 +1,7 @@
1 +# Trouve-KA — artefacts générés de l'app web (non versionnés)
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +.next/
5 +node_modules/
6 +next-env.d.ts
7 +*.tsbuildinfo
added apps/web/app/admin/layout.tsx +18 −0
@@ -0,0 +1,18 @@
1 +/**
2 + * Trouve-KA — layout de la section admin (métadonnées, non indexée)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + */
6 +
7 +import type { Metadata } from "next";
8 +
9 +export const metadata: Metadata = {
10 + title: "Administration",
11 + robots: { index: false, follow: false },
12 +};
13 +
14 +export default function AdminLayout({
15 + children,
16 +}: Readonly<{ children: React.ReactNode }>) {
17 + return children;
18 +}
added apps/web/app/admin/page.tsx +56 −0
@@ -0,0 +1,56 @@
1 +/**
2 + * Trouve-KA — page admin (porte à jeton + dashboard)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + */
6 +
7 +"use client";
8 +
9 +import { useCallback, useEffect, useState } from "react";
10 +import { AdminDashboard } from "@/components/admin/dashboard";
11 +import { TokenForm } from "@/components/admin/token-form";
12 +import { ADMIN_TOKEN_KEY } from "@/lib/admin";
13 +
14 +export default function AdminPage() {
15 + // undefined = lecture de sessionStorage en cours; null = pas de jeton.
16 + const [token, setToken] = useState<string | null | undefined>(undefined);
17 + const [expired, setExpired] = useState(false);
18 +
19 + useEffect(() => {
20 + setToken(sessionStorage.getItem(ADMIN_TOKEN_KEY));
21 + }, []);
22 +
23 + const handleUnauthorized = useCallback(() => {
24 + sessionStorage.removeItem(ADMIN_TOKEN_KEY);
25 + setExpired(true);
26 + setToken(null);
27 + }, []);
28 +
29 + const handleToken = useCallback((value: string) => {
30 + sessionStorage.setItem(ADMIN_TOKEN_KEY, value);
31 + setExpired(false);
32 + setToken(value);
33 + }, []);
34 +
35 + const handleLogout = useCallback(() => {
36 + sessionStorage.removeItem(ADMIN_TOKEN_KEY);
37 + setExpired(false);
38 + setToken(null);
39 + }, []);
40 +
41 + if (token === undefined) {
42 + return null;
43 + }
44 +
45 + if (!token) {
46 + return <TokenForm onSubmit={handleToken} expired={expired} />;
47 + }
48 +
49 + return (
50 + <AdminDashboard
51 + token={token}
52 + onUnauthorized={handleUnauthorized}
53 + onLogout={handleLogout}
54 + />
55 + );
56 +}
added apps/web/app/globals.css +138 −0
@@ -0,0 +1,356 @@
1 +/**
2 + * Trouve-KA — styles globaux (système « éditorial sharp » de Groupe KA)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
7 + * Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
8 + * Accent bleu pâle marine #b9cfee sur encre
9 + */
10 +
11 +@tailwind base;
12 +@tailwind components;
13 +@tailwind utilities;
14 +
15 +:root {
16 + --paper: #f5f3ee;
17 + --surface: #ffffff;
18 + --surface-2: #faf9f5;
19 + --ink: #141814;
20 + --ink-2: #4d5551;
21 + --ink-3: #8b928c;
22 + --line: rgba(20, 24, 20, 0.14);
23 + --line-strong: rgba(20, 24, 20, 0.85);
24 + --green: #1c5c41;
25 + --green-deep: #123f2e;
26 + --lime: #b9cfee;
27 + --lime-soft: #e6eef9;
28 + --amber: #e8a33d;
29 + --amber-soft: #fdf3e2;
30 + --danger: #b3423a;
31 + --danger-soft: #fbe9e7;
32 +
33 + --r-card: 10px;
34 + --r-ctl: 6px;
35 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
36 + --shadow-off: 6px 6px 0 var(--ink);
37 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
38 +
39 + --font-display: var(--font-space-grotesk), system-ui, sans-serif;
40 + --font-body: var(--font-inter), system-ui, sans-serif;
41 + --font-mono: var(--font-jetbrains), ui-monospace, monospace;
42 +}
43 +
44 +@layer base {
45 + html {
46 + -webkit-text-size-adjust: 100%;
47 + scroll-behavior: smooth;
48 + }
49 +
50 + body {
51 + background: var(--paper);
52 + color: var(--ink);
53 + font-family: var(--font-body);
54 + font-size: 15px;
55 + line-height: 1.55;
56 + -webkit-font-smoothing: antialiased;
57 + }
58 +
59 + /* Grain de film pleine page — jamais de z-index sur body > * (position:
60 + relative seulement) pour ne pas casser les utilitaires z-* de Tailwind. */
61 + body::before {
62 + content: "";
63 + position: fixed;
64 + inset: 0;
65 + pointer-events: none;
66 + opacity: 0.35;
67 + z-index: 9999;
68 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
69 + }
70 + body > * {
71 + position: relative;
72 + }
73 +
74 + h1,
75 + h2,
76 + h3,
77 + h4 {
78 + font-family: var(--font-display);
79 + letter-spacing: -0.03em;
80 + }
81 +
82 + ::selection {
83 + background: var(--lime);
84 + color: var(--ink);
85 + }
86 +
87 + :focus-visible {
88 + outline: 2px solid var(--green);
89 + outline-offset: 2px;
90 + }
91 +}
92 +
93 +@layer components {
94 + /* ---------- Micro-typographie ---------- */
95 +
96 + .kicker {
97 + display: inline-flex;
98 + align-items: center;
99 + gap: 10px;
100 + font-family: var(--font-mono);
101 + font-size: 11.5px;
102 + font-weight: 500;
103 + text-transform: uppercase;
104 + letter-spacing: 0.12em;
105 + color: var(--green);
106 + }
107 + .kicker::before {
108 + content: "";
109 + width: 22px;
110 + height: 2px;
111 + background: var(--green);
112 + }
113 +
114 + .klabel {
115 + font-family: var(--font-mono);
116 + font-size: 10px;
117 + font-weight: 700;
118 + text-transform: uppercase;
119 + letter-spacing: 0.1em;
120 + color: var(--ink-3);
121 + }
122 +
123 + .gk-display {
124 + font-family: var(--font-display);
125 + }
126 + .gk-mono {
127 + font-family: var(--font-mono);
128 + }
129 +
130 + /* Traitements de titre signature */
131 + .outline-txt {
132 + color: transparent;
133 + -webkit-text-stroke: 2px var(--ink);
134 + }
135 + .hl {
136 + display: inline-block;
137 + background: var(--lime);
138 + border-radius: 8px;

Diff truncated — file too large.