SPB Git forge

spb/trouve-ka

Public ★ Pinned

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

40commits 1branches 0releases
9.2 MBsize
maindefault branch
22 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

Crawler équitable, recherche hybride sémantique, architecture native multi-nodes

Crawl : claim 1 URL/domaine (DISTINCT ON), backoff adaptatif réparé (defer ne
touche plus last_crawled_at), décroissance de priorité sur pages inchangées,
enqueue_urls_bulk (1 transaction/page vs 1/lien), cap réel urls/domaine
(migration 003), blocklist plateformes+CDN, timeout fetch 10s, boucle de claim
résiliente aux pannes DB.

Recherche : minimum_should_match 2<70%, index kNN (e5-small 384d, migration
alias v2), service embeddings+reranker cross-encoder (services/embedding),
fusion RRF avec gate de couverture, reranking du top-20 aussi en lexical,
55 groupes de synonymes QC appliqués à chaud, endpoint admin zero-results.

Infra : OpenSearch sur M2M32b (heap 8G, 2 shards), Postgres max_connections
200 + timeouts anti-zombies + shared_buffers 2G, backfill embeddings
anti-blocage avec superviseur, migration 004 autovacuum domains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 16, 2026) parent 5eedcb2

161 changed files +880 −18,822

modified .env.example +4 −0
@@ -32,3 +32,7 @@ API_URL=http://localhost:8080
32 32
33 33 # --- Index ---
34 34 SEARCH_INDEX=trouveka-docs
35 +
36 +# Recherche sémantique (optionnel) : URL du service d'embeddings
37 +# (services/embedding — venv dédié sur un node satellite). Vide = lexical seul.
38 +EMBEDDING_URL=
deleted M2M32brouve-ka/.env.example +0 −34
@@ -1,34 +0,0 @@
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
deleted M2M32brouve-ka/.gitignore +0 −13
@@ -1,13 +0,0 @@
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
deleted M2M32brouve-ka/CLAUDE.md +0 −363
@@ -1,363 +0,0 @@
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.
deleted M2M32brouve-ka/README.md +0 −166
@@ -1,166 +0,0 @@
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.
deleted M2M32brouve-ka/apps/api/__init__.py +0 −5
@@ -1,5 +0,0 @@
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)."""
deleted M2M32brouve-ka/apps/api/main.py +0 −297
@@ -1,297 +0,0 @@
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()
deleted M2M32brouve-ka/docker-compose.yml +0 −149
@@ -1,149 +0,0 @@
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:
deleted M2M32brouve-ka/docs/architecture.md +0 −106
@@ -1,106 +0,0 @@
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`.
deleted M2M32brouve-ka/docs/decisions.md +0 −43
@@ -1,43 +0,0 @@
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.
deleted M2M32brouve-ka/infrastructure/deployment/deploy-m2m32.sh +0 −38
@@ -1,38 +0,0 @@
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"
deleted M2M32brouve-ka/infrastructure/docker/backend.Dockerfile +0 −20
@@ -1,20 +0,0 @@
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"]
deleted M2M32brouve-ka/infrastructure/docker/web.Dockerfile +0 −26
@@ -1,26 +0,0 @@
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"]
deleted M2M32brouve-ka/infrastructure/migrations/001_init.sql +0 −124
@@ -1,152 +0,0 @@
1 −
2 −BEGIN;
3 −
4 −CREATE TABLE IF NOT EXISTS schema_migrations (
5 − version INTEGER PRIMARY KEY,
6 − applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
7 −);
8 −
9 −CREATE TABLE IF NOT EXISTS domains (
10 − id BIGSERIAL PRIMARY KEY,
11 − domain TEXT NOT NULL UNIQUE,
12 − first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
13 − last_crawled_at TIMESTAMPTZ,
14 − robots_fetched_at TIMESTAMPTZ,
15 − robots_body TEXT,
16 − robots_status TEXT, -- ok | not_found | error | forbidden
17 − crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay)
18 − quebec_score REAL NOT NULL DEFAULT 0,
19 − authority_score REAL NOT NULL DEFAULT 0,
20 − page_count INTEGER NOT NULL DEFAULT 0,
21 − inlink_domains INTEGER NOT NULL DEFAULT 0,
22 − outlink_domains INTEGER NOT NULL DEFAULT 0,
23 − language_stats JSONB NOT NULL DEFAULT '{}'::jsonb,
24 − content_change_rate REAL,
25 − blocked BOOLEAN NOT NULL DEFAULT FALSE,
26 − is_seed BOOLEAN NOT NULL DEFAULT FALSE
27 −);
28 −CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC);
29 −
30 −CREATE TABLE IF NOT EXISTS urls (
31 − id BIGSERIAL PRIMARY KEY,
32 − url TEXT NOT NULL UNIQUE,
33 − canonical_url TEXT,
34 − domain_id BIGINT NOT NULL REFERENCES domains(id),
35 − first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
36 −);
37 −CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id);
38 −
39 −CREATE TABLE IF NOT EXISTS frontier_items (
40 − id BIGSERIAL PRIMARY KEY,
41 − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
42 − priority REAL NOT NULL DEFAULT 0.5,
43 − depth INTEGER NOT NULL DEFAULT 0,
44 − source_url_id BIGINT REFERENCES urls(id),
45 − discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
46 − last_crawled_at TIMESTAMPTZ,
47 − next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(),
48 − status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked
49 − retries INTEGER NOT NULL DEFAULT 0,
50 − error_code TEXT,
51 − locked_by TEXT,
52 − locked_at TIMESTAMPTZ
53 −);
54 −CREATE INDEX IF NOT EXISTS idx_frontier_ready
55 − ON frontier_items (next_crawl_at, priority DESC)
56 − WHERE status = 'pending';
57 −CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status);
58 −
59 −CREATE TABLE IF NOT EXISTS crawl_attempts (
60 − id BIGSERIAL PRIMARY KEY,
61 − url_id BIGINT NOT NULL REFERENCES urls(id),
62 − fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
63 − status_code INTEGER,
64 − error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed|
65 − -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok
66 − outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged
67 − content_hash TEXT,
68 − bytes INTEGER,
69 − duration_ms INTEGER,
70 − redirect_url TEXT,
71 − title TEXT,
72 − quebec_score REAL
73 −);
74 −CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC);
75 −CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id);
76 −
77 −CREATE TABLE IF NOT EXISTS documents (
78 − id BIGSERIAL PRIMARY KEY,
79 − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
80 − content_hash TEXT NOT NULL,
81 − etag TEXT,
82 − last_modified TEXT,
83 − title TEXT,
84 − description TEXT,
85 − language TEXT,
86 − page_quebec_score REAL NOT NULL DEFAULT 0,
87 − published_at TIMESTAMPTZ,
88 − last_changed_at TIMESTAMPTZ,
89 − first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
90 − last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
91 − enrichment_stage INTEGER NOT NULL DEFAULT 1
92 −);
93 −CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash);
94 −
95 −CREATE TABLE IF NOT EXISTS domain_links (
96 − from_domain_id BIGINT NOT NULL REFERENCES domains(id),
97 − to_domain_id BIGINT NOT NULL REFERENCES domains(id),
98 − link_count INTEGER NOT NULL DEFAULT 1,
99 − updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
100 − PRIMARY KEY (from_domain_id, to_domain_id)
101 −);
102 −CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id);
103 −
104 −CREATE TABLE IF NOT EXISTS search_queries (
105 − id BIGSERIAL PRIMARY KEY,
106 − query TEXT NOT NULL,
107 − language TEXT,
108 − results_total INTEGER NOT NULL,
109 − took_ms INTEGER NOT NULL,
110 − zero_result BOOLEAN NOT NULL DEFAULT FALSE,
111 − created_at TIMESTAMPTZ NOT NULL DEFAULT now()
112 −);
113 −CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result;
114 −
115 −CREATE TABLE IF NOT EXISTS submissions (
116 − id BIGSERIAL PRIMARY KEY,
117 − url TEXT NOT NULL,
118 − submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
119 − status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected
120 −);
121 −
122 −INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
123 −
124 −COMMIT;
deleted M2M32brouve-ka/infrastructure/migrations/002_frontier_priority_index.sql +0 −11
@@ -1,17 +0,0 @@
1 −--
2 −
3 −BEGIN;
4 −
5 −CREATE INDEX IF NOT EXISTS idx_frontier_priority
6 − ON frontier_items (priority DESC, next_crawl_at)
7 − WHERE status = 'pending';
8 −
9 −INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
10 −
11 −COMMIT;
deleted M2M32brouve-ka/infrastructure/monitoring/README.md +0 −22
@@ -1,22 +0,0 @@
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.
deleted M2M32brouve-ka/package.json +0 −19
@@ -1,19 +0,0 @@
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 −}
deleted M2M32brouve-ka/packages/config/__init__.py +0 −67
@@ -1,67 +0,0 @@
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()
deleted M2M32brouve-ka/packages/database/__init__.py +0 −10
@@ -1,10 +0,0 @@
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"]
deleted M2M32brouve-ka/packages/database/db.py +0 −522
@@ -1,522 +0,0 @@
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]
deleted M2M32brouve-ka/packages/database/migrate.py +0 −46
@@ -1,46 +0,0 @@
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
deleted M2M32brouve-ka/packages/logging/__init__.py +0 −39
@@ -1,39 +0,0 @@
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
deleted M2M32brouve-ka/packages/queue/__init__.py +0 −71
@@ -1,71 +0,0 @@
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)
deleted M2M32brouve-ka/packages/search-core/__init__.py +0 −10
@@ -1,10 +0,0 @@
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"]
deleted M2M32brouve-ka/packages/search-core/client.py +0 −66
@@ -1,66 +0,0 @@
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
deleted M2M32brouve-ka/packages/search-core/mapping.py +0 −124
@@ -1,124 +0,0 @@
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 −}
deleted M2M32brouve-ka/packages/shared/__init__.py +0 −20
@@ -1,20 +0,0 @@
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 −]
deleted M2M32brouve-ka/packages/shared/hashing.py +0 −25
@@ -1,25 +0,0 @@
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()
deleted M2M32brouve-ka/packages/shared/ssrf.py +0 −75
@@ -1,75 +0,0 @@
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)
deleted M2M32brouve-ka/packages/shared/urls.py +0 −103
@@ -1,103 +0,0 @@
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
deleted M2M32brouve-ka/packages/types/__init__.py +0 −103
@@ -1,103 +0,0 @@
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]
deleted M2M32brouve-ka/pnpm-lock.yaml +0 −3967
@@ -1,3967 +0,0 @@
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: {}
deleted M2M32brouve-ka/pnpm-workspace.yaml +0 −8
@@ -1,8 +0,0 @@
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
deleted M2M32brouve-ka/pyproject.toml +0 −85
@@ -1,85 +0,0 @@
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"
deleted M2M32brouve-ka/scripts/bootstrap-seeds/seed.sh +0 −14
@@ -1,14 +0,0 @@
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}"
deleted M2M32brouve-ka/scripts/bootstrap-seeds/seeds.txt +0 −80
@@ -1,80 +0,0 @@
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/
deleted M2M32brouve-ka/scripts/check-headers.py +0 −38
@@ -1,38 +0,0 @@
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())
deleted M2M32brouve-ka/scripts/cleanup-garbage.py +0 −63
@@ -1,63 +0,0 @@
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())
deleted M2M32brouve-ka/scripts/eval/ranking-eval.yaml +0 −29
@@ -1,29 +0,0 @@
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
deleted M2M32brouve-ka/scripts/eval/run-eval.py +0 −65
@@ -1,65 +0,0 @@
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())
deleted M2M32brouve-ka/scripts/health-check/check.sh +0 −27
@@ -1,27 +0,0 @@
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
deleted M2M32brouve-ka/scripts/start-crawler/start.sh +0 −14
@@ -1,14 +0,0 @@
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
deleted M2M32brouve-ka/services/classifier/__init__.py +0 −15
@@ -1,15 +0,0 @@
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"]
deleted M2M32brouve-ka/services/classifier/gazetteer.py +0 −75
@@ -1,75 +0,0 @@
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")
deleted M2M32brouve-ka/services/classifier/scorer.py +0 −115
@@ -1,115 +0,0 @@
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
deleted M2M32brouve-ka/services/crawler/__init__.py +0 −12
@@ -1,12 +0,0 @@
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"]
deleted M2M32brouve-ka/services/crawler/fetcher.py +0 −148
@@ -1,148 +0,0 @@
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}"
deleted M2M32brouve-ka/services/crawler/robots.py +0 −84
@@ -1,84 +0,0 @@
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
deleted M2M32brouve-ka/services/crawler/seed.py +0 −57
@@ -1,57 +0,0 @@
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()
deleted M2M32brouve-ka/services/crawler/traps.py +0 −59
@@ -1,59 +0,0 @@
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
deleted M2M32brouve-ka/services/crawler/worker.py +0 −447
@@ -1,447 +0,0 @@
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()
deleted M2M32brouve-ka/services/enrichment/__init__.py +0 −14
@@ -1,14 +0,0 @@
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"]
deleted M2M32brouve-ka/services/enrichment/worker.py +0 −82
@@ -1,82 +0,0 @@
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()
deleted M2M32brouve-ka/services/frontier/__init__.py +0 −9
@@ -1,9 +0,0 @@
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"]
deleted M2M32brouve-ka/services/frontier/policy.py +0 −65
@@ -1,65 +0,0 @@
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))
deleted M2M32brouve-ka/services/indexer/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32brouve-ka/services/indexer/build.py +0 −66
@@ -1,66 +0,0 @@
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 − }
deleted M2M32brouve-ka/services/parser/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32brouve-ka/services/parser/html_parser.py +0 −246
@@ -1,246 +0,0 @@
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 − )
deleted M2M32brouve-ka/services/ranking/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32brouve-ka/services/ranking/query.py +0 −146
@@ -1,146 +0,0 @@
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
deleted M2M32brouve-ka/services/scheduler/__init__.py +0 −9
@@ -1,9 +0,0 @@
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"]
deleted M2M32brouve-ka/services/scheduler/loop.py +0 −81
@@ -1,81 +0,0 @@
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()
deleted M2M32brouve-ka/tests/test_fetcher.py +0 −110
@@ -1,110 +0,0 @@
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
deleted M2M32brouve-ka/tests/test_frontier_policy.py +0 −44
@@ -1,44 +0,0 @@
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
deleted M2M32brouve-ka/tests/test_parser.py +0 −92
@@ -1,92 +0,0 @@
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("")
deleted M2M32brouve-ka/tests/test_quebec_scoring.py +0 −88
@@ -1,88 +0,0 @@
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
deleted M2M32brouve-ka/tests/test_ranking.py +0 −41
@@ -1,41 +0,0 @@
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]
deleted M2M32brouve-ka/tests/test_ssrf.py +0 −39
@@ -1,39 +0,0 @@
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)
deleted M2M32brouve-ka/tests/test_traps.py +0 −39
@@ -1,39 +0,0 @@
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")
deleted M2M32brouve-ka/tests/test_urls.py +0 −53
@@ -1,53 +0,0 @@
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")
deleted M2M32crouve-ka/.env.example +0 −34
@@ -1,34 +0,0 @@
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
deleted M2M32crouve-ka/.gitignore +0 −13
@@ -1,13 +0,0 @@
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
deleted M2M32crouve-ka/CLAUDE.md +0 −363
@@ -1,363 +0,0 @@
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.
deleted M2M32crouve-ka/README.md +0 −166
@@ -1,166 +0,0 @@
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.
deleted M2M32crouve-ka/apps/api/__init__.py +0 −5
@@ -1,5 +0,0 @@
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)."""
deleted M2M32crouve-ka/apps/api/main.py +0 −297
@@ -1,297 +0,0 @@
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()
deleted M2M32crouve-ka/docker-compose.yml +0 −149
@@ -1,149 +0,0 @@
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:
deleted M2M32crouve-ka/docs/architecture.md +0 −106
@@ -1,106 +0,0 @@
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`.
deleted M2M32crouve-ka/docs/decisions.md +0 −43
@@ -1,43 +0,0 @@
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.
deleted M2M32crouve-ka/infrastructure/deployment/deploy-m2m32.sh +0 −38
@@ -1,38 +0,0 @@
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"
deleted M2M32crouve-ka/infrastructure/docker/backend.Dockerfile +0 −20
@@ -1,20 +0,0 @@
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"]
deleted M2M32crouve-ka/infrastructure/docker/web.Dockerfile +0 −26
@@ -1,26 +0,0 @@
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"]
deleted M2M32crouve-ka/infrastructure/migrations/001_init.sql +0 −124
@@ -1,152 +0,0 @@
1 −
2 −BEGIN;
3 −
4 −CREATE TABLE IF NOT EXISTS schema_migrations (
5 − version INTEGER PRIMARY KEY,
6 − applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
7 −);
8 −
9 −CREATE TABLE IF NOT EXISTS domains (
10 − id BIGSERIAL PRIMARY KEY,
11 − domain TEXT NOT NULL UNIQUE,
12 − first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
13 − last_crawled_at TIMESTAMPTZ,
14 − robots_fetched_at TIMESTAMPTZ,
15 − robots_body TEXT,
16 − robots_status TEXT, -- ok | not_found | error | forbidden
17 − crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay)
18 − quebec_score REAL NOT NULL DEFAULT 0,
19 − authority_score REAL NOT NULL DEFAULT 0,
20 − page_count INTEGER NOT NULL DEFAULT 0,
21 − inlink_domains INTEGER NOT NULL DEFAULT 0,
22 − outlink_domains INTEGER NOT NULL DEFAULT 0,
23 − language_stats JSONB NOT NULL DEFAULT '{}'::jsonb,
24 − content_change_rate REAL,
25 − blocked BOOLEAN NOT NULL DEFAULT FALSE,
26 − is_seed BOOLEAN NOT NULL DEFAULT FALSE
27 −);
28 −CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC);
29 −
30 −CREATE TABLE IF NOT EXISTS urls (
31 − id BIGSERIAL PRIMARY KEY,
32 − url TEXT NOT NULL UNIQUE,
33 − canonical_url TEXT,
34 − domain_id BIGINT NOT NULL REFERENCES domains(id),
35 − first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
36 −);
37 −CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id);
38 −
39 −CREATE TABLE IF NOT EXISTS frontier_items (
40 − id BIGSERIAL PRIMARY KEY,
41 − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
42 − priority REAL NOT NULL DEFAULT 0.5,
43 − depth INTEGER NOT NULL DEFAULT 0,
44 − source_url_id BIGINT REFERENCES urls(id),
45 − discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
46 − last_crawled_at TIMESTAMPTZ,
47 − next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(),
48 − status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked
49 − retries INTEGER NOT NULL DEFAULT 0,
50 − error_code TEXT,
51 − locked_by TEXT,
52 − locked_at TIMESTAMPTZ
53 −);
54 −CREATE INDEX IF NOT EXISTS idx_frontier_ready
55 − ON frontier_items (next_crawl_at, priority DESC)
56 − WHERE status = 'pending';
57 −CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status);
58 −
59 −CREATE TABLE IF NOT EXISTS crawl_attempts (
60 − id BIGSERIAL PRIMARY KEY,
61 − url_id BIGINT NOT NULL REFERENCES urls(id),
62 − fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
63 − status_code INTEGER,
64 − error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed|
65 − -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok
66 − outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged
67 − content_hash TEXT,
68 − bytes INTEGER,
69 − duration_ms INTEGER,
70 − redirect_url TEXT,
71 − title TEXT,
72 − quebec_score REAL
73 −);
74 −CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC);
75 −CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id);
76 −
77 −CREATE TABLE IF NOT EXISTS documents (
78 − id BIGSERIAL PRIMARY KEY,
79 − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id),
80 − content_hash TEXT NOT NULL,
81 − etag TEXT,
82 − last_modified TEXT,
83 − title TEXT,
84 − description TEXT,
85 − language TEXT,
86 − page_quebec_score REAL NOT NULL DEFAULT 0,
87 − published_at TIMESTAMPTZ,
88 − last_changed_at TIMESTAMPTZ,
89 − first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
90 − last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
91 − enrichment_stage INTEGER NOT NULL DEFAULT 1
92 −);
93 −CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash);
94 −
95 −CREATE TABLE IF NOT EXISTS domain_links (
96 − from_domain_id BIGINT NOT NULL REFERENCES domains(id),
97 − to_domain_id BIGINT NOT NULL REFERENCES domains(id),
98 − link_count INTEGER NOT NULL DEFAULT 1,
99 − updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
100 − PRIMARY KEY (from_domain_id, to_domain_id)
101 −);
102 −CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id);
103 −
104 −CREATE TABLE IF NOT EXISTS search_queries (
105 − id BIGSERIAL PRIMARY KEY,
106 − query TEXT NOT NULL,
107 − language TEXT,
108 − results_total INTEGER NOT NULL,
109 − took_ms INTEGER NOT NULL,
110 − zero_result BOOLEAN NOT NULL DEFAULT FALSE,
111 − created_at TIMESTAMPTZ NOT NULL DEFAULT now()
112 −);
113 −CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result;
114 −
115 −CREATE TABLE IF NOT EXISTS submissions (
116 − id BIGSERIAL PRIMARY KEY,
117 − url TEXT NOT NULL,
118 − submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
119 − status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected
120 −);
121 −
122 −INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
123 −
124 −COMMIT;
deleted M2M32crouve-ka/infrastructure/migrations/002_frontier_priority_index.sql +0 −11
@@ -1,17 +0,0 @@
1 −--
2 −
3 −BEGIN;
4 −
5 −CREATE INDEX IF NOT EXISTS idx_frontier_priority
6 − ON frontier_items (priority DESC, next_crawl_at)
7 − WHERE status = 'pending';
8 −
9 −INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
10 −
11 −COMMIT;
deleted M2M32crouve-ka/infrastructure/monitoring/README.md +0 −22
@@ -1,22 +0,0 @@
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.
deleted M2M32crouve-ka/package.json +0 −19
@@ -1,19 +0,0 @@
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 −}
deleted M2M32crouve-ka/packages/config/__init__.py +0 −67
@@ -1,67 +0,0 @@
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()
deleted M2M32crouve-ka/packages/database/__init__.py +0 −10
@@ -1,10 +0,0 @@
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"]
deleted M2M32crouve-ka/packages/database/db.py +0 −522
@@ -1,522 +0,0 @@
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]
deleted M2M32crouve-ka/packages/database/migrate.py +0 −46
@@ -1,46 +0,0 @@
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
deleted M2M32crouve-ka/packages/logging/__init__.py +0 −39
@@ -1,39 +0,0 @@
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
deleted M2M32crouve-ka/packages/queue/__init__.py +0 −71
@@ -1,71 +0,0 @@
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)
deleted M2M32crouve-ka/packages/search-core/__init__.py +0 −10
@@ -1,10 +0,0 @@
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"]
deleted M2M32crouve-ka/packages/search-core/client.py +0 −66
@@ -1,66 +0,0 @@
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
deleted M2M32crouve-ka/packages/search-core/mapping.py +0 −124
@@ -1,124 +0,0 @@
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 −}
deleted M2M32crouve-ka/packages/shared/__init__.py +0 −20
@@ -1,20 +0,0 @@
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 −]
deleted M2M32crouve-ka/packages/shared/hashing.py +0 −25
@@ -1,25 +0,0 @@
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()
deleted M2M32crouve-ka/packages/shared/ssrf.py +0 −75
@@ -1,75 +0,0 @@
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)
deleted M2M32crouve-ka/packages/shared/urls.py +0 −103
@@ -1,103 +0,0 @@
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
deleted M2M32crouve-ka/packages/types/__init__.py +0 −103
@@ -1,103 +0,0 @@
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]
deleted M2M32crouve-ka/pnpm-lock.yaml +0 −3967
@@ -1,3967 +0,0 @@
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: {}
deleted M2M32crouve-ka/pnpm-workspace.yaml +0 −8
@@ -1,8 +0,0 @@
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
deleted M2M32crouve-ka/pyproject.toml +0 −85
@@ -1,85 +0,0 @@
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"
deleted M2M32crouve-ka/scripts/bootstrap-seeds/seed.sh +0 −14
@@ -1,14 +0,0 @@
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}"
deleted M2M32crouve-ka/scripts/bootstrap-seeds/seeds.txt +0 −80
@@ -1,80 +0,0 @@
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/
deleted M2M32crouve-ka/scripts/check-headers.py +0 −38
@@ -1,38 +0,0 @@
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())
deleted M2M32crouve-ka/scripts/cleanup-garbage.py +0 −63
@@ -1,63 +0,0 @@
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())
deleted M2M32crouve-ka/scripts/eval/ranking-eval.yaml +0 −29
@@ -1,29 +0,0 @@
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
deleted M2M32crouve-ka/scripts/eval/run-eval.py +0 −65
@@ -1,65 +0,0 @@
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())
deleted M2M32crouve-ka/scripts/health-check/check.sh +0 −27
@@ -1,27 +0,0 @@
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
deleted M2M32crouve-ka/scripts/start-crawler/start.sh +0 −14
@@ -1,14 +0,0 @@
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
deleted M2M32crouve-ka/services/classifier/__init__.py +0 −15
@@ -1,15 +0,0 @@
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"]
deleted M2M32crouve-ka/services/classifier/gazetteer.py +0 −75
@@ -1,75 +0,0 @@
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")
deleted M2M32crouve-ka/services/classifier/scorer.py +0 −115
@@ -1,115 +0,0 @@
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
deleted M2M32crouve-ka/services/crawler/__init__.py +0 −12
@@ -1,12 +0,0 @@
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"]
deleted M2M32crouve-ka/services/crawler/fetcher.py +0 −148
@@ -1,148 +0,0 @@
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}"
deleted M2M32crouve-ka/services/crawler/robots.py +0 −84
@@ -1,84 +0,0 @@
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
deleted M2M32crouve-ka/services/crawler/seed.py +0 −57
@@ -1,57 +0,0 @@
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()
deleted M2M32crouve-ka/services/crawler/traps.py +0 −59
@@ -1,59 +0,0 @@
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
deleted M2M32crouve-ka/services/crawler/worker.py +0 −447
@@ -1,447 +0,0 @@
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()
deleted M2M32crouve-ka/services/enrichment/__init__.py +0 −14
@@ -1,14 +0,0 @@
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"]
deleted M2M32crouve-ka/services/enrichment/worker.py +0 −82
@@ -1,82 +0,0 @@
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()
deleted M2M32crouve-ka/services/frontier/__init__.py +0 −9
@@ -1,9 +0,0 @@
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"]
deleted M2M32crouve-ka/services/frontier/policy.py +0 −65
@@ -1,65 +0,0 @@
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))
deleted M2M32crouve-ka/services/indexer/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32crouve-ka/services/indexer/build.py +0 −66
@@ -1,66 +0,0 @@
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 − }
deleted M2M32crouve-ka/services/parser/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32crouve-ka/services/parser/html_parser.py +0 −246
@@ -1,246 +0,0 @@
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 − )
deleted M2M32crouve-ka/services/ranking/__init__.py +0 −13
@@ -1,13 +0,0 @@
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"]
deleted M2M32crouve-ka/services/ranking/query.py +0 −146
@@ -1,146 +0,0 @@
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
deleted M2M32crouve-ka/services/scheduler/__init__.py +0 −9
@@ -1,9 +0,0 @@
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"]
deleted M2M32crouve-ka/services/scheduler/loop.py +0 −81
@@ -1,81 +0,0 @@
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()
deleted M2M32crouve-ka/tests/test_fetcher.py +0 −110
@@ -1,110 +0,0 @@
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
deleted M2M32crouve-ka/tests/test_frontier_policy.py +0 −44
@@ -1,44 +0,0 @@
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
deleted M2M32crouve-ka/tests/test_parser.py +0 −92
@@ -1,92 +0,0 @@
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("")
deleted M2M32crouve-ka/tests/test_quebec_scoring.py +0 −88
@@ -1,88 +0,0 @@
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
deleted M2M32crouve-ka/tests/test_ranking.py +0 −41
@@ -1,41 +0,0 @@
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]
deleted M2M32crouve-ka/tests/test_ssrf.py +0 −39
@@ -1,39 +0,0 @@
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)
deleted M2M32crouve-ka/tests/test_traps.py +0 −39
@@ -1,39 +0,0 @@
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")
deleted M2M32crouve-ka/tests/test_urls.py +0 −53
@@ -1,53 +0,0 @@
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")
modified apps/api/main.py +95 −4
@@ -11,6 +11,7 @@ Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même;
11 11 si Postgres tombe, la recherche répond quand même (analytics sautées).
12 12 """
13 13
14 +import asyncio
14 15 import html
15 16 import re
16 17 import time
@@ -25,8 +26,8 @@ from trouveka.config import get_settings
25 26 from trouveka.database import Database
26 27 from trouveka.logging import get_logger
27 28 from trouveka.queue import Coordination
28 −from trouveka.ranking import build_search_body
29 −from trouveka.search_core import SearchCore
29 +from trouveka.ranking import build_knn_body, build_search_body, rrf_fuse
30 +from trouveka.search_core import EmbedClient, SearchCore
30 31 from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url
31 32
32 33 log = get_logger("api")
@@ -35,6 +36,7 @@ settings = get_settings()
35 36 db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max)
36 37 coord = Coordination(settings.redis_url)
37 38 search = SearchCore(settings.search_url, settings.search_index)
39 +embedder = EmbedClient(settings.embedding_url)
38 40
39 41
40 42 @asynccontextmanager
@@ -43,6 +45,7 @@ async def lifespan(_app: FastAPI):
43 45 await search.ensure_index()
44 46 yield
45 47 await search.close()
48 + await embedder.close()
46 49 await coord.close()
47 50 await db.close()
48 51
@@ -79,6 +82,22 @@ def _safe_snippet(fragments: list[str]) -> str:
79 82
80 83 BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"}
81 84
85 +# Le kNN n'entre dans la fusion que si le backfill couvre assez l'index :
86 +# sous ce seuil, les voisins vectoriels sortent d'un échantillon arbitraire
87 +# et dégradent le top-10 au lieu de l'améliorer.
88 +KNN_MIN_COVERAGE = 0.6
89 +_knn_ready_cache: dict = {"at": 0.0, "ready": False}
90 +
91 +
92 +async def _knn_ready() -> bool:
93 + now = time.monotonic()
94 + if now - _knn_ready_cache["at"] > 300:
95 + coverage = await search.embedding_coverage()
96 + _knn_ready_cache.update(at=now, ready=coverage >= KNN_MIN_COVERAGE)
97 + if not _knn_ready_cache["ready"]:
98 + log.info("kNN désactivé", extra={"ctx": {"embedding_coverage": round(coverage, 3)}})
99 + return _knn_ready_cache["ready"]
100 +
82 101
83 102 # ---------------------------------------------------------------------- publique
84 103
@@ -106,8 +125,69 @@ async def api_search(
106 125 category=category, quebec_only=quebec_only, freshness=freshness,
107 126 images_only=images,
108 127 )
128 +
129 + # --- Recherche hybride : BM25 + kNN fusionnés par RRF.
130 + # Chaque étage est optionnel (§9) : pas de service d'embeddings, timeout,
131 + # page trop profonde, index sans vecteurs → lexical seul, transparent.
132 + semantic = False
133 + reranked = False
134 + fuse_depth = page * limit + limit
135 + vector = None
136 + if embedder.enabled and fuse_depth <= 200 and not images and await _knn_ready():
137 + vector = await embedder.embed_query(query_text)
138 +
139 + RERANK_WINDOW = 20
140 +
141 + async def _rerank_top(hits: list) -> tuple[list, bool]:
142 + """Réordonne le top-20 par cross-encoder (titre+description+extraits).
143 + En échec/timeout : ordre d'origine, aucun impact."""
144 + if not embedder.enabled or len(hits) < 2:
145 + return hits, False
146 + top = hits[:RERANK_WINDOW]
147 + texts = []
148 + for h in top:
149 + frags = (h.get("highlight", {}).get("body") or [])[:2]
150 + frag_text = _TAG_RE.sub("", " ".join(frags)).replace("<em>", "").replace("</em>", "")
151 + t = (f"{h['_source'].get('title') or ''}. "
152 + f"{h['_source'].get('description') or ''} {frag_text}").strip(". ")
153 + texts.append(t or h["_source"]["url"])
154 + scores = await embedder.rerank(query_text, texts)
155 + if not scores:
156 + return hits, False
157 + order = sorted(range(len(top)), key=lambda i: scores[i], reverse=True)
158 + return [top[i] for i in order] + hits[RERANK_WINDOW:], True
159 +
109 160 try:
110 − res = await search.search(body)
161 + start_idx = (page - 1) * limit
162 + if vector is not None:
163 + depth = max(50, fuse_depth)
164 + lexical_body = dict(body, **{"from": 0, "size": depth})
165 + knn_body = build_knn_body(vector, filters=analysis.get("filters", []), size=depth)
166 + lex_res, knn_res = await asyncio.gather(
167 + search.search(lexical_body), search.search(knn_body), return_exceptions=True
168 + )
169 + if isinstance(lex_res, Exception):
170 + raise lex_res
171 + if isinstance(knn_res, Exception):
172 + fused = lex_res["hits"]["hits"] # kNN indisponible : lexical seul
173 + else:
174 + fused = rrf_fuse(lex_res["hits"]["hits"], knn_res["hits"]["hits"])
175 + semantic = True
176 + if start_idx < RERANK_WINDOW:
177 + fused, reranked = await _rerank_top(fused)
178 + res = {"hits": {"hits": fused[start_idx : start_idx + limit],
179 + "total": lex_res["hits"]["total"]}}
180 + elif embedder.enabled and start_idx < RERANK_WINDOW and not images:
181 + # Lexical pur (kNN gaté ou embed indisponible) : le reranker
182 + # s'applique quand même — il ne dépend pas des embeddings de l'index.
183 + depth = max(50, fuse_depth)
184 + lexical_body = dict(body, **{"from": 0, "size": depth})
185 + lex_res = await search.search(lexical_body)
186 + hits, reranked = await _rerank_top(lex_res["hits"]["hits"])
187 + res = {"hits": {"hits": hits[start_idx : start_idx + limit],
188 + "total": lex_res["hits"]["total"]}}
189 + else:
190 + res = await search.search(body)
111 191 except Exception:
112 192 log.exception("recherche échouée", extra={"ctx": {"q": q}})
113 193 raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible")
@@ -142,7 +222,8 @@ async def api_search(
142 222 except Exception:
143 223 log.exception("analytics de recherche sautées")
144 224
145 − return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results}
225 + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit,
226 + "semantic": semantic, "reranked": reranked, "results": results}
146 227
147 228
148 229 @app.get("/api/status")
@@ -245,6 +326,16 @@ async def admin_recent(limit: int = Query(50, ge=1, le=200)):
245 326 return {"events": await db.recent_events(limit)}
246 327
247 328
329 +@app.get("/api/admin/zero-results", dependencies=[Depends(require_admin)])
330 +async def admin_zero_results(limit: int = Query(50, ge=1, le=200)):
331 + """Requêtes sans résultat : matière première des synonymes et des seeds."""
332 + rows = await db.top_zero_result_queries(limit)
333 + return {"queries": [
334 + {"query": r["query"], "count": r["n"], "last_seen": r["last_seen"].isoformat()}
335 + for r in rows
336 + ]}
337 +
338 +
248 339 @app.post("/api/admin/pause", dependencies=[Depends(require_admin)])
249 340 async def admin_pause():
250 341 await coord.pause_crawler()
modified docker-compose.yml +20 −5
@@ -8,6 +8,21 @@
8 8 services:
9 9 postgres:
10 10 image: postgres:16-alpine
11 + # max_connections : 5 workers locaux (pool 10) + API/enrichment/scheduler
12 + # + 6 satellites LAN (pool 6) dépassent les 100 par défaut.
13 + # Keepalives + timeouts : un worker satellite tué (pkill, reboot, sommeil
14 + # d'un MacBook) laisse sinon des transactions zombies qui tiennent des
15 + # verrous de ligne pendant des heures et bloquent crawl ET autovacuum.
16 + command: [
17 + "postgres",
18 + "-c", "max_connections=200",
19 + "-c", "shared_buffers=2GB",
20 + "-c", "tcp_keepalives_idle=60",
21 + "-c", "tcp_keepalives_interval=10",
22 + "-c", "tcp_keepalives_count=6",
23 + "-c", "idle_in_transaction_session_timeout=120000",
24 + "-c", "statement_timeout=300000",
25 + ]
11 26 environment:
12 27 POSTGRES_USER: trouveka
13 28 POSTGRES_PASSWORD: trouveka
@@ -65,12 +80,16 @@ services:
65 80 environment: &backend_env
66 81 DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka
67 82 REDIS_URL: redis://redis:6379/0
68 − SEARCH_URL: http://opensearch:9200
83 + # Surcharger via .env pour un OpenSearch externe (ex. M2M32b via relais
84 + # socat : http://host.docker.internal:9202). Défaut : le service local.
85 + SEARCH_URL: ${SEARCH_URL_BACKEND:-http://opensearch:9200}
69 86 ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token}
70 87 PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com}
71 88 MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24}
72 89 DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0}
73 90 MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000}
91 + # Service d'embeddings (recherche sémantique) — vide = lexical seul
92 + EMBEDDING_URL: ${EMBEDDING_URL:-}
74 93 ports:
75 94 - "8080:8080"
76 95 depends_on:
@@ -78,8 +97,6 @@ services:
78 97 condition: service_healthy
79 98 redis:
80 99 condition: service_healthy
81 − opensearch:
82 − condition: service_healthy
83 100 restart: unless-stopped
84 101
85 102 migrate:
@@ -102,8 +119,6 @@ services:
102 119 depends_on:
103 120 migrate:
104 121 condition: service_completed_successfully
105 − opensearch:
106 − condition: service_healthy
107 122 redis:
108 123 condition: service_healthy
109 124 restart: unless-stopped
modified infrastructure/deployment/deploy-m2m32.sh +9 −1
@@ -5,6 +5,12 @@
5 5 #
6 6 # Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié.
7 7 # Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok.
8 +#
9 +# ⚠️ OBSOLÈTE pour la prod depuis 2026-08-15 : M2M32 tourne en pm2 NATIF
10 +# (docker = Postgres+Redis seulement; OpenSearch sur M2M32b). Déployer par
11 +# rsync + pm2 restart tk-api tk-crawler-1 tk-crawler-2 tk-enrichment tk-scheduler
12 +# (+ pnpm build dans apps/web puis pm2 restart tk-web si le web change).
13 +# Voir la mémoire projet « trouve-ka-architecture-native ».
8 14 set -euo pipefail
9 15
10 16 NODE="${NODE:-M2M32}"
@@ -14,9 +20,11 @@ NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}"
14 20 cd "$(dirname "$0")/../.."
15 21
16 22 echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR"
23 +# .env est exclu : celui du laptop contient des ports de dev (5435/6380) qui
24 +# cassent l'exposition LAN 5432/6379 dont dépendent les workers satellites.
17 25 rsync -az --delete \
18 26 --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \
19 − --exclude .git --exclude data --exclude '*.egg-info' \
27 + --exclude .git --exclude data --exclude '*.egg-info' --exclude .env \
20 28 ./ "$NODE:$REMOTE_DIR/"
21 29
22 30 echo "→ Build et démarrage du stack sur $NODE"
added infrastructure/migrations/003_url_count_et_autovacuum.sql +28 −0
@@ -0,0 +1,28 @@
1 +-- Trouve-KA — migration 003 : cap réel d'URLs par domaine + autovacuum frontier
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +--
5 +-- 1) domains.url_count : le cap max_urls_per_domain comparait page_count
6 +-- (pages crawlées) au lieu du nombre d'URLs connues — un domaine pouvait
7 +-- accumuler 20 000+ URLs pending avant que le cap ne morde.
8 +-- 2) frontier_items churne énormément (claim/defer/release = UPDATE) : les
9 +-- réglages autovacuum par défaut (20 % de la table) laissent s'accumuler
10 +-- des centaines de milliers de tuples morts qui ralentissent le claim.
11 +
12 +BEGIN;
13 +
14 +ALTER TABLE domains ADD COLUMN IF NOT EXISTS url_count integer NOT NULL DEFAULT 0;
15 +
16 +UPDATE domains d SET url_count = c.n
17 +FROM (SELECT domain_id, count(*) AS n FROM urls GROUP BY domain_id) c
18 +WHERE c.domain_id = d.id;
19 +
20 +ALTER TABLE frontier_items SET (
21 + autovacuum_vacuum_scale_factor = 0.02,
22 + autovacuum_vacuum_cost_delay = 0,
23 + autovacuum_analyze_scale_factor = 0.05
24 +);
25 +
26 +INSERT INTO schema_migrations (version) VALUES (3) ON CONFLICT DO NOTHING;
27 +
28 +COMMIT;
added infrastructure/migrations/004_domains_autovacuum.sql +20 −0
@@ -0,0 +1,20 @@
1 +-- Trouve-KA — migration 004 : autovacuum agressif sur domains
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +--
5 +-- domains est une table chaude : url_count, page_count, quebec_score et
6 +-- language_stats sont mis à jour en continu sur un petit nombre de lignes
7 +-- très sollicitées. Les défauts d'autovacuum (20 %) laissent le bloat
8 +-- dégrader les upserts de découverte.
9 +
10 +BEGIN;
11 +
12 +ALTER TABLE domains SET (
13 + autovacuum_vacuum_scale_factor = 0.05,
14 + autovacuum_vacuum_cost_delay = 0,
15 + autovacuum_analyze_scale_factor = 0.1
16 +);
17 +
18 +INSERT INTO schema_migrations (version) VALUES (4) ON CONFLICT DO NOTHING;
19 +
20 +COMMIT;
modified packages/config/__init__.py +7 −1
@@ -34,7 +34,9 @@ class Settings(BaseSettings):
34 34 # Crawler — limites de sécurité (chaque réponse a des limites)
35 35 max_response_bytes: int = 3_000_000
36 36 max_redirects: int = 5
37 − fetch_timeout: float = 20.0
37 + # 10 s suffisent pour un site sain; 20 s laissait les hôtes morts occuper
38 + # les slots de concurrence et effondrer le débit global.
39 + fetch_timeout: float = 10.0
38 40 max_crawl_depth: int = 8
39 41 max_links_per_page: int = 300
40 42 max_urls_per_domain: int = 5_000
@@ -50,6 +52,10 @@ class Settings(BaseSettings):
50 52 min_quebec_score_to_index: float = 0.15
51 53 min_body_length: int = 80
52 54
55 + # Recherche sémantique (optionnelle — vide = lexical seul, dégradation §13)
56 + embedding_url: str | None = None
57 + embedding_dim: int = 384
58 +
53 59 # API
54 60 api_host: str = "0.0.0.0"
55 61 api_port: int = 8080
modified packages/database/db.py +149 −10
@@ -147,11 +147,13 @@ class Database:
147 147 is_seed,
148 148 )
149 149 dom = await conn.fetchrow(
150 − "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id
150 + "SELECT blocked, url_count FROM domains WHERE id = $1", domain_id
151 151 )
152 152 if dom["blocked"]:
153 153 return None
154 − if not is_seed and dom["page_count"] >= max_urls_per_domain:
154 + # Cap sur les URLs CONNUES du domaine (pas les pages crawlées :
155 + # page_count laissait un domaine enfiler 20 000+ URLs pending).
156 + if not is_seed and dom["url_count"] >= max_urls_per_domain:
155 157 return None
156 158 url_id = await conn.fetchval(
157 159 """
@@ -163,6 +165,9 @@ class Database:
163 165 )
164 166 if url_id is None:
165 167 return None # URL déjà connue : pas de doublon dans le frontier
168 + await conn.execute(
169 + "UPDATE domains SET url_count = url_count + 1 WHERE id = $1", domain_id
170 + )
166 171 await conn.execute(
167 172 """
168 173 INSERT INTO frontier_items (url_id, priority, depth, source_url_id)
@@ -175,20 +180,131 @@ class Database:
175 180 )
176 181 return url_id
177 182
183 + async def enqueue_urls_bulk(
184 + self,
185 + items: list[tuple[str, str, float]],
186 + *,
187 + depth: int,
188 + source_url_id: int | None,
189 + max_urls_per_domain: int = 5000,
190 + ) -> tuple[dict[str, int], int]:
191 + """Enfile toutes les URLs découvertes sur UNE page en une seule transaction.
192 +
193 + items : liste de (url, domain, priority). Retourne ({domain: id}, nb_insérées).
194 +
195 + Une page peut avoir 300 liens : la version unitaire ouvrait 300
196 + transactions qui verrouillaient chacune la ligne du domaine cible
197 + (upsert + url_count). Avec 11 workers découvrant les mêmes domaines
198 + populaires, Postgres passait son temps en attentes Lock:transactionid.
199 + Ici : 1 transaction, 1 verrou par domaine, ordre de verrouillage stable
200 + (tri par domaine puis par id) pour éviter les deadlocks croisés.
201 + """
202 + if not items:
203 + return {}, 0
204 + domains = sorted({d for _, d, _ in items})
205 + async with self.pool.acquire() as conn:
206 + async with conn.transaction():
207 + await conn.execute(
208 + """
209 + INSERT INTO domains (domain)
210 + SELECT unnest($1::text[]) ON CONFLICT (domain) DO NOTHING
211 + """,
212 + domains,
213 + )
214 + rows = await conn.fetch(
215 + "SELECT id, domain, blocked, url_count FROM domains WHERE domain = ANY($1::text[])",
216 + domains,
217 + )
218 + dom_info = {r["domain"]: r for r in rows}
219 + budget = {
220 + d: max(0, max_urls_per_domain - r["url_count"]) for d, r in dom_info.items()
221 + }
222 + urls: list[str] = []
223 + dids: list[int] = []
224 + prios: list[float] = []
225 + seen: set[str] = set()
226 + for url, d, p in items:
227 + info = dom_info.get(d)
228 + if info is None or info["blocked"] or url in seen or budget[d] <= 0:
229 + continue
230 + budget[d] -= 1
231 + seen.add(url)
232 + urls.append(url)
233 + dids.append(info["id"])
234 + prios.append(p)
235 + ids_map = {d: r["id"] for d, r in dom_info.items()}
236 + if not urls:
237 + return ids_map, 0
238 + new_rows = await conn.fetch(
239 + """
240 + INSERT INTO urls (url, domain_id)
241 + SELECT u, d FROM unnest($1::text[], $2::bigint[]) AS t(u, d)
242 + ON CONFLICT (url) DO NOTHING
243 + RETURNING id, url, domain_id
244 + """,
245 + urls,
246 + dids,
247 + )
248 + if not new_rows:
249 + return ids_map, 0
250 + prio_by_url = dict(zip(urls, prios))
251 + await conn.execute(
252 + """
253 + INSERT INTO frontier_items (url_id, priority, depth, source_url_id)
254 + SELECT i, p, $3, $4 FROM unnest($1::bigint[], $2::real[]) AS t(i, p)
255 + ON CONFLICT (url_id) DO NOTHING
256 + """,
257 + [r["id"] for r in new_rows],
258 + [prio_by_url[r["url"]] for r in new_rows],
259 + depth,
260 + source_url_id,
261 + )
262 + counts: dict[int, int] = {}
263 + for r in new_rows:
264 + counts[r["domain_id"]] = counts.get(r["domain_id"], 0) + 1
265 + ordered = sorted(counts.items())
266 + await conn.execute(
267 + """
268 + UPDATE domains d SET url_count = url_count + c.n
269 + FROM unnest($1::bigint[], $2::int[]) AS c(id, n)
270 + WHERE d.id = c.id
271 + """,
272 + [i for i, _ in ordered],
273 + [n for _, n in ordered],
274 + )
275 + return ids_map, len(new_rows)
276 +
178 277 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)."""
278 + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).
279 +
280 + Équité par domaine : au plus UNE URL par domaine par lot. Sans cette
281 + contrainte, les gros domaines à haute priorité monopolisent le claim,
282 + puis leurs URLs sont différées en boucle par la politesse par hôte
283 + (churn massif d'UPDATE sur frontier_items) pendant que les 25 000+
284 + autres domaines pending ne sont jamais explorés.
285 + """
180 286 return await self.pool.fetch(
181 287 """
182 − WITH claimed AS (
183 − SELECT f.id FROM frontier_items f
288 + WITH candidates AS (
289 + SELECT f.id, f.priority, f.next_crawl_at, u.domain_id
290 + FROM frontier_items f
291 + JOIN urls u ON u.id = f.url_id
184 292 WHERE f.status = 'pending' AND f.next_crawl_at <= now()
185 293 ORDER BY f.priority DESC, f.next_crawl_at
186 − LIMIT $2
187 − FOR UPDATE SKIP LOCKED
294 + LIMIT $3
295 + FOR UPDATE OF f SKIP LOCKED
296 + ),
297 + picked AS (
298 + SELECT DISTINCT ON (domain_id) id, priority, next_crawl_at
299 + FROM candidates
300 + ORDER BY domain_id, priority DESC, next_crawl_at
301 + ),
302 + chosen AS (
303 + SELECT id FROM picked ORDER BY priority DESC, next_crawl_at LIMIT $2
188 304 )
189 305 UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now()
190 − FROM claimed
191 − WHERE f.id = claimed.id
306 + FROM chosen
307 + WHERE f.id = chosen.id
192 308 RETURNING f.id, f.url_id, f.priority, f.depth, f.retries,
193 309 f.last_crawled_at, f.next_crawl_at,
194 310 (SELECT url FROM urls WHERE id = f.url_id) AS url,
@@ -196,6 +312,9 @@ class Database:
196 312 """,
197 313 worker_id,
198 314 limit,
315 + # Pool de candidats borné : assez profond pour la diversité de
316 + # domaines, sans verrouiller 1000 lignes par claim à concurrence élevée.
317 + min(max(limit * 10, 200), 600),
199 318 )
200 319
201 320 async def release_item(
@@ -207,13 +326,17 @@ class Database:
207 326 next_crawl_at: datetime | None = None,
208 327 priority: float | None = None,
209 328 increment_retries: bool = False,
329 + touch_last_crawled: bool = True,
210 330 ) -> None:
331 + """touch_last_crawled=False pour les defers de politesse : sinon le
332 + recrawl adaptatif mesure l'écart du defer (~secondes) au lieu du vrai
333 + intervalle entre deux crawls, et le backoff s'effondre au minimum."""
211 334 await self.pool.execute(
212 335 """
213 336 UPDATE frontier_items SET
214 337 status = $2,
215 338 error_code = $3,
216 − last_crawled_at = now(),
339 + last_crawled_at = CASE WHEN $7 THEN now() ELSE last_crawled_at END,
217 340 next_crawl_at = COALESCE($4, next_crawl_at),
218 341 priority = COALESCE($5, priority),
219 342 retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END,
@@ -226,6 +349,7 @@ class Database:
226 349 next_crawl_at,
227 350 priority,
228 351 increment_retries,
352 + touch_last_crawled,
229 353 )
230 354
231 355 async def reset_stale_items(self, older_than_minutes: int = 30) -> int:
@@ -374,6 +498,21 @@ class Database:
374 498 took_ms,
375 499 )
376 500
501 + async def top_zero_result_queries(self, limit: int = 50) -> list[asyncpg.Record]:
502 + """Requêtes zéro-résultat les plus fréquentes (§12 : « de l'or ») —
503 + elles pilotent les seeds, les synonymes et les priorités de crawl."""
504 + return await self.pool.fetch(
505 + """
506 + SELECT lower(query) AS query, count(*) AS n, max(created_at) AS last_seen
507 + FROM search_queries
508 + WHERE zero_result
509 + GROUP BY lower(query)
510 + ORDER BY n DESC, last_seen DESC
511 + LIMIT $1
512 + """,
513 + limit,
514 + )
515 +
377 516 async def add_submission(self, url: str) -> None:
378 517 await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url)
379 518
modified packages/search-core/__init__.py +3 −2
@@ -5,6 +5,7 @@
5 5 """Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate."""
6 6
7 7 from .client import SearchCore
8 −from .mapping import INDEX_SETTINGS
8 +from .embed_client import EmbedClient
9 +from .mapping import EMBEDDING_DIM, INDEX_SETTINGS
9 10
10 −__all__ = ["SearchCore", "INDEX_SETTINGS"]
11 +__all__ = ["SearchCore", "EmbedClient", "EMBEDDING_DIM", "INDEX_SETTINGS"]
modified packages/search-core/client.py +49 −0
@@ -52,6 +52,39 @@ class SearchCore:
52 52 async def search(self, body: dict[str, Any]) -> dict[str, Any]:
53 53 return await self.client.search(index=self.index, body=body)
54 54
55 + async def get_source(self, url: str, fields: list[str]) -> dict[str, Any] | None:
56 + """_source partiel d'un document (pour l'enrichissement), ou None."""
57 + try:
58 + res = await self.client.get(
59 + index=self.index, id=doc_id_for_url(url), _source_includes=fields
60 + )
61 + return res.get("_source")
62 + except NotFoundError:
63 + return None
64 +
65 + async def missing_embeddings(self, size: int = 128) -> list[tuple[str, dict[str, Any]]]:
66 + """Documents sans embedding (backfill sémantique en arrière-plan)."""
67 + res = await self.client.search(
68 + index=self.index,
69 + body={
70 + "size": size,
71 + "query": {"bool": {"must_not": [{"exists": {"field": "embedding"}}]}},
72 + "_source": ["title", "description", "body"],
73 + "sort": [{"crawled_at": {"order": "desc", "missing": "_last", "unmapped_type": "date"}}],
74 + },
75 + )
76 + return [(hit["_id"], hit["_source"]) for hit in res["hits"]["hits"]]
77 +
78 + async def set_embeddings(self, pairs: list[tuple[str, list[float]]]) -> None:
79 + """Écrit les embeddings en bulk (mise à jour partielle, jamais bloquante)."""
80 + if not pairs:
81 + return
82 + lines: list[dict[str, Any]] = []
83 + for _id, vector in pairs:
84 + lines.append({"update": {"_index": self.index, "_id": _id}})
85 + lines.append({"doc": {"embedding": vector}})
86 + await self.client.bulk(body=lines)
87 +
55 88 async def count(self) -> int:
56 89 try:
57 90 res = await self.client.count(index=self.index)
@@ -59,6 +92,22 @@ class SearchCore:
59 92 except NotFoundError:
60 93 return 0
61 94
95 + async def embedding_coverage(self) -> float:
96 + """Fraction des documents ayant un embedding ∈ [0,1].
97 +
98 + Sert de garde-fou : tant que le backfill n'a pas couvert l'index,
99 + le kNN chercherait ses voisins dans un sous-ensemble arbitraire et
100 + polluerait la fusion — mieux vaut du lexical pur (§13)."""
101 + try:
102 + total = await self.client.count(index=self.index)
103 + with_vec = await self.client.count(
104 + index=self.index,
105 + body={"query": {"exists": {"field": "embedding"}}},
106 + )
107 + return (with_vec["count"] / total["count"]) if total["count"] else 0.0
108 + except Exception:
109 + return 0.0
110 +
62 111 async def ping(self) -> bool:
63 112 try:
64 113 return await self.client.ping()
added packages/search-core/embed_client.py +76 −0
@@ -0,0 +1,76 @@
1 +# Trouve-KA — client du service d'embeddings
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Client HTTP du service d'embeddings — conçu pour la dégradation gracieuse (§13).
6 +
7 +Ne lève JAMAIS : toute erreur (service absent, timeout, réponse invalide)
8 +retourne None et le chemin lexical continue seul. Timeout requête court pour
9 +ne pas pénaliser la latence de recherche; timeout passage plus généreux pour
10 +l'enrichissement en arrière-plan.
11 +"""
12 +
13 +import httpx
14 +
15 +
16 +class EmbedClient:
17 + def __init__(
18 + self,
19 + base_url: str | None,
20 + *,
21 + query_timeout: float = 0.25,
22 + passage_timeout: float = 30.0,
23 + rerank_timeout: float = 0.6,
24 + ):
25 + self.base_url = (base_url or "").rstrip("/") or None
26 + self._query_client = httpx.AsyncClient(timeout=query_timeout) if self.base_url else None
27 + self._passage_client = httpx.AsyncClient(timeout=passage_timeout) if self.base_url else None
28 + self._rerank_client = httpx.AsyncClient(timeout=rerank_timeout) if self.base_url else None
29 +
30 + @property
31 + def enabled(self) -> bool:
32 + return self.base_url is not None
33 +
34 + async def close(self) -> None:
35 + for c in (self._query_client, self._passage_client, self._rerank_client):
36 + if c is not None:
37 + await c.aclose()
38 +
39 + async def rerank(self, query: str, texts: list[str]) -> list[float] | None:
40 + """Scores cross-encoder pour le top-N, ou None (l'ordre RRF est conservé)."""
41 + if not self.base_url or self._rerank_client is None or not texts:
42 + return None
43 + try:
44 + resp = await self._rerank_client.post(
45 + f"{self.base_url}/rerank", json={"query": query, "texts": texts}
46 + )
47 + resp.raise_for_status()
48 + scores = resp.json()["scores"]
49 + if not isinstance(scores, list) or len(scores) != len(texts):
50 + return None
51 + return [float(s) for s in scores]
52 + except Exception:
53 + return None
54 +
55 + async def embed_query(self, text: str) -> list[float] | None:
56 + """Vecteur de la requête utilisateur, ou None (fallback lexical)."""
57 + return await self._embed([text], kind="query", client=self._query_client, single=True)
58 +
59 + async def embed_passages(self, texts: list[str]) -> list[list[float]] | None:
60 + """Vecteurs de passages (enrichissement asynchrone), ou None."""
61 + if not texts:
62 + return []
63 + return await self._embed(texts, kind="passage", client=self._passage_client, single=False)
64 +
65 + async def _embed(self, texts: list[str], *, kind: str, client: httpx.AsyncClient | None, single: bool):
66 + if not self.base_url or client is None:
67 + return None
68 + try:
69 + resp = await client.post(f"{self.base_url}/embed", json={"texts": texts, "kind": kind})
70 + resp.raise_for_status()
71 + vectors = resp.json()["vectors"]
72 + if not isinstance(vectors, list) or len(vectors) != len(texts):
73 + return None
74 + return vectors[0] if single else vectors
75 + except Exception:
76 + return None
modified packages/search-core/mapping.py +67 −10
@@ -10,32 +10,79 @@
10 10 - Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal).
11 11 """
12 12
13 −# Synonymes bilingues de départ — extensibles sans réindexation (search-time)
13 +# Synonymes bilingues et québécismes — search-time : modifiables sans réindexer
14 +# (fermer/rouvrir l'index suffit, voir scripts/update-synonyms). Nourrir cette
15 +# liste avec les requêtes zéro-résultat (§12 : « de l'or »).
14 16 BILINGUAL_SYNONYMS = [
15 17 "thermopompe, heat pump",
16 18 "subvention, grant, subsidy",
17 19 "emploi, job, employment",
18 − "entreprise, company, business",
20 + "entreprise, company, business, compagnie",
19 21 "université, university",
20 − "cégep, cegep, college",
21 − "garderie, daycare, cpe",
22 − "impôt, tax, taxes",
22 + "cégep, cegep, college, collège",
23 + "garderie, daycare, cpe, service de garde",
24 + "impôt, tax, taxes, impôts",
23 25 "santé, health",
24 − "plombier, plumber",
26 + "plombier, plumber, plomberie, plumbing",
25 27 "électricien, electrician",
26 − "déneigement, snow removal",
28 + "déneigement, snow removal, déneigeur",
27 29 "assurance, insurance",
28 − "logement, housing, apartment",
30 + "logement, housing, apartment, appartement",
29 31 "ville, city, municipalité, municipality",
30 32 "gouvernement, government",
31 33 "permis, permit, license, licence",
34 + # Institutions et programmes québécois
35 + "saaq, société de l'assurance automobile",
36 + "ramq, régie de l'assurance maladie",
37 + "cnesst, normes du travail",
38 + "reer, rrsp",
39 + "celi, tfsa",
40 + "rqap, congé parental, parental leave",
41 + "assurance-emploi, chômage, employment insurance",
42 + "aide sociale, welfare, assistance sociale",
43 + "hydro, hydro-québec",
44 + # Vie courante QC
45 + "dépanneur, convenience store",
46 + "souffleuse, snowblower, snow blower",
47 + "stationnement, parking",
48 + "condo, condominium",
49 + "chalet, cottage",
50 + "vidanges, ordures, garbage, déchets",
51 + "urgence, emergency",
52 + "médecin de famille, family doctor, omnipraticien",
53 + "clsc, clinique",
54 + "école, school",
55 + "métro, subway, stm",
56 + "autobus, bus",
57 + "traversier, ferry",
58 + "pourvoirie, outfitter",
59 + "érablière, cabane à sucre, sugar shack",
60 + "friperie, thrift store",
61 + "rénovation, renovation, réno",
62 + "toiture, roofing, couvreur",
63 + "arpenteur, land surveyor",
64 + "notaire, notary",
65 + "comptable, accountant, cpa",
66 + "immobilier, real estate, courtier immobilier",
67 + "loyer, rent, bail, lease",
68 + "vaccin, vaccination, vaccine",
69 + "festival, festivals",
70 + "randonnée, hiking, sentier, trail",
71 + "ski de fond, cross-country ski",
72 + "motoneige, snowmobile, skidoo",
73 + "vélo, bike, bicycle, piste cyclable",
32 74 ]
33 75
76 +# Dimension des embeddings (multilingual-e5-small). Changer de modèle de
77 +# dimension différente = nouvel index (migration scripts/migrate-search-index).
78 +EMBEDDING_DIM = 384
79 +
34 80 INDEX_SETTINGS = {
35 81 "settings": {
36 82 "number_of_shards": 1,
37 83 "number_of_replicas": 0,
38 84 "refresh_interval": "1s",
85 + "index.knn": True,
39 86 "analysis": {
40 87 "filter": {
41 88 "french_elision": {
@@ -118,8 +165,18 @@ INDEX_SETTINGS = {
118 165 "freshness_score": {"type": "float"},
119 166 "quality_score": {"type": "float"},
120 167 "spam_score": {"type": "float"},
121 − # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7),
122 − # le mapping est extensible sans réindexation pour un nouveau champ.
168 + # Recherche sémantique : vecteur e5-small normalisé (cosine).
169 + # Rempli en ASYNCHRONE par l'enrichissement — jamais au crawl (§0.3).
170 + "embedding": {
171 + "type": "knn_vector",
172 + "dimension": EMBEDDING_DIM,
173 + "method": {
174 + "name": "hnsw",
175 + "engine": "lucene",
176 + "space_type": "cosinesimil",
177 + "parameters": {"m": 16, "ef_construction": 128},
178 + },
179 + },
123 180 }
124 181 },
125 182 }
modified packages/shared/__init__.py +3 −0
@@ -7,12 +7,15 @@
7 7 from .urls import canonicalize_url, display_url, extract_domain, is_http_url
8 8 from .ssrf import is_safe_url, is_safe_ip
9 9 from .hashing import content_hash, text_fingerprint
10 +from .exclusions import EXCLUDED_DOMAINS, is_excluded_domain
10 11
11 12 __all__ = [
12 13 "canonicalize_url",
13 14 "display_url",
14 15 "extract_domain",
15 16 "is_http_url",
17 + "EXCLUDED_DOMAINS",
18 + "is_excluded_domain",
16 19 "is_safe_url",
17 20 "is_safe_ip",
18 21 "content_hash",
added packages/shared/exclusions.py +50 −0
@@ -0,0 +1,50 @@
1 +# Trouve-KA — exclusions de crawl
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Domaines jamais crawlés : plateformes globales (réseaux sociaux, app stores,
6 +CDN, moteurs) qui polluent le frontier sans jamais produire de contenu québécois
7 +indexable — la plupart exigent JS/login et leurs pages sont vides en fetch HTTP.
8 +
9 +Le budget de crawl est précieux (§12) : chaque slot dépensé sur apps.apple.com
10 +est un slot volé à un vrai site québécois.
11 +"""
12 +
13 +# Comparaison sur le domaine enregistrable ET ses sous-domaines.
14 +EXCLUDED_DOMAINS: frozenset[str] = frozenset({
15 + # Réseaux sociaux
16 + "facebook.com", "fb.com", "instagram.com", "twitter.com", "x.com",
17 + "linkedin.com", "pinterest.com", "pinterest.ca", "tiktok.com",
18 + "snapchat.com", "reddit.com", "threads.net", "bsky.app", "mastodon.social",
19 + # Vidéo / streaming
20 + "youtube.com", "youtu.be", "vimeo.com", "dailymotion.com",
21 + "twitch.tv", "spotify.com", "soundcloud.com",
22 + # App stores / OS
23 + "apps.apple.com", "itunes.apple.com", "apple.com",
24 + "play.google.com", "microsoft.com", "chrome.google.com",
25 + # Moteurs / géants génériques
26 + "google.com", "google.ca", "bing.com", "yahoo.com", "duckduckgo.com",
27 + "amazon.com", "amazon.ca", "ebay.com", "ebay.ca", "aliexpress.com",
28 + "wikimedia.org", "wikidata.org", "archive.org", # wikipedia.org reste crawlable (contenu QC réel)
29 + # Infra / outils
30 + "d2cmedia.ca", # CDN d'images de concessionnaires — des milliers d'URLs, zéro texte
31 + "cloudflare.com", "wordpress.com", "wordpress.org", "blogspot.com",
32 + "wix.com", "squarespace.com", "shopify.com", "godaddy.com",
33 + "goo.gl", "bit.ly", "t.co", "ow.ly", "tinyurl.com",
34 + "gravatar.com", "addthis.com", "sharethis.com",
35 + "doubleclick.net", "googletagmanager.com", "google-analytics.com",
36 + # Cartes / avis génériques
37 + "maps.google.com", "waze.com", "tripadvisor.com", "tripadvisor.ca",
38 + "yelp.com", "yelp.ca", "booking.com", "airbnb.com", "airbnb.ca",
39 + "eventbrite.com", "eventbrite.ca", "meetup.com",
40 +})
41 +
42 +
43 +def is_excluded_domain(domain: str) -> bool:
44 + """True si le domaine (ou un de ses parents) est dans la liste d'exclusion."""
45 + d = domain.lower().rstrip(".")
46 + while d:
47 + if d in EXCLUDED_DOMAINS:
48 + return True
49 + _, _, d = d.partition(".")
50 + return False
modified pyproject.toml +5 −0
@@ -30,6 +30,11 @@ dev = [
30 30 "pytest-asyncio>=0.23",
31 31 "ruff>=0.5",
32 32 ]
33 +# Service d'embeddings uniquement (venv dédié sur un node satellite;
34 +# torch n'entre PAS dans l'image Docker principale)
35 +embedding = [
36 + "sentence-transformers>=3.0",
37 +]
33 38
34 39 [build-system]
35 40 requires = ["setuptools>=69"]
added scripts/migrate-search-index/migrate.py +77 −0
@@ -0,0 +1,77 @@
1 +# Trouve-KA — migration de l'index de recherche vers le mapping kNN
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Migre l'index vers le mapping v2 (champ embedding kNN).
6 +
7 +`index.knn` est un réglage statique OpenSearch : impossible de l'activer sur un
8 +index existant. Procédure (idempotente) :
9 +
10 +1. crawl en pause (les workers respectent trouveka:crawler:paused);
11 +2. création de <index>-v2 avec le mapping kNN;
12 +3. _reindex serveur <index> → <index>-v2;
13 +4. suppression de l'ancien index, alias <index> → <index>-v2
14 + (le code continue de parler à <index> sans changement);
15 +5. reprise du crawl.
16 +
17 +Usage : python -m scripts.migrate-search-index.migrate (ou via docker compose run)
18 +"""
19 +
20 +import asyncio
21 +
22 +from opensearchpy import AsyncOpenSearch
23 +
24 +from trouveka.config import get_settings
25 +from trouveka.queue import Coordination
26 +from trouveka.search_core import INDEX_SETTINGS
27 +
28 +
29 +async def main() -> None:
30 + s = get_settings()
31 + client = AsyncOpenSearch(hosts=[s.search_url], timeout=600)
32 + coord = Coordination(s.redis_url)
33 + index, v2 = s.search_index, f"{s.search_index}-v2"
34 +
35 + try:
36 + if await client.indices.exists_alias(name=index):
37 + print(f"✓ {index} est déjà un alias — migration déjà faite")
38 + return
39 + if not await client.indices.exists(index=index):
40 + await client.indices.create(index=v2, body=INDEX_SETTINGS)
41 + await client.indices.put_alias(index=v2, name=index)
42 + print(f"✓ installation neuve : {v2} créé avec alias {index}")
43 + return
44 +
45 + print("→ pause du crawl")
46 + await coord.pause_crawler()
47 + await asyncio.sleep(5) # laisser les indexations en vol se terminer
48 +
49 + if not await client.indices.exists(index=v2):
50 + await client.indices.create(index=v2, body=INDEX_SETTINGS)
51 + print(f"→ {v2} créé")
52 +
53 + print(f"→ _reindex {index} → {v2} (serveur, peut prendre quelques minutes)")
54 + result = await client.reindex(
55 + body={"source": {"index": index}, "dest": {"index": v2}},
56 + params={"wait_for_completion": "true", "refresh": "true"},
57 + )
58 + print(f"→ réindexé : {result.get('total')} docs, {result.get('took')} ms")
59 +
60 + old_count = (await client.count(index=index))["count"]
61 + new_count = (await client.count(index=v2))["count"]
62 + print(f"→ comptes : ancien={old_count} nouveau={new_count}")
63 + if new_count < old_count * 0.99:
64 + raise RuntimeError("perte de documents détectée, on ne bascule pas")
65 +
66 + await client.indices.delete(index=index)
67 + await client.indices.put_alias(index=v2, name=index)
68 + print(f"✓ alias {index} → {v2} en place")
69 + finally:
70 + await coord.resume_crawler()
71 + print("→ crawl repris")
72 + await coord.close()
73 + await client.close()
74 +
75 +
76 +if __name__ == "__main__":
77 + asyncio.run(main())
added scripts/update-synonyms/update.py +47 −0
@@ -0,0 +1,47 @@
1 +# Trouve-KA — mise à jour à chaud des synonymes de recherche
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Applique le dictionnaire BILINGUAL_SYNONYMS courant à l'index live.
6 +
7 +Les synonymes sont search-time : pas de réindexation nécessaire, mais les
8 +analyzers font partie des settings → close/open de l'index (indisponibilité
9 +de ~1-2 s, les workers retentent d'eux-mêmes).
10 +
11 +Usage : python scripts/update-synonyms/update.py
12 +"""
13 +
14 +import asyncio
15 +
16 +from opensearchpy import AsyncOpenSearch
17 +
18 +from trouveka.config import get_settings
19 +from trouveka.search_core import INDEX_SETTINGS
20 +
21 +
22 +async def main() -> None:
23 + s = get_settings()
24 + client = AsyncOpenSearch(hosts=[s.search_url], timeout=120)
25 + try:
26 + # Résoudre l'alias vers l'index concret
27 + aliases = await client.indices.get_alias(name=s.search_index)
28 + concrete = next(iter(aliases))
29 + analysis = {"analysis": INDEX_SETTINGS["settings"]["analysis"]}
30 +
31 + print(f"→ fermeture de {concrete}")
32 + await client.indices.close(index=concrete)
33 + try:
34 + await client.indices.put_settings(index=concrete, body=analysis)
35 + print("→ analyzers/synonymes mis à jour")
36 + finally:
37 + await client.indices.open(index=concrete)
38 + print(f"→ {concrete} rouvert")
39 + await client.cluster.health(index=concrete, wait_for_status="yellow", timeout=60)
40 + n = len(INDEX_SETTINGS["settings"]["analysis"]["filter"]["bilingual_synonyms"]["synonyms"])
41 + print(f"✓ {n} groupes de synonymes actifs")
42 + finally:
43 + await client.close()
44 +
45 +
46 +if __name__ == "__main__":
47 + asyncio.run(main())
modified services/crawler/worker.py +47 −24
@@ -28,7 +28,7 @@ from trouveka.logging import get_logger
28 28 from trouveka.parser import looks_like_garbage, parse_html
29 29 from trouveka.queue import Coordination
30 30 from trouveka.search_core import SearchCore
31 −from trouveka.shared import canonicalize_url, content_hash, extract_domain
31 +from trouveka.shared import canonicalize_url, content_hash, extract_domain, is_excluded_domain
32 32 from trouveka.types import ErrorCode, Outcome
33 33
34 34 from .fetcher import Fetcher, scheme_host
@@ -94,14 +94,23 @@ class CrawlerWorker:
94 94 )
95 95
96 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)
97 + try:
98 + if await self.coord.is_paused():
99 + await asyncio.sleep(3)
100 + continue
101 + free = self.s.max_global_concurrency - len(in_flight)
102 + if free <= 0:
103 + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED)
104 + continue
105 + batch = await self.db.claim_batch(self.worker_id, limit=free)
106 + except Exception:
107 + # Dégradation gracieuse (§13) : un Postgres/Redis qui redémarre ne
108 + # doit pas tuer le worker — le pool asyncpg se répare au prochain
109 + # acquire, on attend et on réessaie.
110 + log.exception("claim/coordination indisponible, retry dans 5 s",
111 + extra={"ctx": {"worker_id": self.worker_id}})
112 + await asyncio.sleep(5)
103 113 continue
104 − batch = await self.db.claim_batch(self.worker_id, limit=free)
105 114 if not batch:
106 115 if in_flight:
107 116 await asyncio.wait(
@@ -151,6 +160,7 @@ class CrawlerWorker:
151 160 url_id,
152 161 status="pending",
153 162 next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)),
163 + touch_last_crawled=False, # un defer n'est pas un crawl : ne pas casser le backoff adaptatif
154 164 )
155 165
156 166 async def _domain_row_by_name(self, domain: str):
@@ -223,7 +233,7 @@ class CrawlerWorker:
223 233 final = canonicalize_url(result.final_url) or result.final_url
224 234 if final != url:
225 235 final_domain = extract_domain(final)
226 − if final_domain and not looks_like_trap(final):
236 + if final_domain and not is_excluded_domain(final_domain) and not looks_like_trap(final):
227 237 await self.db.enqueue_url(
228 238 final, final_domain, priority=item["priority"], depth=item["depth"],
229 239 source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain,
@@ -329,11 +339,18 @@ class CrawlerWorker:
329 339 outcome=outcome, content_hash=chash, num_bytes=len(result.body),
330 340 duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score,
331 341 )
342 + # changed=True seulement si la page a été (ré)indexée : une page non
343 + # indexable qui divisait son intervalle par 2 finissait recrawlée
344 + # toutes les heures pour rien.
332 345 recrawl = next_recrawl_delay(
333 − changed=True, previous_delay_hours=self._previous_delay_hours(item),
346 + changed=(outcome == Outcome.INDEXED),
347 + previous_delay_hours=self._previous_delay_hours(item),
334 348 min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours,
335 349 default_hours=self.s.default_recrawl_hours,
336 350 )
351 + if outcome == Outcome.NOT_QUEBEC:
352 + # Non pertinent Québec : on revérifie rarement (le score du domaine évolue)
353 + recrawl = max(recrawl, timedelta(days=7))
337 354 await self.db.release_item(
338 355 url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl,
339 356 )
@@ -361,9 +378,13 @@ class CrawlerWorker:
361 378 outcome=Outcome.UNCHANGED,
362 379 content_hash=existing["content_hash"] if existing else None,
363 380 )
381 + # Décroissance de priorité : une page stable cède sa place aux URLs
382 + # jamais crawlées — sinon les recrawls des gros domaines monopolisent
383 + # éternellement la tête du frontier (exploration > re-vérification).
364 384 await self.db.release_item(
365 385 item["url_id"], status="pending",
366 386 next_crawl_at=datetime.now(UTC) + recrawl,
387 + priority=max(0.05, float(item["priority"]) * 0.9),
367 388 )
368 389
369 390 async def _finish_error(self, item, result) -> None:
@@ -392,8 +413,8 @@ class CrawlerWorker:
392 413 source_domain = domain_row["domain"]
393 414 source_quebec = max(float(domain_row["quebec_score"]), signals.score)
394 415
395 − outlink_domains: dict[int, int] = {}
396 − enqueued = 0
416 + candidates: list[tuple[str, str, float]] = []
417 + cross_domain_counts: dict[str, int] = {}
397 418 for link in page.links:
398 419 if link.nofollow or looks_like_trap(
399 420 link.url,
@@ -402,7 +423,7 @@ class CrawlerWorker:
402 423 ):
403 424 continue
404 425 target_domain = extract_domain(link.url)
405 − if not target_domain:
426 + if not target_domain or is_excluded_domain(target_domain):
406 427 continue
407 428 same_domain = target_domain == source_domain
408 429 target_row = await self._domain_row_by_name(target_domain)
@@ -423,18 +444,20 @@ class CrawlerWorker:
423 444 # Économie de crawl : ignorer les cibles au signal Québec quasi nul
424 445 if priority < 0.1:
425 446 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
447 + candidates.append((link.url, target_domain, priority))
433 448 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 −
449 + cross_domain_counts[target_domain] = cross_domain_counts.get(target_domain, 0) + 1
450 +
451 + # Toute la page en UNE transaction (verrous domains minimisés)
452 + ids_map, _enqueued = await self.db.enqueue_urls_bulk(
453 + candidates,
454 + depth=item["depth"] + 1,
455 + source_url_id=item["url_id"],
456 + max_urls_per_domain=self.s.max_urls_per_domain,
457 + )
458 + outlink_domains = {
459 + ids_map[d]: n for d, n in cross_domain_counts.items() if d in ids_map
460 + }
438 461 if outlink_domains:
439 462 await self.db.record_domain_links(item["domain_id"], outlink_domains)
440 463
added services/embedding/__init__.py +8 −0
@@ -0,0 +1,8 @@
1 +# Trouve-KA — service d'embeddings
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Service d'embeddings multilingues (FR/EN alignés) pour la recherche sémantique.
6 +
7 +Tourne hors du chemin critique : l'indexation lexicale n'en dépend jamais (§0.3).
8 +"""
added services/embedding/server.py +106 −0
@@ -0,0 +1,106 @@
1 +# Trouve-KA — serveur d'embeddings
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Serveur HTTP d'embeddings multilingues.
6 +
7 +Modèle : intfloat/multilingual-e5-small (384 dims) — espace vectoriel aligné
8 +FR/EN : « thermopompe » et « heat pump » sont voisins sans traduction (§9).
9 +e5 exige les préfixes "query: " / "passage: " — le paramètre `kind` les gère.
10 +
11 +Volontairement autonome (fastapi + sentence-transformers seulement) : il tourne
12 +dans son propre venv sur un node satellite, PAS dans l'image Docker principale
13 +(torch pèse ~1 Go et n'a rien à faire dans le chemin de crawl).
14 +
15 +Lancement : uvicorn services.embedding.server:app --host 0.0.0.0 --port 8091
16 +"""
17 +
18 +import os
19 +import threading
20 +from typing import Literal
21 +
22 +from fastapi import FastAPI
23 +from pydantic import BaseModel, Field
24 +
25 +MODEL_NAME = os.environ.get("EMBEDDING_MODEL", "intfloat/multilingual-e5-small")
26 +RERANK_MODEL_NAME = os.environ.get(
27 + "RERANK_MODEL", "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
28 +)
29 +MAX_TEXTS = int(os.environ.get("EMBEDDING_MAX_TEXTS", "256"))
30 +MAX_CHARS = int(os.environ.get("EMBEDDING_MAX_CHARS", "1200"))
31 +
32 +app = FastAPI(title="Trouve-KA Embeddings", docs_url=None, redoc_url=None)
33 +
34 +_model = None
35 +_reranker = None
36 +_model_lock = threading.Lock()
37 +
38 +
39 +def _get_model():
40 + global _model
41 + if _model is None:
42 + with _model_lock:
43 + if _model is None:
44 + from sentence_transformers import SentenceTransformer
45 +
46 + _model = SentenceTransformer(MODEL_NAME, device="cpu")
47 + return _model
48 +
49 +
50 +def _get_reranker():
51 + global _reranker
52 + if _reranker is None:
53 + with _model_lock:
54 + if _reranker is None:
55 + from sentence_transformers import CrossEncoder
56 +
57 + _reranker = CrossEncoder(RERANK_MODEL_NAME, device="cpu")
58 + return _reranker
59 +
60 +
61 +class EmbedRequest(BaseModel):
62 + texts: list[str] = Field(..., min_length=1, max_length=MAX_TEXTS)
63 + kind: Literal["query", "passage"] = "passage"
64 +
65 +
66 +class EmbedResponse(BaseModel):
67 + vectors: list[list[float]]
68 + dim: int
69 + model: str
70 +
71 +
72 +@app.get("/health")
73 +def health() -> dict:
74 + return {"ok": True, "model": MODEL_NAME, "loaded": _model is not None}
75 +
76 +
77 +@app.post("/embed", response_model=EmbedResponse)
78 +def embed(req: EmbedRequest) -> EmbedResponse:
79 + model = _get_model()
80 + prefix = "query: " if req.kind == "query" else "passage: "
81 + texts = [prefix + t[:MAX_CHARS] for t in req.texts]
82 + vectors = model.encode(texts, normalize_embeddings=True, batch_size=64)
83 + return EmbedResponse(vectors=vectors.tolist(), dim=len(vectors[0]), model=MODEL_NAME)
84 +
85 +
86 +class RerankRequest(BaseModel):
87 + query: str = Field(..., min_length=1, max_length=300)
88 + texts: list[str] = Field(..., min_length=1, max_length=64)
89 +
90 +
91 +class RerankResponse(BaseModel):
92 + scores: list[float]
93 + model: str
94 +
95 +
96 +@app.post("/rerank", response_model=RerankResponse)
97 +def rerank(req: RerankRequest) -> RerankResponse:
98 + """Cross-encoder (requête, passage) → score de pertinence fin.
99 +
100 + Réservé au top-N (coût quadratique en attention) : l'API n'envoie que ~20
101 + candidats déjà filtrés par BM25+kNN.
102 + """
103 + model = _get_reranker()
104 + pairs = [(req.query, t[:MAX_CHARS]) for t in req.texts]
105 + scores = model.predict(pairs, batch_size=32)
106 + return RerankResponse(scores=[float(s) for s in scores], model=RERANK_MODEL_NAME)
modified services/enrichment/worker.py +10 −1
@@ -20,11 +20,21 @@ from trouveka.config import get_settings
20 20 from trouveka.database import Database
21 21 from trouveka.logging import get_logger
22 22 from trouveka.queue import Coordination
23 −from trouveka.search_core import SearchCore
23 +from trouveka.search_core import EmbedClient, SearchCore
24 24
25 25 log = get_logger("enrichment")
26 26
27 27 GROUP = "enrichers"
28 +BACKFILL_INTERVAL = 15 # secondes entre deux lots de backfill d'embeddings
29 +BACKFILL_BATCH = 64
30 +BACKFILL_CYCLE_TIMEOUT = 120 # un cycle qui dépasse ça est tué (anti-blocage)
31 +
32 +
33 +def _passage_text(src: dict) -> str:
34 + """Texte embeddé : titre + description + début du corps (le signal sémantique
35 + d'une page est concentré en tête; 1200 chars suffisent pour e5-small)."""
36 + parts = [src.get("title") or "", src.get("description") or "", (src.get("body") or "")[:900]]

Diff truncated — file too large.